Goroutine泄露排查

* 本页面主要介绍Goroutine泄露排查的相关内容。

Goroutine泄露问题,是我们在Go程序性能分析中经常会遇到的问题。那么怎么对这类问题进行排查和解决呢?这里就给大家做一个详细的总结。

一、Goroutine泄露啦

在发布一个Go应用时,我们会默认开启两个http handler: 一个是 pprof,方便线上动态追踪问题;另外一个是 prometheus 的 metrics,这样就可以通过 grafana 准实时的监控当前 runtime 信息,及时预警。类似下图显示的,明显这个Go应用中存在Goroutine泄露问题:因为随着时间的推移,goroutine 在持续上涨!

监控显示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()
  • WaitGroup:WaitGroup 和锁有所差别,它类似 Linux 中的信号量,可以实现一组 goroutine 操作的等待。使用的时候,如果设置了错误的任务数,也可能会导致阻塞,导致泄露发生。解决这种情况的优良实践是,尽量不要一次性设置全部任务数量,而是每次启动时通过 wg.Add(1)的方式增加。
  •   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泄露进行了一个全面的总结,希望对大家学习这方面的知识有所帮助。你学废了吗?


* 本页内容参考以下数据源:

  • 《Go程序设计语言》
  • https://zhuanlan.zhihu.com/p/74090074
  • https://zhuanlan.zhihu.com/p/75555215
  • https://studygolang.com/articles/28416

凯冰科技 · 代码改变世界,技术改变生活
下一篇:内存泄露排查 →