协程泄漏是 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;只采一次值没用,必须看变化。
怎么用 /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) 在测试里常失效
goleak 不是万能的,它只捕获测试函数退出时“还活着”的 goroutine。很多泄漏在测试结束前就因超时或 channel 关闭退出了,goleak 根本看不到。
常见误用场景:
-
defer goleak.VerifyNone(t)后又启动 goroutine 并立刻return,verify 执行时 goroutine 已退出 - 用了
t.Parallel(),导致生命周期被干扰 - 没等异步逻辑完成就结束测试,比如漏掉
或 <code>time.Sleep(10ms)
更稳的做法:
用 goleak.VerifyTestMain(m) 包裹整个 TestMain,覆盖全部测试用例;但前提是所有 goroutine 必须在测试包结束前显式退出(比如 cancel()、close(ch))。
哪些 channel 场景 100% 导致 goroutine 卡死
绝大多数泄漏都出在 channel 使用上,以下模式只要出现,几乎必然泄漏:
- 向一个
nil chan发送或接收:var c chan int;runtime不 panic,goroutine 直接挂住 - 从无缓冲 channel 读,但没人写;或向无缓冲 channel 写,但没人读
- 向有缓冲 channel 写满后继续写,且缓冲区从未被消费(例如
make(chan int, 1)写两次) -
select中所有case都不可达,且没有default - 忘记
close(channel),却在另一端用for range channel死等
最容易被忽略的一点:http.Response.Body 不 Close(),会导致底层 persistConn 无法复用或释放,间接拖住 readLoop goroutine —— 它不报错,但会持续堆积。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











