goroutine 泄漏是 Go 服务里最常见的内存增长原因之一:一个 goroutine 永远等不到它等的事。这篇用七种典型的泄漏写法复现,再用 goroutine profile 定位到具体的行、用等待原因判断类别,并用 Go 1.27.1 里新出现的 goroutineleak profile 让 GC 帮你找出真正的泄漏。
起始 NumGoroutine = 1;每种写法调用 100 次之后 NumGoroutine = 701(7 种泄漏 + 2 种修好的写法)
泄漏的共同点是:goroutine 在等一个再也不会发生的事件。runtime.NumGoroutine() 持续增长是最直接的信号;每个泄漏的 goroutine 约占 2.8 KB(见上一篇),十万个就是 280 MB。两种修好的写法不留任何 goroutine:给 channel 一个缓冲,或让 select 同时等一个能取消的 context。
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:45goroutine 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(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 msGo 1.27.1 的 runtime/pprof 里多了 goroutineleak profile,源码注释把它描述为“stack traces of all leaked goroutines”,写出它之前先运行一次带泄漏检测的 GC。从实验看,它报告的是“等待的同步对象已经没有其它可达引用”的那些:等全局 channel 的 100 个没有报告,因为全局变量还指着那个 channel(这正是它不会误报的原因,也是它的盲区)。检测的具体算法我没有读源码,这里只是实测的行为。leakMutex 少报告了 2 个,原因没查明。