最典型的 goroutine 泄漏源头是中间件中启动协程未监听 context.done():请求结束、gin.context 回收后协程仍在运行;正确做法是传入 c.request.context() 并在 select 中监听 done()。

中间件里启动 goroutine 却没传 context.Done()
这是最典型的泄漏源头:中间件中用 go 启动子协程,但没监听 ctx.Done(),导致请求结束、gin.Context 被回收后,子协程还在死等 channel 或 sleep。比如日志上报、异步审计这类“不阻塞主流程但要保证执行”的逻辑,很容易写成这样:
func AuditMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// ❌ 错误:没绑定 context,请求超时或取消后 goroutine 仍运行
go func() {
time.Sleep(2 * time.Second)
auditLog(c.ClientIP(), c.Request.URL.Path)
}()
c.Next()
}
}
正确做法是把 c.Request.Context() 传进去,并在 select 中监听 Done():
- 用
context.WithTimeout()或context.WithCancel()派生新 context - 子协程内必须有
select { case 分支 - 避免直接使用原始
c,应只取需要的字段(如c.ClientIP())并传参,防止gin.Context被意外持有
recover 中间件失效导致 panic 波及主 goroutine
如果自定义中间件里发生 panic,而你又没启用 gin.Recovery(),或者用了 gin.New() 却忘了手动注册它,整个 HTTP server 就会崩溃——这不是协程泄漏,但常被误判为“服务突然挂了”,其实只是 panic 没兜住。
检查点:
- 是否用了
gin.Default()?它默认带Logger()和Recovery() - 如果用
gin.New(),确认是否显式调用了r.Use(gin.Recovery()) - 别在中间件里写
defer recover()—— Gin 的Recovery是全局拦截器,自己加反而干扰链路
一个常见错误是:在 Recovery 中间件之后又写了个 panic 触发点,结果 recovery 已经跑过了,panic 直接上抛到 http.Server.ServeHTTP 层。
goroutine 数量持续上涨却查不到源头
用 runtime.NumGoroutine() 看到数字不断涨,但 pprof 的 /debug/pprof/goroutine?debug=2 又全是系统 goroutine?大概率是中间件里启的 goroutine 处于 chan receive 或 time.Sleep 状态,没卡在栈顶,所以默认 profile 不显示完整堆栈。
这时候要:
- 访问
http://localhost:6060/debug/pprof/goroutine?debug=2(注意?debug=2),它会输出所有 goroutine 的完整调用栈,包括阻塞中的 - 搜索关键词:
auditLog、sendNotification、cache.Refresh这类业务中间件名 - 重点看状态为
IO wait、chan receive、select的 goroutine,它们往往就是泄漏点
另外,别忽略 sync.WaitGroup 忘记 Done() 的情况——尤其在嵌套 goroutine + wg.Add(1) 后没配对调用的地方。
中间件顺序错乱引发 context 生命周期错位
比如你写了两个中间件:AuthMiddleware 和 TraceMiddleware,但注册顺序是:
r.Use(TraceMiddleware()) r.Use(AuthMiddleware())
而 TraceMiddleware 里提前启了 goroutine 并持有了 c,但 AuthMiddleware 后面可能 abort 了请求(比如 token 过期返回 401),这时 TraceMiddleware 的 goroutine 依然在跑,且拿不到正确的上下文状态。
关键原则:
- 所有可能 abort 的中间件(鉴权、限流、参数校验)必须放在异步操作类中间件之前
- 异步中间件应尽量只读取已确定有效的字段(如 path、method、client IP),不要依赖后续中间件注入的数据
- 如果必须依赖,就用
c.Get("key")+ 判空,而不是直接假设值存在
真正难排查的泄漏,往往不是代码写错了,而是 context 生命周期和 goroutine 生命周期没对齐——前者结束了,后者还攥着引用不肯松手。











