协程泄漏是 runtime.NumGoroutine() 持续单向上涨且 pprof debug=2 显示大量 goroutine 卡在 chan receive/select/IO wait;需紧盯请求后不回落、压测后不恢复、长期单调上升三种趋势,并用三处日志和 debug=2 堆栈定位阻塞点。

协程泄漏不是“可能有”,而是 runtime.NumGoroutine() 持续单向上涨 + /debug/pprof/goroutine?debug=2 里大量 goroutine 卡在 chan receive、select 或 IO wait——这就是铁证,不用猜。
怎么用 runtime.NumGoroutine() 快速筛出真泄漏
数字高不等于泄漏。刚启动服务时 runtime.NumGoroutine() 跳到 100+ 很正常,那是 pprof、健康检查、日志采集等后台 goroutine 在初始化。
真正要盯的是这三种趋势:
- 单次 HTTP 请求处理完,goroutine 数没回落(比如入口是 85,handler 返回后还是 85+)
- 压测结束等待 30 秒,总数仍比空闲态高出一大截
- 稳定运行几小时后,数字从 60 → 240 → 950 这样单调斜线上升
实操建议:在关键路径加三处日志:log.Printf("goroutines@idle: %d", runtime.NumGoroutine())、@start、@done;只采一次值没用,必须看变化。
立即学习“go语言免费学习笔记(深入)”;
怎么用 /debug/pprof/goroutine?debug=2 定位阻塞点
?debug=1 只返回统计摘要,看不出谁卡在哪;?debug=2 才输出完整堆栈和阻塞时长,生产环境直接 curl http://localhost:6060/debug/pprof/goroutine?debug=2 就行。
重点关注这些状态的 goroutine:
-
chan receive (nil chan):监听了未初始化或已关闭的 channel -
select卡死:所有 case 都不可达,且没写default -
semacquire:锁没释放、sync.WaitGroup忘记Done() -
IO wait:比如io.ReadFull卡住、ssh.Dial没设超时、http.Client没传context
如果看到几百个 goroutine 全卡在同一个函数里,阻塞时间显示 432000s(5 天),基本不用再查,就是它。
为什么 goleak.VerifyNone 在测试里常漏报
goleak.VerifyNone(t) 只捕获测试函数退出时“还活着”的 goroutine。如果泄漏 goroutine 在测试结束前就因 channel 关闭或超时退出了,就不会被捕获。
常见误用:
- 在
defer goleak.VerifyNone(t)之后又启动 goroutine 并立即return,导致 verify 执行时 goroutine 已结束 - 用
t.Parallel()干扰了 goroutine 生命周期判断
更稳的做法:
- 用
goleak.VerifyTestMain(m)放在TestMain里,覆盖整个测试包 - 确保待测逻辑完整跑完(比如显式
close(ch)、cancel()或等donechannel),再让 verify 检查 - 对已知合法残留(如 SDK 的全局监控 goroutine),用
goleak.IgnoreTopFunction()明确排除,别乱 ignoretesting.(*T).Run
Go 1.24+ 的 /debug/pprof/goroutineleak 端点怎么用
这个端点需要显式启用:GODEBUG=goleak=1 启动程序,首次访问前建议先调用 runtime.GC() 确保泄漏可被识别。
访问 http://localhost:6060/debug/pprof/goroutineleak?debug=1,返回堆栈中带 _Gleaked 状态标记的就是确定泄露项。
注意:
- 它只检测“阻塞在不可达同步原语上”的 goroutine,对死循环、无限重试等主动不退出场景无效
- 旧版本 Go 不支持,别在 1.23 及之前硬试
- 它不替代
?debug=2,而是互补:前者更早发现,后者更直观定位
最易被忽略的泄漏点:HTTP 客户端没设 context 超时、服务端 http.Server 缺少 ReadTimeout/IdleTimeout、resp.Body.Close() 被漏掉——这些都会让底层连接和 goroutine 钉死数小时以上。

















