goroutineleak profile:在 Go 1.27 生产环境定位卡在 channel 和锁上的 goroutine
Go 的并发原语好用,但也容易让老手犯错。生态里有 race detector 之类的工具,goroutine 泄漏却常被漏掉。
goroutine 通过共享的并发原语(channel、锁、wait group)同步或交换信息,通信时经常阻塞在这些原语上等待条件满足;也可能阻塞在操作系统操作上,比如读网络 socket 或文件。如果一个 goroutine 阻塞了,而解除阻塞所需的条件永远无法满足,就可以认为它泄漏了。累积的泄漏 goroutine 会通过过多的内存占用(泄漏的 goroutine 本身或它引用的内存)以及 GC 的 CPU 开销拖慢性能,在设置了 GOMEMLIMIT 时尤其明显。
发现泄漏不容易。单元测试层面,goleak 可以在单个测试结束后把未终止的 goroutine 报为可疑;Go 1.25 引入的 synctest 让并发测试更可控。但这些方法都难以按生产系统的规模检查泄漏。常规 goroutine profile 只能看阻塞过多的操作或增长趋势,区分不了真泄漏与流量上升导致的临时大量阻塞,数量少时可能多年无人发现。
Go 1.27 正式提供 goroutine leak profiler:在运行中的 Go 程序(包括生产)里发现 goroutine 泄漏的轻量机制。它精确、几乎不产生误报,代价是只覆盖一部分泄漏——永久阻塞在 channel 或 sync 包原语上的 goroutine。好消息是这已经覆盖了很大一部分。
一个典型泄漏:并发 worker 提前返回
processWorkItems 并发处理 work item,把结果聚合到 ch。ch 是无缓冲的,每个 worker 发送结果时都要等主 goroutine 接收。如果 processWorkItems 因为某个错误提前返回,接收循环终止,其余 sender goroutine 就永久阻塞。这是真实 Go 程序(包括 Uber 生产服务)里常见的错误。
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
// Process work items in parallel, aggregating results in ch.
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
// Collect the results from ch, or return an error if one is found.
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
// This early return may cause goroutine leaks.
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
采集与定位
profile 通过 runtime/pprof 以 goroutineleak 类型提供,也通过 net/http/pprof 提供的 handler 暴露。如果服务已经接了 net/http/pprof,什么都不用做:在装了 handler 的 host 和端口上,/debug/pprof/goroutineleak 端点自动可用。
示例程序 main.go:导入 errors、log、net/http、_ "net/http/pprof"、time;processWorkItem 里 time.Sleep(10 * time.Millisecond),w == 5 时返回 errors.New("simulated error");main 里起 http.ListenAndServe("localhost:6060", nil),然后循环处理 items := []workItem{1..10},每秒一次。
$ go build -o leaky
$ ./leaky
程序运行不久就会积累泄漏,可以在 http://localhost:6060/debug/pprof 的 Web UI 查看;也可以 curl 采集后交给 go tool pprof:
$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1 in .../main.go
0 116 (flat, cum) 100% of Total
. . 31: go func() {
. . 32: res, err := processWorkItem(w)
. 116 33: ch <- result{res, err}
. . 34: }()
profile 直接指出 goroutine 泄漏在 ch <- result{res, err}(第 33 行),精准指到肇事操作。程序运行越久,泄漏的 goroutine 越多。
修复:给 channel 加缓冲
ch := make(chan result, len(ws))
这样即使 processWorkItems 提前返回,所有 work item goroutine 也能发送而不阻塞。
实现原理:把 liveness 接进 GC 标记
核心观察:如果一个 goroutine 阻塞在某个没有其他 goroutine 能访问(通过内存引用)的并发原语上,它显然泄漏了。把这个线索泛化为 liveness 的归纳定义:
一个 goroutine 是 live 的,当且仅当:
- 它没有被并发原语阻塞,或
- 阻塞它的至少一个并发原语被另一个 live goroutine 引用。
从明显 live 的未阻塞 goroutine 出发,追踪它们持有的引用(局部变量)找出可访问的并发原语,再把这些原语上阻塞的 goroutine 纳入 live,迭代直到不再发现新的 live goroutine。
Go runtime 已经通过 GC 计算内存可达性,只需把 GC 适配过来。runtime 使用并发三色标记清除 GC(现在还有 Green Tea 变体)。关键改动:
- 初始阶段常规 GC 会把所有 goroutine(和全局变量)标记为可达(mark roots)。改成只包含未阻塞的 goroutine,因为它们保证是 live 的。
- 标记阶段追踪 mark roots(传递)引用的对象并标记为可用内存。此阶段不改,但第 1 步保证只标记 live goroutine 引用的内存。
- 标记阶段结束时检查所有未作为 mark root 的阻塞 goroutine。如果某个 goroutine 被至少一个已在第 2 步标记的并发原语阻塞,就把它加入 mark root,并从第 2 步继续标记。这对应 liveness 定义的归纳步。
- 所有 live goroutine 发现完毕后,剩余未加入 mark root 的 goroutine 状态置为 leaked。
- 标记阶段最后一次恢复,把所有泄漏 goroutine 加入 mark root,让 GC 标记出它在常规运行中本会标记的全部内存。
GC 周期结束后,goroutine leak profiler 像常规 goroutine profile 一样工作,只筛选严格泄漏的 goroutine。
限制
- 内存超范围(memory overreach):如果并发原语一直能通过全局变量或可运行 goroutine 到达,那么阻塞在它上面的 goroutine 永远不会被报告为泄漏,即使这个原语以后不再使用。可以通过更好地规范并发原语引用的访问、更清晰地划分生命周期来缓解。
- 非标准阻塞:为正确性,泄漏检测严格限于 Go 一等并发原语:channel 发送与接收(包括 nil channel)、阻塞式 select(没有 default 分支,甚至没有 case 的 select)、
sync包成员(Mutex、RWMutex、WaitGroup、Cond)。因其他原因阻塞的 goroutine(文件与网络 IO、直接系统调用)永远不算泄漏;自定义并发(如自旋锁)同理,除非底层依赖上述原语。 - 非确定性:泄漏只能在发生后检测,不能预测,所以复现和诊断 flaky 程序仍然困难。最好混用多种手段:在包括生产在内的各层使用 goroutine leak profile,并在测试套件里接入 goleak 和 synctest。
性能影响
内存开销可忽略(只有少量记账),但泄漏检测可能比常规 GC 慢。最坏情况是 daisy-chain:无泄漏示例中,可运行 goroutine G0 引用阻塞 G1 的原语 P1,依此类推。证明 P(i+1) 的 liveness 需要先证明 P(i) 的 liveness,带来两个代价:
- GC 标记阶段相对于 goroutine 扫描顺序实际上被串行化,因为从某个 P(i) 可达的内存必须先全部标记,P(i+1) 才能被加入 root。
- 当前每轮标记结束都要检查所有阻塞 goroutine,最坏 O(n²) 步(n 为 goroutine 总数)。
第二点可以优化,第一点是泄漏检测的内在限制无法绕过。
GC 仍与用户代码并发运行。而且,如果一个 goroutine 泄漏在某个时刻可被观察到,那么在同一执行过程的任何更晚时刻也能被观察到。所以周期性 profiling 的基础设施可以调整采样频率,比如每 4 小时一次,以极小代价把开销降到几乎为零。
致谢:goroutine 泄漏检测是 Aarhus 大学、Washington University in St. Louis 与 Uber 的研究合作成果,论文 "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection"(Saioc 等,ASPLOS 2025)。从学术原型到 Go 特性,得到 Go 团队 Michael Knyszek、Michael Pratt 与 PJ Malloy(@thepudds)的指导。
更多泄漏模式
下面这些模式来自工业级代码库与开源项目,按复杂度递增。可以在 Go playground 上试这些例子。
Double send
比预期多发送一次消息。下面期望向无缓冲 channel 发送一次,但错误分支里 ch <- nil 之后漏了 return,于是每次出错都会发送两次,造成泄漏。
func DoubleSend() {
ch := make(chan any)
go func(err error) {
if err != nil {
// In case of an error, send nil.
ch <- nil
// Return statement is missing.
}
// Otherwise, continue with normal behaviour.
// This send is still executed, which causes a leak in the error case.
ch <- struct{}{}
}(fmt.Errorf("error"))
// Receive only one message.
<-ch
}
profile 虽不直接指出漏掉的 return,但会高亮泄漏的发送操作,把你引到出问题的函数:
(pprof) list DoubleSend
Total: 1
ROUTINE ======================== main.DoubleSend.func1 in .../main.go
0 1 (flat, cum) 100% of Total
. . 118: go func(err error) {
. . 119: if err != nil {
. . 121: ch <- nil
. . 123: }
. 1 126: ch <- struct{}{}
. . 127: }(fmt.Errorf("error"))
. . 129: <-ch
修复:错误分支发送后补 return。
Early return
反过来的情况同样常见:接收方在某些控制流分支上漏掉通信。这是开头示例的简化版。
func EarlyReturn(err error) {
ch := make(chan any)
go func() {
ch <- struct{}{}
}()
if err != nil {
// The parent goroutine quits too early in case of an error.
// Sender leaks.
return
}
<-ch
}
profile 输出指向 ch <- struct{}{}。修复:给 ch 缓冲大小 1。
Timeout
上面的变体,涉及 context 与非确定选择(select):
func Timeout(ctx context.Context) {
ch := make(chan any)
go func() {
ch <- struct{}{}
}()
select {
case <-ch: // Receive message from worker
case <-ctx.Done():
// Sender leaks because there is no
// future rendezvous over the channel.
}
}
如果 sender 还没和父 goroutine 同步 context 就被取消,sender 泄漏。list Timeout 会显示 10 个样本停在 ch <- struct{}{}。修复同样是给 channel 缓冲 1。
range over channel 但没 close
用 range 遍历 channel 会重复接收值,channel 关闭且缓冲区内的值都收完后循环退出。关键是:如果 channel 永不关闭,range 循环会永远阻塞该 goroutine。漏写 close 是常见错误:
func noCloseRange(list []any, workers int) {
ch := make(chan any)
for i := 0; i < workers; i++ {
go func() {
for item := range ch {
_ = item
}
}()
}
for _, item := range list {
// The parent leaks by sending an item if workers == 0
// or if all the workers panic, but the panic is recovered.
ch <- item
}
// Otherwise, the channel is never closed, so workers
// leak once there are no more items left to process.
}
go noCloseRange([]any{1, 2, 3}, 3) // Leaks all 3 workers
profile 输出:
Type: goroutineleak
(pprof) list noCloseRange.func1
Total: 4
ROUTINE ======================== main.noCloseRange.func1 in .../main.go
0 3 (flat, cum) 75.00% of Total
. . 82: go func() {
. 3 84: for item := range ch {
. . 86: _ = item
. . 87: }
. . 88: }()
3 个 worker 阻塞在 range ch。修复:所有消息发送完后 close(ch)。
顺带一提,如果 workers 被误设为 0,父 sender 会泄漏:
go noCloseRange([]any{1, 2, 3}, 0) // Sender leaks with 0 workers
profile 同样能捕获:
(pprof) list noCloseRange$
Total: 4
ROUTINE ======================== main.noCloseRange in .../main.go
0 1 (flat, cum) 25.00% of Total
. . 92: for _, item := range list {
. 1 95: ch <- item
. . 96: }
现实中一般可以假定 workers > 0,但泄漏 profile 也能顺带监控这种意外违例,而不必写保守的 workers <= 0 检查。
方法契约违约
上面这些模式的词法作用域都相对受限。当功能分散到多个函数、方法、包,实现又被接口遮蔽时,手工发现泄漏的难度会大幅上升。这一节的 worker 类型嵌了两个 channel 字段 ch 和 done;Start 方法创建循环 goroutine,用 select 读这两个 channel;该 goroutine 只能通过 done 收到消息而终止,done 由 Stop 关闭。Start 可以被调用任意多次,但只要调用过至少一次,最终就应调用 Stop。