★ goroutine 泄漏与服务稳定性:如何排查
一句话结论:goroutine 泄漏的本质是“goroutine 退不出去”:阻塞在永远等不到结果的 channel、select 没有超时/退出分支、后台任务不随主流程取消、ticker 没停、无限起协程没有并发上限。外在表现是进程内存与句柄只涨不降、GC 压力大、延迟劣化,最终 OOM 或连接池耗尽。排查三板斧:pprof 抓 goroutine 栈看堆积点、连续采样对比数量增长、runtime.NumGoroutine 上监控告警;修复靠统一生命周期纪律:context 贯穿、阻塞必带 ctx.Done/超时、worker 限并发、ticker defer Stop。
常见泄漏来源
| 场景 | 原因 | 修复 |
|---|---|---|
| channel 收发无人配对 | 阻塞永不返回 | 用带缓冲或 select 加 default/超时 |
| for + select 无线程退出条件 | 协程随服务常驻 | select 加 ctx.Done(),随请求/服务退出 |
| 每请求起 goroutine 不限量 | 任务堆积 | 信号量/worker pool 限并发 |
| 后台任务无生命周期 | 服务退出后残留 | 根 ctx 取消,wg.Wait 等收尾 |
| time.Tick/time.After 循环 | 定时器无法停止 | 用 time.NewTicker + defer Stop() |
排查示例
// 1. 暴露 pprof(生产要鉴权或内网)
import _ "net/http/pprof"
go http.ListenAndServe(":6060", nil)
// 访问 /debug/pprof/goroutine?debug=2 看全部栈,或:
// go tool pprof http://localhost:6060/debug/pprof/goroutine
// 隔几分钟各抓一次,对比数量与栈顶函数增长
// 2. 单元测试防泄漏(uber-go/goleak)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // 测试结束后断言没有遗留 goroutine
}
// 3. 线上监控
// 定期记录 runtime.NumGoroutine(),超过基线且不回落即告警
修复套路
- 生命周期统一:main 建 rootCtx(signal 触发 cancel),所有后台 goroutine 都监听 ctx.Done()。
- 阻塞调用三件套:select 里永远带上 ctx.Done() 或定时器,避免无限等。
- 优雅退出顺序:停接收新流量(http.Server.Shutdown)→ cancel 后台任务 → wg.Wait 等收尾 → 超时强制退出。
- 定时器纪律:time.NewTicker 用完 Stop;避免 time.Tick 与循环内 time.After。
追问记忆点
- 追问:为什么内存涨但看不出谁泄漏?——先看 goroutine 数量与 pprof 栈,泄漏协程会成堆卡在同一等待点,比猜内存更直接。
- 追问:select 里只监听 channel 够吗?——不够,生产代码的每个阻塞点都要问“它怎么退出”。
- 记忆点:泄漏=退不出去;排查看 pprof goroutine+NumGoroutine 监控;修复靠 ctx 贯穿+超时+限并发+优雅退出。