← 中文资源

简体中文版本

Go — goroutine 数持续攀升:用 pprof 定位泄漏

诊断持续增长的 goroutine 计数,抓取 pprof goroutine 剖析,读懂阻塞栈,并关闭那个永不触发的 channel 或 context。

阅读英文原文

症状

  泄漏以运行时剖析里单调递增的计数器自我宣告:goroutine_count 随请求量线性上涨,负载结束后也不回落。真正的诊断在按等待状态分组之后:pprof 的 debug=1 输出显示 6800 个 goroutine 停在同一个 chan receive 上长达 9 分钟,全部位于同一个函数——该函数每请求泄漏一个 goroutine,因为它等待的 channel 永远没人发送也永远没人关闭。每个停泊的 goroutine 都钉住自己的栈与捕获的所有变量,RSS 因此同步攀升。

诊断思路

  先画 goroutine 计数随时间的曲线,区分“随请求线性增长且负载后不回落”(泄漏)与“到平台期后回到基线”(稳态并发);再按栈分组统计,数千个共享同一阻塞栈就是泄漏签名,栈会点名等待原语与拥有它的代码;最后用 goleak 把泄漏变成确定性测试失败。常见成因:goroutine 等的 channel 无人发送也无人关闭、select 里有 ctx.Done() 但所有者从不 cancel、生产者停止投喂却不 close 队列、HTTP 响应体不关闭让读循环 goroutine 苟活。

根因

  永久阻塞在 receive 或 select 上的 goroutine 永远不会被垃圾回收——阻塞中的 goroutine 从调度器可达。每个都钉住自己的栈加捕获的全部对象,于是错过停机信号的每请求 goroutine 无限累积内存。

修复与验证

  保证退出信号必达:context 的所有者必须 defer cancel();select 循环必须包含 ctx.Done() 分支;生产者职责是 defer close(out) 让消费者的 range 循环自然退出;每个循环都有一条可达的终止路径。用 goleak.VerifyTestMain 挂在包级测试上守住不变量。验证时驱动固定请求数,断言负载结束后数秒内 goroutine 计数回到加载前基线。取消要协作式进行——让 goroutine 完成当前工作单元再返回,强制中途退出会丢在途工作。

代码示例

$ curl -s http://localhost:6060/debug/pprof/
goroutine_count: 8421
$ curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | head -40
# ... 6800 @ 0x45b7c1 0x44d5c2
# chan receive, 9 minutes
#   main.(*Worker).listen(...)
#       /app/src/worker.go:87
func (w *Worker) listen(ctx context.Context) {
    defer close(w.done)
    for {
        select {
        case <-ctx.Done():
            return // reachable exit: owner cancels
        case job := <-w.queue:
            w.process(job)
        }
    }
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // the owner responsibility — missing defer is the leak
func produce(out chan<- Job) {
    defer close(out) // consumers exit their range loop
    for _, j := range jobs {
        out <- j
    }
}
import "go.uber.org/goleak"

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}