Goroutine Leaks引发的思考(Go语言)

admin 2023年9月8日15:03:39评论13 views字数 5374阅读17分54秒阅读模式

本期作者


Goroutine Leaks引发的思考(Go语言)


邹靓

哔哩哔哩创平高级测试开发


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


背景


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


排查过程


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

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


Goroutine Leaks引发的思考(Go语言)
Goroutine Leaks引发的思考(Go语言)


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信息)。


Goroutine Leaks引发的思考(Go语言)


从上图中的耗时数据可以看出,rutime.gopark函数占了大头,这代表着存在大量goroutine在调用了runtime.gopark函数后都被暂停了,即被放入等待状态,这可能导致goroutine线程阻塞,这显然是不健康的状态,那么既然知道了时间大量花费在rutime.gopark函数的执行上,接着开始找的就是什么东西在内存中占据了大量位置。


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使用不当相关,接下来让我们来找找证据,确定这个猜想。


Goroutine Leaks引发的思考(Go语言)


 3、找到证据,确认猜想:

是否是gRPC引起goroutine的阻塞?


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


Goroutine Leaks引发的思考(Go语言)


通过登陆问题服务器,可以明显看到大量gotoutine在调用栈 google.golang.org/[email protected]/internal/transport/http2_client.go:340 的地方阻塞着(图2中标记黄色的代表阻塞的goroutine数量以及调用栈,代码函数;标记红色的地方代表goroutine阻塞的总数量),随即查看 TCP 连接数果然在问题发生时间段前后有出现断层、且数量在持续激增(图3所示中标记红色的地方可以看出存在明显断层,标记黄色的地方可以看到TCP连接数持续激增的趋势)。


Goroutine Leaks引发的思考(Go语言)
Goroutine Leaks引发的思考(Go语言)


通过切入点的查看最终可以基本确认了我们之前的猜想,是由于gRPC-Client的异常创建引起goroutine的数量阻塞,并引起了本次goroutine泄漏。


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数量激增的情况。


Goroutine Leaks引发的思考(Go语言)
Goroutine Leaks引发的思考(Go语言)


答疑解惑


本次问题已经排查完毕,找到原因并解决,但是仍有几个疑问。


1、goroutine到底是个啥?


在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,少了任何一步都不可以完成并发编程,而Go语言中的为了实现简单方便的的并发编程的机制,引入了goroutine(协程)跟必不可少的GMP模型。简单来说就是Go程序在运行的时候会智能地将goroutine中的任务合理地分配给每个CPU。在Go语言编程中你不需要去自己写进程、线程、协程,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了( go func() ),非常简单粗暴[6]。


Goroutine Leaks引发的思考(Go语言)


2、Go不是有GC吗,为什么还会有

不同形式的内存泄漏的发生?


GC是清理不再使用的对象,GO的GC主要的触发机制有手动跟自动,手动回收需要开发自己调用runtime.GC(),自动回收的触发时机分为两种,第一种是超过内存大小阈值(控制器计算的触发堆大小)、第二种是定时触发(默认2min触发一次)。在本次线上问题中我们遇到的是gRpc-client长连接不断重复创建新的 goroutine但是没有被正确的合理复用导致的泄漏问题(属于持续不断地产生新的 goroutine、在goroutine生命周期没有结束的情况下GC不会清理这块内存)。


3、goroutine泄露除了本次的场景,

到底还有多少其他场景?


除了错误创建gRPC-Client连接还有关于通道(channel)的场景,比如 channel 的读或者写:

1.无缓冲channel的阻塞通常是写操作因为没有读而阻塞;

2.有缓冲的channel因为缓冲区满了,写操作阻塞;

3.期待从channel读数据,结果没有goroutine写数据;

4.使用是当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放;

5.select操作,select里也是channel操作,如果所有case上的操作阻塞,goroutine也无法继续执行[6];

6.一个goroutine尝试向一个没有接收方的无缓冲channel发送消息。

当然还有别的类型的内存泄漏问题,从这几个例子中简单举几个demo说明,方便以后遇到了踩坑。

比如第4种、跟第6种形式的内存泄漏例子具体如下[7]:


测试函数 


第4种错误使用。例如:

var cache = map[interface{}]interface{}{}func keepalloc() {            for i := 0; i < 10000; i++ {                         m := make([]byte, 1<<10)                         cache[i] = m           }}


第6种错误使用。例如:

var ch = make(chan struct{})func keepalloc2() {            for i := 0; i < 100000; i++ {                        // 没有接收方,goroutine 会一直阻塞                       go func() { ch <- struct{}{} }()           }}


调用函数测试 


package main

import (    "os"    "runtime/trace")

func main() {              f, _ := os.Create("trace.out")              defer f.Close()              trace.Start(f)              defer trace.Stop()              keepalloc()              keepalloc2()}


go run main.go运行代码以后会有一个trace.out文件生成用命令go tool trace trace.out来查看发现堆(Heap)跟goroutine线程数一直在增长,如图6所示这两个案例中的Heap并没有被清理掉,最终可能酿成内存泄漏的大祸。


Goroutine Leaks引发的思考(Go语言)


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


目前通过手动功能测试是无法发现goroutine泄露,所以一般借助工具来写单测实现泄漏检测。比如goleak[8] 这个工具主要的方法有两种:

1.第一种是VerifyNone ——针对单一的test case,在每个测试用例结束检测有没有泄漏(这种方法可能会对测试用例的代码有入侵)。

具体代码:

func TestA(t *testing.T) {            defer goleak.VerifyNone(t)           // test logic here.}


2.第二种方法是VerifyTestMain ——针对test package ,它会创建一个函数在每个 test package 结束后检测有没有泄漏。

具体代码:

func TestMain(m *testing.M) {              goleak.VerifyTestMain(m)}


还有一些针对VerifyTestMain的方式可能存在无法确定是哪个test case引起的,就可以通过bash脚本来确定来源:

# 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


总结


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


参考


[1]https://github.com/google/gops

[2]https://pkg.go.dev/net/http/pprof

[3]https://lessisbetter.site/2019/05/18/go-goroutine-leak/

[4]https://github.com/googleapis/google-cloud-go

[5]https://learnku.com/articles/58641

[6]https://www.topgoer.com/

[7]https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.7.GC-GC.md

[8]https://github.com/uber-go/goleak

原文始发于微信公众号(哔哩哔哩技术):Goroutine Leaks引发的思考(Go语言)

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年9月8日15:03:39
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Goroutine Leaks引发的思考(Go语言)https://cn-sec.com/archives/2018393.html

发表评论

匿名网友 填写信息