goroutine 泄露本质是“该退不退”,表现为 runtime.NumGoroutine() 在请求结束、压测后或长期运行中不回落或单调上升;可用日志监控@idle/@start/@done、pprof?debug=2 定位阻塞点、goleak.VerifyNone 在测试中捕获未退出协程,并通过 context 与 channel 正确配合避免泄漏。

goroutine 泄露不是“数量多”,而是“该退不退”——只要 runtime.NumGoroutine() 在请求结束后不回落、压测后不恢复、长期单调上升,基本就是泄露了。
怎么用 runtime.NumGoroutine 快速初筛
它不能定案,但能帮你 5 秒内排除假警报。刚启动服务时看到数字跳到 100+,大概率只是 pprof、http.Server、健康检查等后台 goroutine 初始化所致。
- 盯这三种情形:单次请求处理完后数字没回落(比如入口是 80,handler 返回后还是 80+)
- 压测结束等待 30 秒,总数仍比空闲态高一大截
- 稳定运行几小时后呈单调上升趋势(50 → 180 → 420)
- 建议在关键路径加三处日志:
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" 就行。
- 重点关注状态为
chan receive、select、semacquire、IO wait的 goroutine - 如果看到几百个 goroutine 全卡在同一个函数里,阻塞时间显示 “250000+ minutes”,不用犹豫,就是它
- 典型堆栈模式:
runtime.gopark+runtime.chanrecv或runtime.selectgo,调用栈指向net/http.readLoop、golang.org/x/crypto/ssh或你自己的for range ch - 对比两次快照更准:服务刚启动抓一次(A),运行 5 分钟后再抓一次(B),用
diff -u A B | grep "^+"找新增堆栈
为什么 goleak.VerifyNone 在测试阶段最有效
它不依赖运行时监控,而是在单元测试结束时做 goroutine 快照比对,当场揪出“启动了却没退出”的协程。
立即学习“go语言免费学习笔记(深入)”;
- 在
Test函数末尾加defer goleak.VerifyNone(t),它会报告新增但未退出的 goroutine 及其初始调用栈 - 注意必须
defer,否则 test panic 时不会执行,漏检 - 别用
goleak.IgnoreTopFunction("testing.(*T).Run")—— 这是忽略框架自身,不是你的代码 - 常见漏点:启了
time.Ticker忘记Stop()、go func() { }()闭包等不到信号、HTTP client 没设超时导致readLoop/writeLoop永驻
修复 channel 和 context 配合不当的泄漏
90% 的泄露根因是 channel 生命周期失控,而 context 是唯一能主动叫停它的机制——但必须两端都到位。
- 向无缓冲
chan发送前,确保有 goroutine 在另一端接收;否则应改用带缓冲 channel,或用select+ctx.Done()防守 -
for range ch接收时,必须由发送方close(ch);如果发送方不确定何时结束,改用select+done chan struct{} - 启动 goroutine 前传入
ctx,内部必须用select监听ctx.Done()并显式return,不能只打印日志或忽略 - 错误写法:
go func() { time.Sleep(10 * time.Second); doWork() }()—— 超时后仍运行;正确写法是把time.Sleep换成case 并配 <code>case
最常被忽略的一点:pprof 显示 goroutine 很多但 heap 不涨,不代表没事。goroutine 本身开销小,但它可能持着大对象指针(比如闭包捕获了含 []byte 的 struct),导致内存被“钉住”。这时候光看 heap profile 会完全错过问题根源。


















