goroutine泄漏的铁证是runtime.NumGoroutine()持续单向上涨且pprof debug=2显示大量goroutine卡在chan receive/select/semacquire;需通过@idle/@start/@done三处打点观察趋势,结合goleak VerifyNone事前拦截与HTTP全链路超时配置修复。

goroutine 泄漏不是“可能有”,而是 runtime.NumGoroutine() 持续单向上涨 + pprof 显示大量 goroutine 卡在 chan receive/select/semacquire —— 这就是铁证。
怎么确认真泄漏,而不是初始化抖动
刚启动时 runtime.NumGoroutine() 跳到 80+ 很正常:pprof、健康检查、日志采集器都在起 goroutine。关键看趋势:
- 单次 HTTP 请求入口打点是 85,handler 返回前再打一次还是 85+,说明没回落
- 压测结束等 30 秒,总数仍比空闲态(比如 60)高出 40 以上
- 服务稳定运行几小时,从 50 → 180 → 420 这样单调爬升
实操建议:在关键路径加三处日志,别只采一次:log.Printf("goroutines@idle: %d", runtime.NumGoroutine())、@start、@done;采样间隔至少 time.Sleep(100 * time.Millisecond),否则漏掉靠超时退出的协程。
pprof debug=2 看到 chan receive 就基本锁定了
访问 http://localhost:6060/debug/pprof/goroutine?debug=2,重点关注这些状态:
立即学习“go语言免费学习笔记(深入)”;
-
chan receive (nil chan):监听了未初始化或已关闭的 channel -
select卡死:所有 case 都不可达,且没写default -
semacquire:锁没释放、sync.WaitGroup忘记Done() -
IO wait:比如io.ReadFull卡住、http.Client没配context、ssh.Dial没设超时
如果看到几百个 goroutine 全卡在同一个函数里,阻塞时间显示 “432000s”(5 天),基本不用怀疑,就是它。注意:?debug=1 只给统计摘要,看不出谁卡在哪,必须用 ?debug=2。
HTTP client/server 超时缺失是批量泄漏主因
每个未超时的 HTTP 连接背后,至少挂着 2 个 goroutine(readLoop+writeLoop)。它们不释放,就一直占着 fd 和栈内存:
- 客户端用
http.DefaultClient:没有连接池限制、没有超时、keepalive 连接永不关闭 - 服务端没设
ReadTimeout/WriteTimeout/IdleTimeout:空闲连接长期滞留,goroutine 不退出 -
http.NewRequest未绑定context.WithTimeout:下游响应慢时,上游 goroutine 无限等待 - 忘记
resp.Body.Close():底层连接无法复用,触发新建连接 + 新 goroutine
修复必须两端同步:http.Client 要带 timeout + transport.MaxIdleConnsPerHost 限制;服务端配置全链路超时,并启用 http.TimeoutHandler 做兜底;所有 io.Closer 类型对象(file / db / resp.Body)都必须显式 Close()。
测试阶段用 goleak.VerifyNone(t) 拦截泄漏
pprof 是事后诊断,goleak 是事前拦截。它靠比对测试前后 goroutine 快照,报告“新增但未退出”的协程及其初始调用栈:
- 必须写成
defer goleak.VerifyNone(t),否则 test panic 时不会执行 - 别在
defer后又立刻return,否则 verify 执行时 goroutine 已退出,漏检 - 测试里合法启了 HTTP server?得手动
srv.Shutdown(),否则goleak会报它
真正难排查的不是“哪里泄漏”,而是“为什么泄漏后还看不出”——比如 time.Tick 创建的 ticker goroutine 永不关闭,或 context 传递断裂导致下游 goroutine 失去“叫停”能力。这类问题不会立刻爆发,但会在高并发或长连接场景下缓慢拖垮服务。


















