runtime.numgoroutine() 不能直接当泄漏检测器用,因其仅返回总数且不区分活跃与卡死goroutine;需结合受控上下文差值比对和pprof定位真实泄漏源。

为什么 runtime.NumGoroutine() 不能直接当泄漏检测器用
runtime.NumGoroutine() 返回当前存活的 goroutine 总数,但它不区分“活跃”和“卡死/遗忘”的 goroutine。比如一个正常运行的服务,goroutine 数可能在 10–50 之间波动;但如果某次请求后它从 20 持续涨到 200、再涨到 2000,才值得怀疑——关键不是绝对值,而是**不可控的增长趋势**。
常见误用是只在启动时记一次 baseline,然后隔几秒查一次,发现数字变大就报警。这忽略了 HTTP server、database/sql 连接池、日志异步 flush 等合法后台 goroutine 的动态增减。
- HTTP server 默认启用
http.Server.IdleTimeout,但长连接期间会维持 goroutine,不是泄漏 - 第三方库(如
github.com/redis/go-redis)内部用 goroutine 做心跳或重连,数量稳定就不算泄漏 - 用
time.AfterFunc或time.Ticker后没显式Stop(),会导致 goroutine 永久存活
怎么用 runtime.NumGoroutine() 做有效对比
核心是做**受控上下文下的差值比对**:同一操作反复执行,观察增量是否收敛。比如压测一个 handler,每轮调用前记一次数,调用后等 2 秒再记一次,重复 10 轮,看 delta 是否趋近于 0。
示例代码片段(非完整服务):
func TestHandlerLeak(t *testing.T) {
// 启动 HTTP server(略)
base := runtime.NumGoroutine()
for i := 0; i
- 必须加
time.Sleep,否则可能测到正在执行中的临时 goroutine - 不要用
runtime.GC()强制回收——goroutine 不受 GC 管理,调了也没用 - 如果某轮 delta > 2 且持续增长(比如 1→3→6→12),基本可定位为泄漏
配合 pprof 定位泄漏源头
runtime.NumGoroutine() 只告诉你“多了”,pprof 才告诉你“多在哪”。一旦确认异常增长,立刻抓 goroutine profile:
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt
重点关注:
- 状态为
select或chan receive且堆栈里没有超时控制的 channel 操作 - 无限循环里没
time.Sleep或select的 goroutine - 调用链含
net/http.(*persistConn).readLoop但 client 已断开,说明没正确关闭 resp.Body
典型泄漏模式:go func() { defer wg.Done(); doWork(); }() 中 doWork() panic 且没 recover,wg.Done() 永远不执行,goroutine 卡住。
真正要检查的不是 goroutine 数量,而是资源生命周期
很多“泄漏”本质是 goroutine 持有资源没释放:未关闭的 http.Response.Body、未 cancel 的 context.Context、未 Close() 的 sql.Rows。这些会让 goroutine 无法退出,但问题根因不在 goroutine 本身。
- 所有
http.Do()后必须resp.Body.Close(),否则底层连接 goroutine 卡在 read loop - 启动 goroutine 前,优先用带 cancel 的 context:
ctx, cancel := context.WithCancel(parentCtx); defer cancel() - 用
for rows.Next()读数据库时,务必确保rows.Close()在 defer 中执行,哪怕循环提前 break
单纯盯着 runtime.NumGoroutine() 数字,容易忽略这些更隐蔽、破坏性更大的资源泄漏点。











