★ 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 贯穿+超时+限并发+优雅退出。
笔记加载中…