写 Go 服务的开发者大多都听过一个词:goroutine 泄漏。goroutine 是 Go 语言里同时执行任务的轻量级单元,可以粗略理解为程序里同时干活的「工人」。所谓泄漏,是指某些工人因为程序里的 bug 被永久卡在某个等待动作上——比如等一条永远不会再来的消息——从此再也不能醒来。它们不再干活,却一直占着内存,还会连累垃圾回收(GC,自动清理无用内存的机制)越来越慢。这类 bug 非常隐蔽:程序不会崩溃,功能甚至完全正常,只是资源在悄悄流失。

Go 1.27 引入了针对它的官方工具:协程泄漏剖析器(goroutine leak profiler)。它是一个新的剖析类型 goroutineleak,能在正在运行的程序里——包括线上生产环境——把泄漏的协程精确标记出来,而且几乎不误报。它的适用范围是「永久阻塞在 channel(协程间传递消息的管道)或 sync 包提供的同步原语(互斥锁、等待组这类同步工具)上的协程」,这已经覆盖了协程泄漏中很大的一部分。

这个工具适合谁用:正在维护 Go 服务、被「内存越涨越高却查不出原因」这类问题困扰的开发和运维人员。如果你的服务已经挂了 net/http/pprof 调试端点,什么都不用改,它会自动出现在 /debug/pprof/goroutineleak 上。

协程泄漏为什么难以发现

协程泄漏难查在三点。第一,它不留下崩溃现场,程序照常运行、功能正常。第二,已有的检测手段大多只覆盖单元测试:Uber 开源的 goleak 可以在单测结束时标记没有退出的协程;Go 1.25 进入标准库的 synctest 让测试能控制并发事件的顺序,更好复现竞态场景。但两者都管不到生产环境。第三,传统 goroutine profile(普通协程剖析)只能看到「多少协程阻塞在哪」的趋势,分不清哪些是暂时等待(比如流量高峰期的正常排队)、哪些是真泄漏;数量少的泄漏更容易一漏就是很多年。

怎么用

用法和 pprof(Go 自带的一套性能剖析工具)家族其他剖析一致。服务已启用 net/http/pprof 时,直接用 curl 抓一份:

curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof

再用 go tool pprof leak.prof 打开,通过 list 命令定位到具体是哪一行代码把协程卡死了——官方示例程序正是靠它在 ch <- result{res, err} 这一行找到了泄漏点。有多少协程卡在那里,剖析里就报多少个;程序跑得越久、触发次数越多,数目越大。

原理:把垃圾回收改造成泄漏探测器

新工具的判定依据借用了垃圾回收器的可达性分析。Go 运行时本身就有一套「从根对象出发,标记所有能到达的内存」的机制;官方把标记的起点改成「没被阻塞的协程」,再定义了一个叫活性(liveness)的规则:一个协程只要没有被阻塞,或者阻塞它的原语被另一个活跃协程引用着,就算还活着。标记过程多跑几轮,最后没被任何一轮标记到的阻塞协程,就是泄漏的。这套机制不用重写 GC 主体,只调整几次标记的起点,所以实现轻量、误报率极低。

它查不到什么

这个工具刻意只覆盖 Go 的「一等并发原语」:channel 收发、select(同时等待多个通道操作的语句)、sync.Mutex(互斥锁)/sync.RWMutex(读写锁)/sync.WaitGroup(等待一组协程完成的计数器)/sync.Cond(条件变量)。因此有三类情况它查不到:一是卡在文件 IO、网络 IO、直接系统调用上的协程,这些不属于检测范围;二是自定义的同步方式,比如自旋锁(不停循环尝试拿锁,通常只用于极短临界区),除非其底层恰好用了上述原语;三是「内存过度可达」——如果某个原语一直被全局变量或可运行协程引用着,阻塞其上的协程会被认为「还有救」而不上报。时序上它也只能在泄漏发生之后检出,无法提前预判。

真实项目里的泄漏案例

以下案例都发生在真实的生产项目里,对应的代码仓库与 PR 编号均可查证:

  • CockroachDB(PR 584):循环里加锁后,在 break 前忘了 Unlock,下次再来加锁的协程永久阻塞。修法是在 break 前补一次解锁。
  • etcd(PR 6857):channel 收发与关闭的顺序竞争,让某个协程永远等不到消息。修法是把发送放进 select,同时监听退出通道。
  • Kubernetes(PR 6632):通道与互斥锁互相等待,两个协程一起卡死。修法是先收完消息再抢锁。
  • Moby(PR 25384):把 WaitGroup.Wait() 错写进了循环体,插件多于一个时,新起的协程会永久等待。修法是把 Wait 移到循环外。
  • Moby(PR 28462):关闭监控通道时用「发消息」而不是「关通道」,与持锁协程形成互锁。修法是直接 close 通道。

这些案例也顺带展示了最常见的几类修法:给 channel 加缓冲、补上遗漏的 return、把 Wait 放对位置。

性能开销与使用建议

内存开销可以忽略。最坏情况下单轮垃圾回收的复杂度会到 O(n²)(n 为协程总数),可能让 GC 变慢一些,但常规场景影响很小。官方建议以低频周期采集(例如每 4 小时一份)观察趋势——泄漏一旦出现过,之后任何时刻都能再次抓到。

这个功能从哪来

它源自学术研究:Aarhus University、Washington University in St. Louis 与 Uber 合作的成果,以论文《Dynamic Partial Deadlock Detection and Recovery via Garbage Collection》(Saioc 等,ASPLOS 2025)发表。从原型到进入 Go 语言,Google Go 团队的 Michael Knyszek、Michael Pratt 提供了指导,PJ Malloy 也参与了落地。

对维护 Go 服务的团队来说,Go 1.27 的协程泄漏剖析器把「查协程泄漏」从人工盯 profile 变成了机器自动标记:只要服务挂着 pprof,升级到 Go 1.27 之后定期抓一份 goroutineleak 剖析,就能在绝大多数协程泄漏积累成事故之前发现它们。需要记住的只有边界——它查的是卡在 channel 和 sync 原语上的协程,IO 类等待不在其中。