bigywater.tech
Go · 56

goroutine 泄漏:怎么发现、怎么定位到那一行

goroutine 泄漏是 Go 服务里最常见的内存增长原因之一:一个 goroutine 永远等不到它等的事。这篇用七种典型的泄漏写法复现,再用 goroutine profile 定位到具体的行、用等待原因判断类别,并用 Go 1.27.1 里新出现的 goroutineleak profile 让 GC 帮你找出真正的泄漏。

701
七种泄漏各 100 个之后的 NumGoroutine(起始 1,两种修好的写法没有留下 goroutine)
main.go:19
goroutine profile 直接给出每组泄漏 goroutine 阻塞的源码行
598 个
goroutineleak profile 报告的泄漏数(700 个阻塞的 goroutine 里,等全局 channel 的 100 个不算)

复现与现象

实测 · 本机执行输出
  起始 NumGoroutine = 1;每种写法调用 100 次之后 NumGoroutine = 701(7 种泄漏 + 2 种修好的写法)

泄漏的共同点是:goroutine 在等一个再也不会发生的事件。runtime.NumGoroutine() 持续增长是最直接的信号;每个泄漏的 goroutine 约占 2.8 KB(见上一篇),十万个就是 280 MB。两种修好的写法不留任何 goroutine:给 channel 一个缓冲,或让 select 同时等一个能取消的 context。

goroutine profile 定位

实测 · 本机执行输出
  goroutine profile(701 个 goroutine):
     100 个 @ main.blockedGlobal.func1 main.go:25
     100 个 @ main.leakMutex.func1 main.go:22
     100 个 @ main.leakNil.func1 main.go:21
     100 个 @ main.leakRecv.func1 main.go:20
     100 个 @ main.leakSelect.func1 main.go:24
     100 个 @ main.leakSend.func1 main.go:19
     100 个 @ main.leakWG.func1 main.go:23
       1 个 @ main.summarize main.go:45

goroutine profile 把栈相同的 goroutine 合并并给出个数。debug=1 输出里每个栈的最下面一行用户代码就是它阻塞的位置(main.go:19 到 main.go:25)。可以通过 net/http/pprof 的 /debug/pprof/goroutine?debug=1 在线上抓,本文没有试。

等待原因

实测 · 本机执行输出
    [chan receive]           200 个
    [chan receive (nil chan)] 100 个
    [chan send]              100 个
    [running]                1 个
    [select (no cases)]      100 个
    [sync.Mutex.Lock]        100 个
    [sync.WaitGroup.Wait]    100 个

debug=2 的完整转储里,每个 goroutine 的标题带着等待原因,例如 [chan send]、[chan receive (nil chan)]、[select (no cases)]、[sync.Mutex.Lock]。这些词本身就能告诉你是哪类问题:nil channel 通常是忘了初始化,空 select 是写错了,等锁是忘了解锁。

goroutineleak profile

实测 · 本机执行输出
  goroutineleak profile(598 个 goroutine):
      98 个 @ main.leakMutex.func1 main.go:22
     100 个 @ main.leakNil.func1 main.go:21
     100 个 @ main.leakRecv.func1 main.go:20
     100 个 @ main.leakSelect.func1 main.go:24
     100 个 @ main.leakSend.func1 main.go:19
     100 个 @ main.leakWG.func1 main.go:23
  生成 goroutineleak profile 耗时 2.8 ms

Go 1.27.1 的 runtime/pprof 里多了 goroutineleak profile,源码注释把它描述为“stack traces of all leaked goroutines”,写出它之前先运行一次带泄漏检测的 GC。从实验看,它报告的是“等待的同步对象已经没有其它可达引用”的那些:等全局 channel 的 100 个没有报告,因为全局变量还指着那个 channel(这正是它不会误报的原因,也是它的盲区)。检测的具体算法我没有读源码,这里只是实测的行为。leakMutex 少报告了 2 个,原因没查明。

分步演示

goroutine 泄漏
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。每种写法调用 100 次;profile 用 runtime/pprof 在进程内取得并解析(debug=1 的文本按栈聚合、debug=2 的标题行统计等待原因);为避免内联改变栈,编译时加了 -gcflags=-l;goroutineleak 是 Go 1.27.1 的 pprof.Profiles() 里列出的 profile,源码里的描述见 runtime/pprof/pprof.go
    • 没有验证:goroutineleak profile 的检测算法与误报漏报边界(只验证了七种写法)、在线上通过 net/http/pprof 抓取、泄漏的 goroutine 持有的内存与 channel 缓冲对堆的影响、goleak 一类测试库的用法、时间驱动的泄漏(如忘了 Stop 的 Ticker)是否被 profile 报告
    京ICP备2026067502号-1京公网安备11010502063487号