Goroutine泄露问题,是我们在Go程序性能分析中经常会遇到的问题。那么怎么对这类问题进行排查和解决呢?这里就给大家做一个详细的总结。
一、Goroutine泄露啦
在发布一个Go应用时,我们会默认开启两个http handler: 一个是 pprof,方便线上动态追踪问题;另外一个是 prometheus 的 metrics,这样就可以通过 grafana 准实时的监控当前 runtime 信息,及时预警。类似下图显示的,明显这个Go应用中存在Goroutine泄露问题:因为随着时间的推移,goroutine 在持续上涨!
我们先从直观上,感受了一下Goroutine泄露。虽然 goroutine 是轻量级的线程,占用资源很少,但如果一直得不到释放并且还在不断创建新协程,随着时间的推移,程序最终可能就会因为内存耗尽而引发服务终止等问题。所以,我们必须采取一些手段去处理。一般,我们会从预防和监控两个方面入手!
二、如何预防?
预防, 永远要放在解决问题的首位。这个道理大家都懂,就跟新冠疫情一样,预防比救治更重要。
要做到预防,我们就需要了解什么样的代码会产生泄露,以及了解如何写出正确的代码。
Go语言原生支持高并发,简单的一个go关键字就可以启动一个goroutine,这样就造成这个优势很容易被滥用。如果是一个常驻程序,比如http server,每接收到一个请求,便会启动一个goroutine,时间流逝,每次启动的 goroutine 都得不到释放,你的服务将会离奔溃越来越近。那么,我们要怎么预防呢?先明确泄露的分类!
泄露情况分类
按照并发的数据同步方式对泄露的各种情况进行分析。简单可归于两类,即:
- channel 导致的泄露
- 传统同步机制导致的泄露:主要指面向共享内存的同步机制,比如排它锁、共享锁等
※ 在 通道channel 这一节中,我们总结过什么情况下,channel会引起阻塞。
- channel 发送不接收:问题根源是发送者不知道接收者已经关闭,还在往通道里发送数据。所以,解决方案就是通过 channel 的关闭向所有的接收者发送广播信息。
- channel 接收不发送:一般来讲很少会只接收不发送,大多数情况是发送已完成,但是发送者并没有关闭 channel,接收者自然也无法知道发送完毕,阻塞因此就发生。解决方案是发送完成后一定要记得关闭 channel。
- 操作nil channel:向 nil channel 发送和接收数据都将会导致阻塞。定义channel之后一定记得初始化再操作!
※ 在 共享变量实现并发 和 同步工具 两节中,我们分别详细总结了锁和 WaitGroup 的详细用法。接下来,我们看下这两个工具经常会导致goroutine泄露的情况:
- Mutex:在使用Mutex时,Lock之后忘记Unlock造成泄露。这种情况我们可以借助Defer完成“配对”解锁。
mutex.Lock()
defer mutext.Unlock()
wg.Add(1)
go func() {
fmt.Println("操作1")
wg.Done()
}()
综上来看,只要是会造成阻塞的写法都可能会产生泄露。所以,防止泄露也就转变为了防止发生阻塞。为了进一步防止泄露,有些实现中也会加入超时处理,主动释放时间过长的goroutine。但毕竟是人写代码,终究难免可能犯错,那有没有方便的方法,来“自动化”发现代码中是否存在泄露风险呢?当然以后,各位看官且往下看!
Leak Test
可以通过一个开源包实现,包的名称是 leaktest,即泄露测试的意思。利用 leaktest,我们测试下下面写的 http 处理函数 query示例。因为要检测 handler 是否泄露,如果经过网络就会丢失服务端的相关信息,这时,我们可以借助 Go 中的 net/http/test 包完成测试。
func Test_Query(t *testing.T) {
defer leaktest.Check(t)()
//创建一个请求
req, err := http.NewRequest("GET", "/query", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
//直接使用 query(rr,req)
query(rr, req)
// 其他测试
// ...
}
测试结果如下:
=== RUN Test_Query
--- FAIL: Test_Query (5.01s)
leaktest.go:162: leaktest: context canceled
leaktest.go:168: leaktest: leaked goroutine: goroutine 20 [chan send]:
study/goroutine/leak/06.query.func2(0xc0001481e0)
/Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:24 +0x37
created by study/goroutine/leak/06.query
/Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:23 +0x7e
FAIL
从输出信息中,我们可以明确地知道出现了泄露,并且通过输出堆栈很快就能定位出现问题的代码。测试代码非常简单,在测试函数开始通过 defer 执行 leaktest 的 Check。
leaktest提供的三个检测函数,分别是 Check、CheckTimeout 和 CheckContext,从前到后的实现一个比一个底层。Check 默认会等待五秒再执行检测,如果需要改变这个时间,可以使用 CheckTimeout 函数。leaktest 的实现原理也和堆栈有关,源码不多,如果有兴趣可以读读,源码文件地址。
知道了怎么预防和自动检测代码的泄露风险,那么接下来,我们再看看如何监控?
三、如何监控?
虽说预防减少了泄露产生的概率,但没有人敢说自己不犯错,因而,通常我们还需要一些监控手段进一步保证程序的健壮性。
简单的方法之一是通过 runtime.NumGoroutine 获取当前运行中的 goroutine 数量粗略估计。
NumGoroutine
runtime.NumGoroutine 可以获取当前进程中正在运行的 goroutine 数量,观察这个数字可以初步判断出是否存在 goroutine 泄露异常。例如:
package main
import (
"net/http"
"runtime"
"strconv"
)
func write(w http.ResponseWriter, data []byte) {
_, _ = w.Write(data)
}
func count(w http.ResponseWriter, r *http.Request) {
write([]byte(strconv.Itoa(runtime.NumGoroutine())))
}
func main() {
http.HandleFunc("/_count", count)
http.ListenAndServe(":6080", nil)
}
上面的例子,启动服务后访问 localhost:6080/_count 即可。但并不是这个数值很大,就代表出现了goroutine泄露。因为高并发情况下,依然会有很高的goroutine数值,所以,需要再引入时间参考线,即观察不同时刻,随着时间的增加,数量在不断上升,基本上没有下降,则基本可以确定存在泄露点。比如我们再增加一个会因为channel问题造成泄露的方法,绑定到路由/query上,代码如下:
func query(w http.ResponseWriter, r *http.Request) {
c := make(chan byte)
go func() {
c <- 0x31
}()
go func() {
c <- 0x32
}()
go func() {
c <- 0x33
}()
rs := make([]byte, 0)
for i := 0; i < 2; i++ {
rs = append(rs, <-c)
}
write(w, rs)
}
func main() {
http.HandleFunc("/query", query)
http.ListenAndServe(":6080", nil)
}
然后,通过下面的命令:总共访问 1000 次,并发访问 100 次。 做个简单的压测,然后观察goroutine数量的变化。
$ ab -n 1000 -c 100 localhost:6080/query
但这个方式只适合于非常简单的程序泄露排查,对于复杂项目就显得力不从心了。那我们可以使用以下辅助工具来协助排查。
pprof
pprof是由 Go 官方提供的可用于收集程序运行时报告的工具,其中包含 CPU、内存等信息。当然,也可以获取运行时 goroutine 堆栈信息,如此一来,我们就可以很容易看出哪里导致了 goroutine 泄露。关于它的使用,我们在上一节 Go性能分析工具 有过详细的独立总结,这里咱们还是以上面的例子为蓝本,简单举例pprof如何使用即可。
import "runtime/pprof"
func goroutineStack(w http.ResponseWriter, r *http.Request) {
_ = pprof.Lookup("goroutine").WriteTo(w, 1)
}
通过绑定路由/_goroutine,我们可以得到如下信息:
goroutine profile: total 1004
948 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233b37 0x10595d1
# 0x1233b36 main.query.func2+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:20
45 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233ae7 0x10595d1
# 0x1233ae6 main.query.func1+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:16
7 @ 0x102e70b 0x102e7b3 0x10068ed 0x10066c5 0x1233b87 0x10595d1
# 0x1233b86 main.query.func3+0x36 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/main.go:24
1 @ 0x102e70b 0x1029ba9 0x1029256 0x108b7da 0x108b8ed 0x108c216 0x112f80f 0x113b348 0x11f5f6a 0x10595d1
# 0x1029255 internal/poll.runtime_pollWait+0x65 /usr/local/go/src/runtime/netpoll.go:173
# 0x108b7d9 internal/poll.(*pollDesc).wait+0x99 /usr/local/go/src/internal/poll/fd_poll_runtime.go:85
# 0x108b8ec internal/poll.(*pollDesc).waitRead+0x3c /usr/local/go/src/internal/poll/fd_poll_runtime.go:90
# 0x108c215 internal/poll.(*FD).Read+0x1d5 /usr/local/go/src/internal/poll/fd_unix.go:169
# 0x112f80e net.(*netFD).Read+0x4e /usr/local/go/src/net/fd_unix.go:202
*******************下省略*******************
通过统计信息,我们看到,当前共有 1004 个 goroutine 在运行。接下来的部分,主要是具体介绍每个 goroutine 的情况,相同函数的 goroutine 会被合并统计,并按数量从大到小排序。输出前三段就是我们在 query 函数中开启的三个 goroutine。分别是 main.query.func1、main.query.func2 以及 main.query.func3,对应于它们,当前仍在运行中的 goroutine 数量分别是 45、948、7。看样子泄露的 goroutine 函数分布并非均匀。几个函数都是匿名的,如果我们需要确定具体位置,可以通过堆栈实现。比如 func1,明确指出了位于的所在文件和代码行数。
http/net/pprof
上面的例子,我们自己写了一个HTTP服务来收集pprof信息,其实官方已经实现了这个功能,而且不仅仅只有goroutine,还有CPU、内存等。使用很简单,只需要服务启动时导入 net/http/pprof 即可。接着访问地址 /debug/pprof/goroutine?debug=1,将会可以看到与上一节输出的相同内容。
gops
gops命令,支持列出当前环境下的 Go 进程,并支持对 Go 程序的诊断。默认情况下,gops 可列出进程,但是并不支持对进程进行成诊断。需要在代码中做相应操作。
$ gops
97778 96800 gops go1.11.1 /usr/local/go/bin/gops
97605 73594 leaker* go1.11.1 /Users/polo/Public/Work/go/src/study/goroutine/leak/06/leaker
当前只有两个 go 进程在运行。leaker后面多了个*,这代表这个程序支持通过gops进行诊断,是因为在leaker加入了诊断支持代码:
func main() {
if err := agent.Listen(agent.Options{ShutdownCleanup: true}); err != nil {
log.Fatalln(err)
}
...
}
这样,就可以根据PID来进行单独诊断了:
$ gops stats 97605
goroutines: 1004
OS threads: 14
GOMAXPROCS: 8
num CPU: 8
gops 也可以查看堆栈,我们只需执行 gops stack PID 即可。
四、总结
这一节,咱们从“预防”和“监控”两个方向对goroutine泄露进行了一个全面的总结,希望对大家学习这方面的知识有所帮助。你学废了吗?