真泄漏需观察runtime.numgoroutine()持续单向上涨趋势:请求后不回落、压测后30秒不恢复、长期单调上升(如50→180→420);配合pprof?debug=2查阻塞在chan receive/select/io wait的goroutine,并用goleak.verifynone(t)在测试中拦截未退出协程。

runtime.NumGoroutine() 怎么看才算真泄漏
数字高不等于泄漏,刚启动时跳到 80+ 很正常——那是 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=2
?debug=1 只返回统计摘要,看不出谁卡在哪;?debug=2 才输出完整调用栈、阻塞时长和创建源头——这才是定位依据。
生产环境直接 curl http://localhost:6060/debug/pprof/goroutine?debug=2 就行。重点关注这些状态:
-
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) 在测试里怎么不误报
goleak 是事前拦截,不是魔法——它靠比对测试前后 goroutine 快照,报告“新增但未退出”的协程及其初始调用栈。但它默认不过滤你启的后台逻辑。
常见踩坑点:
- 必须写成
defer goleak.VerifyNone(t),否则test panic时不执行,等于白加 - 若测试里合法启了 HTTP server,得加
goleak.IgnoreCurrent(),否则会把server的监听协程当泄漏 - 忽略函数名要写全:比如
IgnoreTopFunction("net/http.(*Server).Serve"),漏包路径或括号就失效 - 别在
init()或包变量初始化里拉起goroutine——goleak.VerifyNone(t)会把它当泄漏报出来
它不检测“缓慢泄漏”,只抓测试生命周期内未退出的 goroutine;对 t.Parallel() 干扰下的生命周期也容易失准。
HTTP 客户端和 resp.Body.Close() 为什么总被忽略
很多人以为 resp.Body.Close() 只是释放连接,其实它直接影响底层 persistConn 的生命周期——不关,连接就一直挂在 http.Transport 里,对应读写协程持续存在。
更隐蔽的是:即使加了 ctx 超时,若 Body 没 Close(),协程仍可能卡在 readLoop 或 writeLoop 中,表现为大量 IO wait 状态。
修复要点:
- 所有
http.NewRequestWithContext(ctx, ...)后,必须确保resp.Body.Close()被调用(用defer最稳妥) - 自定义
http.Transport时,务必设IdleConnTimeout和MaxIdleConnsPerHost - 第三方 client(如
pgx、sarama)同样要查文档,确认是否有Close()或Shutdown()方法,并在服务退出前统一调用
协程泄漏最麻烦的地方不是找不到,而是它不报错、不 panic,只悄悄钉住大对象(比如 []byte、map),让 heap_inuse 持续上涨——你看到的“内存泄漏”,八成其实是 goroutine 没退出。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











