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.
引言:江湖中有个传说: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连接数过多的内存溢出。
Bilibili Tech
Provides introductions and tutorials on Bilibili-related technologies.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.