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

协程泄漏不是“可能有”,而是 runtime.NumGoroutine() 持续单向上涨 + pprof 里大量 goroutine 卡在 chan receive、select 或 semacquire —— 这就是铁证,不用猜。
怎么用 runtime.NumGoroutine() 快速筛出真泄漏
数字高不等于泄漏。刚启动时跳到 100+ 很正常,那是 pprof、健康检查、日志采集等后台 goroutine 在初始化。关键看趋势:
- 单次 HTTP 请求入口打点是 85,
handler返回前再打一次还是 85+,说明没回落 - 压测结束等待 30 秒后,总数仍比空闲态(比如 60)高出 40 以上
- 服务稳定运行几小时,从 50 → 180 → 420 这样单调爬升
实操建议:在关键路径加三处日志,别只采一次:log.Printf("goroutines@idle: %d", runtime.NumGoroutine())、@start、@done;采样间隔至少 time.Sleep(100 * time.Millisecond),否则漏掉靠超时退出的协程。
怎么用 /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(t) 在测试阶段拦截泄漏
pprof 是事后诊断,goleak 是事前拦截。它靠比对测试前后 goroutine 快照,报告“新增但未退出”的协程及其初始调用栈。
- 放在测试函数开头:
defer goleak.VerifyNone(t);必须defer,否则 panic 时不执行 - 若测试里启了
http.Server或用了time.AfterFunc,需显式忽略:goleak.IgnoreCurrent()或goleak.IgnoreTopFunction("github.com/xxx/pkg.initWorker") - 避免误报:不要 ignore
testing.(*T).Run—— 这是框架自身,不是你的代码
注意:goleak 无法捕获被 t.Parallel() 干扰的生命周期,且对死循环、无限重试等主动不退出场景无效。
最容易被忽略的泄漏点:http.Response.Body 和 persistConn
很多人以为 resp.Body.Close() 只是释放连接,其实它直接影响底层 persistConn 的生命周期。漏关会导致 readLoop / writeLoop goroutine 永不退出。
- 读取部分响应体(比如只读前 1024 字节)后不
Close(),一定泄漏 - 完整读完(如用
io.ReadAll(resp.Body))后不Close(),也可能泄漏 —— Go 1.24+ 已修复该行为,但旧版本仍需显式关闭 - 用
http.Client时务必带context.WithTimeout,否则 DNS 解析卡住也会拖住 goroutine
真正难排查的,从来不是堆栈里明晃晃的 chan receive,而是那些看似“已经处理完”的请求,却因为一个没关的 Body,悄悄钉住了整个连接池和背后的 readLoop goroutine。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











