在Go语言的面试中,Goroutine
是一个非常重要的话题,因为它是Go语言并发编程的核心。Goroutine为我们提供了轻量级的线程,并且能够让程序在多核处理器上更高效地运行。在这篇文章中,我将详细讨论Goroutine的基本概念、如何使用它以及一些面试中常见的考察点。
一、Goroutine的基本概念
在Go语言中,Goroutine 是一种并发执行的轻量级线程。每个Goroutine都有独立的栈空间,默认情况下,Go的运行时系统会为每个Goroutine分配2KB的栈空间。栈空间是动态扩展的,这使得Go能够同时运行成千上万的Goroutine,而不会像传统线程那样占用大量的内存资源。
1. 什么是Goroutine泄漏
在生活中泄漏通常指的是某个容易的故障导致资源的浪费,比如汽车的油箱破了导致汽油泄漏,而在计算机领域,泄漏(Leakage)通常指的是某些进程/资源因为被遗忘而忘记回收,占用着系统的资源,最终导致系统资源枯竭而宕机。
在日常GO语言的编码中创建协程是很容易的,但是对goroutine生命周期的管理是不容易的。具体来说如果你创建了一个协程(goroutine),你以为这个goroutine最终会按照预期完成自己的任务然后结束执行,但是由于某些原因,这个goroutine一直没有终止,他占用着系统的资源永不释放,无人管控,最终可能因为大量存在这一类泄漏的协程,系统资源消耗殆尽,导致主程序意外终止或者系统崩溃。
2. 监控泄漏的goroutine
日常开发中遇到goroutine泄漏通常是比较难
2.1 goleak
github地址 https://github.com/uber-go/goleak
goleak 是一个 Go 语言的工具,用于检查 goroutine 泄漏的情况。它可以在测试代码中使用,自动检查测试结束时是否有 goroutine 泄漏,并将泄漏的 goroutine 信息输出。
下面是使用 goleak 检查 goroutine 泄漏的步骤:
- 安装goleak
go get -u golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow
go get -u github.com/uber-go/goleak
- 我们编写一段会产生goroutine泄漏的代码
func LeakDemo1(){
ch := make(chan int) // 创建一个无缓冲区的通道
go func() {
val := <-ch
fmt.Println("哈哈哈哈",val)
}()
}
然后我们在demo1目录下创建一个main_test文件,里面编写一下内容(有goLand这个IDE的可以直接在LeakDemo函数上右键 -> generate -> Test for fuction可以帮助我们快速创建Testing文件)
package main
import (
"go.uber.org/goleak"
"testing"
)
func TestLeakDemo1(t *testing.T) {
defer goleak.VerifyNone(t)
LeakDemo1()
}
这样,当测试用例结束时,goleak 会检查当前是否有 goroutine 泄漏,如果有,则会在控制台输出相关信息,帮助开发人员快速地定位和解决问题。
- 下面我们右键main_test文件运行
发现答案了,goleak帮我们寻找出来了对应的泄漏代码,如下图出现了 found unexpected goroutines
的字样,说明出现了协程泄漏。
VerifyTestMain的运行结果与VerifyNone有一点不同,VerifyTestMain会先报告测试用例执行结果,然后报告泄漏分析,如果测试的用例中有多个goroutine泄漏,无法精确定位到发生泄漏的具体test,需要使用如下脚本进一步分析:
# Create a test binary which will be used to run each test individually
$ go test -c -o tests
# Run each test individually, printing "." for successful tests, or the test name
# for failing tests.
$ for test in $(go test -list . | grep -E "^(Test|Example)"); do ./tests -test.run "^$test\$" &>/dev/null && echo -n "." || echo -e "\n$test failed"; done
需要注意的是,goleak 只能检查 goroutine 泄漏的情况,不能检查内存泄漏或者其他类型的资源泄漏。如果需要检查内存泄漏等问题,需要使用其他工具进行分析。
goleak实现原理
从VerifyNone入口,我们查看源代码,其调用了Find方法:
// Find looks for extra goroutines, and returns a descriptive error if
// any are found.
func Find(options ...Option) error {
// 获取当前goroutine的ID
cur := stack.Current().ID()
opts := buildOpts(options...)
var stacks []stack.Stack
retry := true
for i := 0; retry; i++ {
// 过滤无用的goroutine
stacks = filterStacks(stack.All(), cur, opts)
if len(stacks) == 0 {
return nil
}
retry = opts.retry(i)
}
return fmt.Errorf("found unexpected goroutines:\n%s", stacks)
}
我们在看一下filterStacks方法:
// filterStacks will filter any stacks excluded by the given opts.
// filterStacks modifies the passed in stacks slice.
func filterStacks(stacks []stack.Stack, skipID int, opts *opts) []stack.Stack {
filtered := stacks[:0]
for _, stack := range stacks {
// Always skip the running goroutine.
if stack.ID() == skipID {
continue
}
// Run any default or user-specified filters.
if opts.filter(stack) {
continue
}
filtered = append(filtered, stack)
}
return filtered
}
这里主要是过滤掉一些不参与检测的goroutine stack,如果没有自定义filters,则使用默认的filters:
func buildOpts(options ...Option) *opts {
opts := &opts{
maxRetries: _defaultRetries,
maxSleep: 100 * time.Millisecond,
}
opts.filters = append(opts.filters,
isTestStack,
isSyscallStack,
isStdLibStack,
isTraceStack,
)
for _, option := range options {
option.apply(opts)
}
return opts
}
从这里可以看出,默认检测20次,每次默认间隔100ms;添加默认filters;
总结一下goleak的实现原理:
使用runtime.Stack()方法获取当前运行的所有goroutine的栈信息,默认定义不需要检测的过滤项,默认定义检测次数+检测间隔,不断周期进行检测,最终在多次检查后仍没有找到剩下的goroutine则判断没有发生goroutine泄漏。
3. 常见的Goroutine泄漏的原因
- Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
- Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
- Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。
3.1 无缓冲区的channel
对于无缓冲区的channel来说,最经典的goroutine泄漏无非就是协程在监听无缓冲的channel,由于对于无缓冲区的channle来说,无论是从channel中读还是往channel中写数据,都会造成阻塞。所以很容易出现泄漏问题
A 从空缓存channel中recv数据
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 这行代码将永远被阻塞
fmt.Println("We received a value:", val) // 这行代码永远不会执行
}()
}
上面代码中,函数leak函数创建了一个无缓冲区channel,并且启动一个goroutine从ch管道中接收元素,由于ch管道是无缓冲管道并且没有任何协程往ch中发送数据,所以第5行代码 val := <- ch
将会永久阻塞,永远不会执行leak函数的第6行代码。
B 往空缓存channel里Send数据
同理,如果往一个无缓冲的channel中发送数据也会造成goroutine阻塞
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 这行代码将永远被阻塞
fmt.Println("We received a value:", val) // 这行代码永远不会执行
}()
}
但是没有管理好他的生命周期,导致这些协程被遗忘,让本该结束退出的协程因为没有管制而永远在后台隐秘的运行,并占用着系统的资源,直到系统资源枯竭,程序退出。
1.1 被遗忘的发送者
// search
// @Description: 定义一个搜索接口
// @param term : 搜索的关键字
// @return string : 搜索的返回结果
// @return error
func search(keyword string)(string,error){
time.Sleep(time.Millisecond * 200) // 模拟业务执行搜索需要的时间 假设是200毫秒
return "搜索结果",nil
}
type result struct{
record string
err error
}
func process(key string) error{
ctx,cancel := context.WithTimeout(context.Background(),100*time.Millisecond) // 设置如果超过100毫秒没有返回就终止协程
defer cancel()
ch := make(chan result)
// 开启协程,执行后台搜索
go func() {
record,err := search(key)
ch <- result{
record: record,
err: err,
}
}()
select{
case <- ctx.Done():
return errors.New("搜索超时,被动搜索") // 因为设置了100毫秒的限制
case res :=<-ch:
if res.err != nil{
return res.err
}
fmt.Println("返回搜索结果",res.record)
return nil
}
}
整个函数的执行流程是这样的
由于超时时间不合理,加上无缓冲区管道,导致最终协程泄漏
使用有缓冲区通道
如果我们将无缓冲区通道修改为有缓冲区通道,那么
ctx,cancel := context.WithTimeout(context.Background(),100*time.Millisecond) // 设置如果超过100毫秒没有返回就终止协程
defer cancel()
ch := make(chan result,1)
现在,在超时情况下,在接收器移动之后,搜索Goroutine将通过将结果值放置在通道中完成发送,然后返回。该Goroutine的内存以及通道的内存最终将被回收。一切都会自然而然地解决。
1.2 半途而废的协程
在go语言中,有个规则:
程序先执行init函数,然后执行main函数,在main函数执行完成并返回时候,程序退出,不会等待其他任何子goroutine,所谓的子goroutine就是我们使用go关键字启动的协程
规范很清楚,当程序从主函数返回时,您的程序不会等待任何未完成的Goroutine执行完毕。这是一件好事!想想让一个Goroutine泄漏或让一个Goroutine运行很长时间是多么容易。如果您的程序在终止之前等待非主Goroutine完成,它可能会陷入某种僵尸状态,永远不会终止。
然而,当您启动一个Goroutine来做一些重要的事情,但主函数不知道等待它完成时,这种终止行为就会成为一个问题。这种类型的场景可能会导致完整性问题,例如损坏数据库、文件系统或丢失数据。
type Tracker struct {
}
func (t *Tracker) Event(data string){
time.Sleep(time.Millisecond)
log.Println(data)
}
type App struct{
tracker Tracker
}
func(a *App)Handler(w http.ResponseWriter,r *http.Request){
// 实际编写业务代码
// 返回给客户端
w.WriteHeader(http.StatusOK)
go a.tracker.Event("记录埋点")
}
代码的重要部分是第21行。这就是在新Goroutine的范围内调用a.track.Event方法的地方。这具有异步跟踪事件而不增加请求延迟的预期效果。然而,这段代码落入了不完整的工作陷阱,必须进行重构。在第21行创建的任何Goroutine都不能保证运行或完成。这是一个完整性问题,因为服务器关闭时可能会丢失事件。
担保的重构
为了避免陷阱,团队修改了Tracker类型来管理Goroutines本身。该类型使用sync.WaitGroup来保持打开的Goroutine的计数,并为要调用的主函数提供Shutdown方法,该方法将等待所有Goroutine完成。
首先,处理程序被修改为不直接创建Goroutine。清单4中唯一的变化是它不再包含go关键字去异步调用。
func(a *App)Handler(w http.ResponseWriter,r *http.Request){
// 实际编写业务代码
// 返回给客户端
w.WriteHeader(http.StatusOK)
a.tracker.Event("记录埋点")
}
接下来,Tracker类型被重写以管理Goroutines本身。
type Tracker struct {
wg sync.WaitGroup
}
// Event starts tracking an event. It runs asynchronously to
// not block the caller. Be sure to call the Shutdown function
// before the program exits so all tracked events finish.
func (t *Tracker) Event(data string) {
t.wg.Add(1)
go func() {
defer t.wg.Done()
time.Sleep(time.Millisecond) // Simulate network write latency.
log.Println(data)
}()
}
func(t *Tracker) Shutdown() {
t.wg.Wait()
}
在清单5中,第12行将sync.WaitGroup添加到Tracker的类型定义中。在第21行的Event方法中,调用了t.wg.Add(1)。这会使计数器递增,以说明在第24行创建的Goroutine。一旦创建了Goroutine,Event函数就会返回满足客户端最小化事件跟踪延迟要求的结果。创建的Goroutine执行其工作,完成后在第27行调用t.wg.done()。调用Done方法会减少计数器,以便WaitGroup知道此Goroutine已完成。
对Add和Done的调用对于跟踪活动Goroutine的数量很有用,但程序仍必须被指示等待它们完成。为此,Tracker类型在第35行获得了一个新的方法Shutdown。此函数的最简单实现是调用t.wg.Wit(),该函数将一直阻塞,直到Goroutine计数减为0。最后,必须从func main调用此方法,如清单6所示:
func main() {
// Start a server.
// Details not shown...
var a App
// Shut the server down.
// Details not shown...
// Wait for all event goroutines to finish.
a.track.Shutdown()
}
清单6的重要部分是第66行,它阻止func main终止,直到a.track.Shutdown()完成。
但也许不要等太久
Shutdown方法的实现很简单,可以完成所需的工作;它等待Goroutines完成。不幸的是,它等待的时间没有限制。根据您的生产环境,您可能不愿意无限期地等待程序关闭。为了给Shutdown方法添加截止日期,团队将其更改为:
清单7
// Shutdown waits for all tracked events to finish processing
// or for the provided context to be canceled.
func (t *Tracker) Shutdown(ctx context.Context) error {
// Create a channel to signal when the waitgroup is finished.
ch := make(chan struct{})
// Create a goroutine to wait for all other goroutines to
// be done then close the channel to unblock the select.
go func() {
t.wg.Wait()
close(ch)
}()
// Block this function from returning. Wait for either the
// waitgroup to finish or the context to expire.
select {
case <-ch:
return nil
case <-ctx.Done():
return errors.New("timeout")
}
}
现在在清单7的第38行,Shutdown方法接受context.context作为输入。这就是调用者如何限制允许关机等待的时间。在第41行的函数中,创建一个频道,然后在第45行启动一个Goroutine。这个新的Goroutine的唯一任务是等待WaitGroup完成,然后关闭频道。最后,行52开始一个选择块,它等待上下文被取消或频道被关闭。
接下来,团队将func main中的调用更改为:
清单8
// Wait up to 5 seconds for all event goroutines to finish.
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
err := a.track.Shutdown(ctx)
在清单8中,在主函数中创建了一个具有5秒超时的上下文。这将传递给.track.Shutdown以设置main愿意等待多长时间的限制。
结论
随着Goroutines的引入,该服务器的处理程序能够将需要跟踪事件的API客户端的延迟成本降至最低。只使用go关键字在后台运行这项工作很容易,但该解决方案存在完整性问题。要做到这一点,需要在关闭程序之前努力确保所有相关的Goroutine都已终止。
并发是一个有用的工具,但必须谨慎使用。
有人说可以使用pprof来排查,虽然其可以达到目的,但是这些性能分析工具往往是在出现问题后借助其辅助排查使用的,有没有一款可以防患于未然的工具吗?当然有,goleak他来了,其由 Uber 团队开源,可以用来检测goroutine泄漏,并且可以结合单元测试,可以达到防范于未然的目的,本文我们就一起来看一看goleak。
不知道你们在日常开发中是否有遇到过goroutine泄漏,goroutine泄漏其实就是goroutine阻塞,这些阻塞的goroutine会一直存活直到进程终结,他们占用的栈内存一直无法释放,从而导致系统的可用内存会越来越少,直至崩溃!简单总结了几种常见的泄漏原因:
- Goroutine内的逻辑进入死循坏,一直占用资源
- Goroutine配合channel/mutex使用时,由于使用不当导致一直被阻塞
- Goroutine内的逻辑长时间等待,导致Goroutine数量暴增
接下来我们使用Goroutine+channel的经典组合来展示goroutine泄漏;
func GetData() {
var ch chan struct{}
go func() {
<- ch
}()
}
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
GetData()
time.Sleep(2 * time.Second)
}
这个例子是channel忘记初始化,无论是读写操作都会造成阻塞,这个方法如果是写单测是检查不出来问题的:
func TestGetData(t *testing.T) {
GetData()
}
=== RUN TestGetData
--- PASS: TestGetData (0.00s)
PASS
内置测试无法满足,接下来我们引入goleak来测试一下。
goleak
github地址:https://github.com/uber-go/goleak
使用goleak主要关注两个方法即可:VerifyNone、VerifyTestMain,VerifyNone用于单一测试用例中测试,VerifyTestMain可以在TestMain中添加,可以减少对测试代码的入侵,举例如下:
使用VerifyNone:
func TestGetDataWithGoleak(t *testing.T) {
defer goleak.VerifyNone(t)
GetData()
}
运行结果
=== RUN TestGetDataWithGoleak
leaks.go:78: found unexpected goroutines:
[Goroutine 35 in state chan receive (nil chan), with asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1 on top of the stack:
goroutine 35 [chan receive (nil chan)]:
asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1()
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:12 +0x1f
created by asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:11 +0x3c
]
--- FAIL: TestGetDataWithGoleak (0.45s)
FAIL
Process finished with the exit code 1
通过运行结果看到具体发生goroutine泄漏的具体代码段;使用VerifyNone会对我们的测试代码有入侵,可以采用VerifyTestMain方法可以更快的集成到测试中:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
=== RUN TestGetData
--- PASS: TestGetData (0.00s)
PASS
goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 5 in state chan receive (nil chan), with asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1 on top of the stack:
goroutine 5 [chan receive (nil chan)]:
asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData.func1()
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:12 +0x1f
created by asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector.GetData
/Users/go/src/asong.cloud/Golang_Dream/code_demo/goroutine_oos_detector/main.go:11 +0x3c
]
Process finished with the exit code 1
四、面试常见考察点
在Go语言面试中,关于Goroutine的考察通常会包括以下几个方面:
Goroutine的创建与使用
面试官可能会问你如何在Go中创建Goroutine,如何同步多个Goroutine的执行,如何确保主函数在所有Goroutine完成后再退出。通道(Channel)的使用
作为Go中非常重要的并发工具,通道的使用是面试中的常见问题。你可能会被要求用通道来解决一些常见的并发问题,比如Goroutine之间的数据传递、同步等。Goroutine的生命周期
面试官可能会问你如何管理Goroutine的生命周期,尤其是如何避免Goroutine泄漏(Goroutine Leak),即某个Goroutine无法退出或被及时回收。调度和性能
你可能会被问到Go的调度机制,如何高效地利用多个CPU核心,如何通过调整GOMAXPROCS
来影响调度器的行为,以及如何诊断和优化并发程序的性能。
五、Goroutine的性能优化
尽管Goroutine非常轻量,但它的性能仍然受到调度器的影响。如果Goroutine的数量过多,或者它们频繁进行上下文切换,可能会导致性能下降。以下是一些优化Goroutine性能的建议:
减少Goroutine的数量
不要随意创建大量的Goroutine。创建过多的Goroutine会导致调度器的开销增加,从而影响程序的整体性能。使用缓冲通道
如果你需要在Goroutine之间传递大量数据,可以使用缓冲通道来减少阻塞和等待时间。避免共享内存
Go语言提倡通过通道而不是共享内存来进行Goroutine之间的通信。避免共享内存可以避免竞态条件,并使代码更加可预测。合理使用
sync.Pool
在需要频繁创建和销毁对象的场景下,可以考虑使用sync.Pool
来重用对象,避免频繁的内存分配和垃圾回收开销。
六、总结
Goroutine是Go语言的核心特性之一,它为并发编程提供了非常高效且简洁的解决方案。在面试中,Goroutine相关的问题不仅考察你的并发编程能力,还考察你对Go语言内部机制的理解。通过掌握Goroutine的创建、同步、调度和性能优化技巧,能够在Go语言面试中表现得更加出色。
希望通过这篇文章,对Goroutine有了更加深入的理解,并能够在实际工作中灵活应用这些知识。如果正在准备Go语言面试,建议多做一些实际的并发编程练习,掌握如何有效地使用Goroutine来处理高并发问题。