协程泄漏需排除初始化波动和后台干扰,通过 runtime.NumGoroutine() 快速初筛,重点监控请求后不回落、压测后不恢复、长期单调上升三种情形;配合三处日志、pprof debug=2 查阻塞栈,关注 chan receive/select/semacquire/IO wait 状态;测试阶段用 goleak.VerifyNone 拦截泄漏;内存上涨未必反映在 heap profile,因 goroutine 会钉住大对象,须结合 goroutine 栈分析生命周期。

协程数持续上涨且不回落,基本就是泄漏了——但得先排除初始化波动和系统 background goroutine 干扰,否则容易误判。
用 runtime.NumGoroutine() 快速确认是否真泄漏
这不是最终证据,但能帮你 5 秒内排除“假警报”。刚启动 HTTP 服务时看到 NumGoroutine() 突然跳到 120+,大概率只是 pprof、log、health check 等后台 goroutine 在初始化,不是泄漏。
真正要盯的是这三种情况:
- 单次请求处理完后,数字没回落(比如入口打点是 80,handler 返回后还是 80+)
- 压测结束后等待 30 秒,总数仍比空闲态高一大截
- 稳定运行几小时后,数字呈单调上升趋势(50 → 180 → 420)
建议在关键路径加三处日志:log.Printf("goroutines@idle: %d", runtime.NumGoroutine())(空闲)、@start(请求进)、@done(返回前),别只采一次样。
立即学习“go语言免费学习笔记(深入)”;
用 /debug/pprof/goroutine?debug=2 抓阻塞源头
默认的 ?debug=1 只给统计摘要,看不出谁卡在哪;必须加 ?debug=2 才输出完整调用栈。生产环境直接 curl 就行:
curl "http://localhost:6060/debug/pprof/goroutine?debug=2"
重点关注这些状态的 goroutine:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
-
chan receive(监听未关闭 channel) -
select(漏写default或所有 case 都阻塞) -
semacquire(锁没释放、WaitGroup 没 Done) -
IO wait(比如io.ReadFull卡住、SSH 连接没超时)
如果看到几百个 goroutine 全卡在同一个函数里、阻塞时间显示“250000+ minutes”,不用犹豫,就是它。
用 goleak 在测试阶段拦截泄漏
单元测试里启了个 time.Ticker 忘记 Stop(),或者用 go func() { ... }() 启了个闭包却等不到信号——这类问题 goleak 能当场揪出。
在 TestMain 或每个测试末尾加:
defer goleak.VerifyNone(t)
它会对比测试前后 goroutine 列表,报告“新增但未退出”的协程及其初始调用栈。注意两点:
- 别用
goleak.IgnoreTopFunction("testing.(*T).Run")——这是忽略框架自身,不是你的代码 - 若要忽略已知合法残留(如某 SDK 的全局监控 goroutine),得用
goleak.IgnoreCurrent()在测试开头拍快照,或明确 ignore 函数名:goleak.IgnoreTopFunction("github.com/some/pkg.startMonitor")
为什么 pprof heap 看不出泄漏,但内存还在涨?
因为 goroutine 本身开销小(初始栈仅 2KB),但它活着就会“钉住”大对象——比如一个闭包捕获了整个 struct,而 struct 里有 []byte 或 map[string]*bigStruct。GC 不敢回收,内存就悄悄涨。
这时候光看 heap 指标没用,得回到 /debug/pprof/goroutine?debug=2,把阻塞 goroutine 的调用栈和它持有的变量生命周期一起分析。最常被忽略的是:channel 关闭时机不对、context 没传到底层、defer 里忘了关资源、甚至 time.AfterFunc 的闭包引用了大对象。

















