编程 goroutineleak profile:在 Go 1.27 生产环境定位卡在 channel 和锁上的 goroutine

2026-11-10 20:03:05

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 的,当且仅当:

  1. 它没有被并发原语阻塞,或
  2. 阻塞它的至少一个并发原语被另一个 live goroutine 引用。

从明显 live 的未阻塞 goroutine 出发,追踪它们持有的引用(局部变量)找出可访问的并发原语,再把这些原语上阻塞的 goroutine 纳入 live,迭代直到不再发现新的 live goroutine。

Go runtime 已经通过 GC 计算内存可达性,只需把 GC 适配过来。runtime 使用并发三色标记清除 GC(现在还有 Green Tea 变体)。关键改动:

  1. 初始阶段常规 GC 会把所有 goroutine(和全局变量)标记为可达(mark roots)。改成只包含未阻塞的 goroutine,因为它们保证是 live 的。
  2. 标记阶段追踪 mark roots(传递)引用的对象并标记为可用内存。此阶段不改,但第 1 步保证只标记 live goroutine 引用的内存。
  3. 标记阶段结束时检查所有未作为 mark root 的阻塞 goroutine。如果某个 goroutine 被至少一个已在第 2 步标记的并发原语阻塞,就把它加入 mark root,并从第 2 步继续标记。这对应 liveness 定义的归纳步。
  4. 所有 live goroutine 发现完毕后,剩余未加入 mark root 的 goroutine 状态置为 leaked。
  5. 标记阶段最后一次恢复,把所有泄漏 goroutine 加入 mark root,让 GC 标记出它在常规运行中本会标记的全部内存。

GC 周期结束后,goroutine leak profiler 像常规 goroutine profile 一样工作,只筛选严格泄漏的 goroutine。

限制

  1. 内存超范围(memory overreach):如果并发原语一直能通过全局变量或可运行 goroutine 到达,那么阻塞在它上面的 goroutine 永远不会被报告为泄漏,即使这个原语以后不再使用。可以通过更好地规范并发原语引用的访问、更清晰地划分生命周期来缓解。
  2. 非标准阻塞:为正确性,泄漏检测严格限于 Go 一等并发原语:channel 发送与接收(包括 nil channel)、阻塞式 select(没有 default 分支,甚至没有 case 的 select)、sync 包成员(Mutex、RWMutex、WaitGroup、Cond)。因其他原因阻塞的 goroutine(文件与网络 IO、直接系统调用)永远不算泄漏;自定义并发(如自旋锁)同理,除非底层依赖上述原语。
  3. 非确定性:泄漏只能在发生后检测,不能预测,所以复现和诊断 flaky 程序仍然困难。最好混用多种手段:在包括生产在内的各层使用 goroutine leak profile,并在测试套件里接入 goleak 和 synctest。

性能影响

内存开销可忽略(只有少量记账),但泄漏检测可能比常规 GC 慢。最坏情况是 daisy-chain:无泄漏示例中,可运行 goroutine G0 引用阻塞 G1 的原语 P1,依此类推。证明 P(i+1) 的 liveness 需要先证明 P(i) 的 liveness,带来两个代价:

  1. GC 标记阶段相对于 goroutine 扫描顺序实际上被串行化,因为从某个 P(i) 可达的内存必须先全部标记,P(i+1) 才能被加入 root。
  2. 当前每轮标记结束都要检查所有阻塞 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。

复制全文 生成海报 Go 并发 pprof 性能 排查

推荐文章

程序员茄子在线接单