协程泄漏的铁证是 runtime.numgoroutine() 持续单向上涨且 pprof debug=2 显示大量 goroutine 卡在 chan receive/select/semacquire;需排除初始化波动,重点监控请求后不回落、压测后不恢复、长期单调上升三种趋势,并结合三处日志、pprof 快照对比及 goleak 测试拦截定位修复。

runtime.NumGoroutine() 数值持续上涨就是泄漏的铁证
别被初始值干扰——刚启动时 runtime.NumGoroutine() 到 80+ 很正常,那是 pprof、健康检查、日志采集等后台协程在初始化。真泄漏看趋势:单次请求处理完不回落(比如入口打点 72,handler 返回后仍是 75+)、压测结束等 30 秒仍比空闲态高 40 以上、稳定运行几小时从 50 → 180 → 420 单调爬升。建议在关键路径加三处日志:log.Printf("goroutines@idle: %d", runtime.NumGoroutine())、@start、@done,采样间隔至少 time.Sleep(100 * time.Millisecond),否则漏掉靠超时退出的协程。
pprof/debug=2 是线上唯一能定位到行号的手段
默认 /debug/pprof/goroutine?debug=1 只给统计摘要,看不出卡在哪;必须用 ?debug=2 才输出完整堆栈、阻塞时长和创建源头。生产环境直接执行:curl "http://localhost:6060/debug/pprof/goroutine?debug=2"。重点关注这些状态:
-
chan receive (nil chan):监听了未初始化或已关闭的 channel -
select卡死:所有 case 都不可达,且没写default或没响应ctx.Done() -
semacquire:锁没释放、sync.WaitGroup忘记Done() -
IO wait:比如io.ReadFull卡住、ssh.Dial没设超时、http.Client没配context
如果看到几百个 goroutine 全卡在同一函数里,阻塞时间显示 “432000s”(5 天),基本不用怀疑,就是它。
goleak.VerifyNone(t) 必须 defer 调用才真正生效
goleak.VerifyNone(t) 不是导入就自动拦截的魔法,而是你写在测试末尾的一道守门人。没显式调用,等于没设防。必须写成:defer goleak.VerifyNone(t),否则 test panic 时不会执行,防线直接失效。合法后台服务如 net/http.(*Server).Serve 要手动忽略:goleak.IgnoreTopFunction("net/http.(*Server).Serve"),漏掉 net/http 包名就白配。绝对禁止在 init() 或包变量初始化里拉起 goroutine——goleak 会把它当泄漏报出来。
select 中漏掉 default 或 ctx.Done() 是最隐蔽的泄漏入口
这是截至 2026 年 7 月 22 日,线上事故复盘中占比最高的泄漏模式。表面看代码逻辑完整,但只要一个 select 块里没写 default、也没监听 ctx.Done(),一旦所有 channel 都不可达,goroutine 就永久阻塞在 select 上。它不报错、不 panic、内存增长缓慢,但累积几天后就能拖垮服务。修复很简单:每个 select 至少含 default 或 case ,别依赖“理论上不会卡住”这种假设。











