协程泄漏导致内存持续上涨和oom崩溃,根源是goroutine卡在阻塞状态未退出;需用pprof定位、errgroup统一管理、内层recover兜底并结合超时与上下文取消机制。

协程没回收,runtime.GC() 也救不了你
Go 程序跑着跑着内存持续上涨、OOM 崩溃,pprof 显示 goroutine 数量只增不减——基本可以断定是协程泄漏。它和内存泄漏不同,不是堆上对象没释放,而是大量 goroutine 卡在阻塞状态(比如 select{}、chan 读写、time.Sleep 或网络等待),无法退出,还持续占着栈内存(默认 2KB,可能扩容到几 MB)。
常见诱因:for 循环里无条件启协程但没配超时或取消;http.HandlerFunc 中启协程处理却忘了绑定 req.Context();用 sync.WaitGroup 但漏调 Done();或者协程自己 panic 了没 recover,导致 WaitGroup 永远等不到它。
- 用
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2查看所有 goroutine 的堆栈,重点关注卡在chan receive、select、semacquire的条目 - 上线前务必加
runtime.SetMutexProfileFraction(1)和runtime.SetBlockProfileRate(1),方便定位阻塞点 - 别依赖
defer wg.Done()在 panic 后执行——它不会运行。改用defer func(){ if r := recover(); r != nil { wg.Done() } }()或更稳妥的errgroup.Group
errgroup.Group 是协程池错误传播的底线配置
手写协程池时,如果只用 sync.WaitGroup + chan 收集错误,大概率会丢错:一个 goroutine panic,其他还在跑,主流程不知道该不该停;多个 error 同时写入同一个 error 变量,竞态覆盖。
errgroup.Group 不是“高级语法糖”,它是 Go 官方对“并发任务+错误聚合+上下文取消”三件套的最小可靠封装。它的 Go() 方法自动绑定父 context.Context,任一子协程返回非 nil error 或 context 被 cancel,其余协程会收到信号退出。
立即学习“go语言免费学习笔记(深入)”;
- 别直接传
context.Background()给errgroup.WithContext()—— 必须用带超时或可取消的 context,例如context.WithTimeout(ctx, 5*time.Second) -
g.Go(func() error { ... })内部不要另起 goroutine,否则脱离errgroup管理,错误和生命周期都失控 - 如果任务本身需要长时间阻塞(如轮询),必须在循环内检查
ctx.Err() != nil并主动 return,否则errgroup的 cancel 无效
自定义协程池里 recover() 不写在顶层,等于没写
很多人在协程池的 worker 函数里写 defer func(){ recover() }(),但位置错了:如果 recover 放在 for-select 循环外层,panic 发生在循环内部时,recover 已执行完毕,后续 panic 仍会导致整个 goroutine 终止且不通知池管理器。
正确做法是把 recover 套在每次任务执行的最内层,也就是实际业务逻辑包裹处。否则协程挂了,池子还当它活着,任务数虚高、资源不释放、错误无声丢失。
- worker 函数结构应为:
for { select { case task := - 别在
task.Run()外层 defer recover——task 本身可能也是个函数闭包,panic 发生在它内部,外层 defer 捕不到 - recover 后建议打日志(含 stack trace),否则 panic 被吞掉,问题彻底隐形
runtime.NumGoroutine() 不能当健康指标来监控
看到 runtime.NumGoroutine() 数值变大就告警?危险。Go 运行时本身会维护一堆后台 goroutine(如 net/http 的 keep-alive 清理、timerproc、gcBgMarkWorker),它们长期存在且数量随负载浮动。单纯比数字没意义。
真正要盯的是「非预期 goroutine」的增长趋势:比如每秒新启 100 个,但 5 分钟后仍有 95 个没退出;或者 pprof 显示同一堆栈反复出现上百次。
- 用
expvar.NewInt("goroutines_leaked")手动计数你明确知道该退出却没退的协程(比如从池中取出又归还失败的) - 在 HTTP handler 入口打点:
start := time.Now(); defer func(){ if time.Since(start) > 30*time.Second { log.Printf("slow handler: %v", debug.Stack()) } }(),间接暴露卡死协程 - CI 阶段跑
go test -race,很多协程泄漏伴随 data race,工具能提前揪出
协程泄漏最难调试的点在于:它不立刻崩,而是在高并发、长周期、混合 IO 的场景下缓慢积累。等你发现时,往往已经混着 context 超时、channel 关闭、锁竞争一起爆发——这时候再翻代码,得一层层确认每个 goroutine 的退出路径是否真的可达。










