Go 1.27 goroutineleak profile:在 pprof 里只看到泄漏的 goroutine
goroutine 泄漏指的是:一个 goroutine 被阻塞,而解除阻塞所需的条件永远不会满足。泄漏会持续推高内存占用和 GC 的 CPU 消耗,开了 GOMEMLIMIT 的程序尤其明显。
单测层面有 goleak,Go 1.25 的 testing/synctest 也能覆盖一部分场景,但都没法在生产环境用。线上通常看 goroutine profile,而它只能看趋势:分不清是「泄漏」还是「流量高时临时大量阻塞」,少量的泄漏可能拖几年都发现不了。
Go 1.27 引入了 goroutine leak profiler,通过 runtime/pprof 的新 profile 类型 goroutineleak 提供。已接入 net/http/pprof 的服务无需改动,端点就是 /debug/pprof/goroutineleak。它的判定精确、几乎不误报,代价是只覆盖永久阻塞在 channel 或 sync 包原语上的 goroutine。
参考:
怎么用
典型场景:processWorkItems 用无缓冲 channel 收集并发结果,某条结果带 err 时父 goroutine 提前返回,剩下的 sender 就永久卡在 `ch leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
(pprof) list processWorkItems
`list` 会直接指到 `ch <- result{res, err}` 那一行,flat/cum 就是泄漏的 goroutine 数。修法是给 channel 留足容量:
```go
ch := make(chan result, len(ws))
判定方式
工具定义了一个「活性」概念:一个 goroutine 是 live 的,当且仅当它没有被并发原语阻塞,或者阻塞它的那个原语被另一个 live goroutine 引用。
实现复用了 GC 的三色标记,只改了一处:常规 GC 把所有 goroutine 当作 mark root,这里改成只有未阻塞的 goroutine 是 root。从这些显然 live 的 goroutine 出发,追踪它们能访问到的并发原语,把阻塞在这些原语上的 goroutine 也标成 live,迭代到不动点;每轮标记结束后检查被阻塞的 goroutine,如果它被已标记的原语阻塞,就加入 root 继续标记。最终没被加入的就是泄漏。最后把这些泄漏 goroutine 也加回 root 再跑一遍,让 GC 正常回收它们引用的内存。
四个真实泄漏模式
- 并发 worker 提前 return:上面的例子,父 goroutine 出错返回,sender 泄漏。修法是加容量为 1(或
len(ws))的缓冲。 - Double send:错误分支里
ch <- nil之后漏了return,继续执行第二条 send,接收方只收一条,发送方泄漏。修法是补上return。 - select 超时:
select { case <-ch: case <-ctx.Done(): },ctx 先取消,sender 没人接收就泄漏。同样给 1 容量缓冲。 - range channel 不 close:worker 的
for item := range ch永不退出;另外workers=0时父 goroutine 的ch <- item也会泄漏。修法是发送完close(ch),并对 worker 数量做校验。
局限
- Memory overreach:并发原语如果一直被全局变量或某个可运行的 goroutine 引用,阻塞在它上面的 goroutine 不会被判为泄漏。
- Non-standard blocking:只覆盖 channel 收发(含 nil channel)、无 default 的 select(含无 case 的 select)、以及 sync 包的
Mutex/RWMutex/WaitGroup/Cond。文件与网络 IO、直接系统调用、自定义自旋锁都不算。 - 非确定性:只能事后发现,不能预测。
- 性能:内存开销可忽略,但标记可能变慢。最坏情况是 daisy-chain——G0 引用的 P1 阻塞 G1,G1 引用的 P2 阻塞 G2……标记阶段实际上被串行化,而且每轮标记结束都要检查所有阻塞 goroutine,单个 GC 周期最坏 O(n²)。由于泄漏一旦出现就会一直可观测,周期性采集即可(比如每 4 小时一次),开销几乎可以忽略。