Backend Development 13 min read

Investigation of Goroutine Leak in Go

The article details how a sudden surge in goroutine and heap usage was traced to repeatedly creating gRPC clients instead of reusing a singleton, leading to blocked goroutines and TCP connections, and explains using pprof, stack traces, and tools like goleak to detect and prevent such leaks.

Bilibili Tech
Bilibili Tech
Bilibili Tech
Investigation of Goroutine Leak in Go

引言:江湖中有个传说:10次内存泄露,9次都是goroutine泄露。(Go语言)

前段时间,通过监控观察到,我们业务中的某个服务突然出现goroutine数量、堆对象数量激增的情况,作为测试人员的我们跟着开发一步一步揭开了问题的真相,同时通过这个问题的排查也引发了我们的一些思考,如果你也有遇到过类似问题,毫无头绪的时候,欢迎阅读本文,跟我一起拨开层层迷雾,找到问题真凶。(一切问题都只是纸老虎~)

排查过程:当发生goroutine数量激增的时候,我们的主要排查的思路一般都是先通过直接调用runtime.NumGoroutine()或者使用可视化的goroutine数量监控工具来查看goroutine数量,通过对比异常时间段前后goroutine的数量是不是持续不正常增长(业务不在高峰期的时候突然翻N倍被视为异常)来确认是否是 goroutine泄漏,一旦确认,那么接下来就可通过找到goroutine泄漏的问题作为切入点(忘记设置默认的请求超时时间、跟redis、sql交互超时、向已关闭的通道发数据等等)从而找到根因。

那么我们回到这个业务例子吧!本次可以通过监控看到,问题发生时间前后数量差距巨大且是持续增长的趋势,确定是goroutine泄漏,到底是什么导致了泄漏呢?那么接下来,就必须找到切入点根治它。

1、切入点1:找出最耗时的地方在哪里

当我们遇到goroutine相关的问题,第一个步骤就可以先打印异常的goroutine stack trace信息,这样需要选择合适的工具以及方法,目前获取stack trace异常信息的方式主要有:1.使用panic来获取异常退出的stack trace信息(不巧的是,本次事件没有明显错误异常);2.使用SIGQUIT信号来终止没有panic但是有异常的程序,并获取core文件来查看stack trace信息(但这个方式可能破坏第一现场);3.使用开源工具例如gops[1]进行堆栈跟踪(自行到下载第三方开源工具并使用);4.使用go tool pprof http://ip:port/debug/pprof/goroutine获取内存统计信息、goroutine信息以及其他性能分析数据[2](根据个人喜好,本次笔者使用的是pprof获取goroutine信息)。

2、切入点2:找到最耗内存的地方在哪里

那么,最耗内存的,究竟是何方妖孽呢?我们仍然是通过pprof执行go tool pprof -alloc_space http://ip:port/debug/pprof/heap和top命令找到top1耗内存的位置是:google.golang.org/grpc/internal/transport.newBufWriter(golang软件包中gRPC的内部方法),这代表我们这次的泄漏极有可能跟gRPC使用不当相关,接下来让我们来找找证据,确定这个猜想。

3、找到证据,确认猜想:是否是gRPC引起goroutine的阻塞?

我们还是通过pprof执行http://ip:port/debug/pprof/goroutine?debug=1 or 2(debug=1 可以查看某条调用路径上,阻塞在此goroutine的数量。debug=2则可以查看所有goroutine的运行栈(调用路径),可以显示阻塞在此的时间[3])两种命令方式都可以查看当前阻塞在此goroutine的数量以及运行栈,乍一看这些密密麻麻的调用栈信息确实让人眼花缭乱,所以标记出了最重要的信息含义方便理解(图1[3]所示)。

4、罪魁祸首锁定:找到造成本次泄漏的代码

通过开发的code review问题发生时间段上线的新代码可以看到在业务代码中使用到了Google Cloud Client Libraries for Go,这类库通常在服务端使用gRPC来连接Google Cloud API[4]。本次问题代码中Client应该写成单例模式使用,不应该写在具体的函数中每次请求就创建一个新的Client,且go t.keepalive() 并发调用导致大量请求下会有越来越多的client keepalive(图4、图5)而这些client连接都是长连接不会立马关闭,最终导致出现了TCP连接数量和groutine数量激增的情况。

5、怎么在测试的时候发现goroutine泄露?

目前通过手动功能测试是无法发现goroutine泄露,所以一般借助工具来写单测实现泄漏检测。比如goleak[8]这个工具主要的方法有两种:1.第一种是VerifyNone——针对单一的test case,在每个测试用例结束检测有没有泄漏(这种方法可能会对测试用例的代码有入侵);2.第二种方法是VerifyTestMain——针对test package,它会创建一个函数在每个 test package 结束后检测有没有泄漏。

总结:通过这次的goroutine泄漏的学习印证了Dave的那句话:“Never start a goroutine without knowing how it will stop。” 当一个启用跟运行成本低的goroutine却被人遗忘它也会占用其他成本(CPU、RAM)时,往往会发生滥用、无人管制最终酿成泄漏的悲惨下场。我们在使用gRPC-Client的时候应该做好复用client,并在使用完的时候做好close,不然会出现TCP连接数过多的内存溢出。

DebuggingGogRPCPerformance TuningMemory Leakgo programminggoroutine
Bilibili Tech
Written by

Bilibili Tech

Provides introductions and tutorials on Bilibili-related technologies.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.