runtime.numgoroutine() 是协程泄漏的“确诊信号”而非预警,测试阶段用它做快筛:操作前后采样比值超2~3即需排查;推荐配合done chan或time.afterfunc确认退出,采样3次取最小值作baseline,或用goleak.verifynone(t)自动检测,需defer调用并忽略合法后台服务如net/http.(*server).serve;线上定位靠pprof/goroutine?debug=2查新增阻塞堆栈;select中必须含default或ctx.done()防隐式泄漏。

runtime.NumGoroutine() 不能告诉你协程为什么漏,但能立刻告诉你“它已经漏了”——这是你发现泄漏的第一道真实信号,不是预警,是确诊。
用 runtime.NumGoroutine() 快速抓出明显泄漏
这不是线上监控手段,而是测试阶段最轻量的“泄漏快筛”。它不查堆栈、不看源码,只比数字:操作前记一次,操作后等足够久再记一次,差值超 2~3 就该停下手头事去翻代码。
- 别只
time.Sleep(100 * time.Millisecond)一次——有些 goroutine 靠超时退出,有些靠外部信号,建议配合done chan struct{}或time.AfterFunc显式确认退出时机 - 系统 goroutine(GC、timer 等)本身有波动,采样 3 次取最小值作 baseline 更稳;或者直接上
goleak,它默认过滤掉已知安全的 stdlib 协程 - 绝对禁止在
init()或包变量初始化里拉起 goroutine——goleak.VerifyNone(t)会把它当泄漏报出来
用 goleak.VerifyNone(t) 在测试中自动揪出残留协程
它不是导入就生效的魔法,而是你写在 Test 函数末尾的一道守门人。没显式调用,等于没设防。
- 必须写成
defer goleak.VerifyNone(t),否则 test panic 时不会执行,防线直接失效 - 如果你的测试里合法启了后台 HTTP server,得加
goleak.IgnoreCurrent(),否则它会把net/http.(*Server).Serve当成泄漏 - 忽略函数名要写全路径:
IgnoreTopFunction("net/http.(*Server).Serve"),漏掉net/http包名就白配
用 net/http/pprof 查线上服务里卡死的协程在哪
当 runtime.NumGoroutine() 显示从 50 涨到 800,pprof 告诉你“哪一行卡住、为什么卡、谁在等它”。这才是生产环境定位泄漏的刚性工具。
- 启动时注册:
import _ "net/http/pprof",再起个 goroutine 跑http.ListenAndServe("localhost:6060", nil) - 访问
http://localhost:6060/debug/pprof/goroutine?debug=2,重点看状态为IO wait、chan receive、chan send的堆栈 - 对比两次 dump:一次刚启动,一次协程数明显上涨后,用 diff 工具看新增堆栈——新增的,就是泄漏源头
select 里漏掉 default 或 ctx.Done() 是最隐蔽的泄漏入口
很多人以为 “我写了 select,就等于做了并发控制”,其实不然。只要某个 case 里调用了阻塞函数(比如 http.Get、io.Read、无缓冲 channel 接收),而没有配套退出机制,整个 goroutine 就钉死在那里,stopCh 信号根本进不来。
-
select不是轮询器,它只选一次;一旦进入某个 case,后续代码块全部串行执行,中间任何阻塞都会冻结整个 goroutine - 所有长生命周期 goroutine 必须监听
ctx.Done(),且阻塞操作要包裹在select中,不能裸写val := - 无缓冲 channel 上的发送/接收,本质是同步点;如果一方提前退出且没关 channel,另一方就永久挂起——GC 不管这种“活着的僵尸”
真正难防的泄漏,往往藏在你认为“反正很快就会有人读”的那条无缓冲 channel 上。它不报错、不 panic,只是安静地占着 2KB 栈内存,等你服务跑三天后突然 OOM。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











