最直接信号是/debug/pprof/goroutine?debug=2中持续出现大量状态为select、chan receive或semacquire且阻塞时间不断增长的goroutine,尤其created by指向中间件函数时,基本可确认因未控生命周期、未关channel或无超时调用导致挂起。

怎么发现 goroutine 在中间件里卡死
最直接的信号是 /debug/pprof/goroutine?debug=2 页面中持续出现大量状态为 select、chan receive 或 semacquire 且阻塞时间不断增长的 goroutine,尤其当它们的 created by 指向某个中间件函数时——这基本就是死循环或未关闭 channel 导致的挂起。
常见诱因包括:
- 在中间件里启动 goroutine 但没用
context.WithCancel控制生命周期,又忘了cancel() - 用
for {}等待 channel 数据,但 sender 已退出或 channel 被 close 后没 break - 调用同步阻塞函数(如无超时的
http.DefaultClient.Do())且没包在select+ctx.Done()里
如何让中间件不成为 goroutine 泄漏温床
核心原则:中间件本身必须是同步、短生命周期的。所有异步逻辑都该明确归属到 handler 层,并受 context 约束。
实操建议:
- 禁用中间件内直接
go func() { ... }()—— 若真需异步,改用goroutine pool或交由后台任务队列处理 - 所有 channel 操作必须配超时或
select分支:select { case - 用
runtime.NumGoroutine()定期采样(比如每 10 秒),突增 >20% 就触发告警,而不是等 OOM - 在
gin.RecoveryWithWriter()前注册一个轻量级中间件,记录进入/退出时间,对耗时 >5s 的请求打日志并 dump goroutine stack
为什么 pprof/goroutine?debug=1 不够用
/debug/pprof/goroutine?debug=1 只显示按栈归并后的 goroutine 数量,比如 goroutines: 142,但它无法告诉你哪一类正在缓慢累积。真正有用的是 ?debug=2 输出里的三类线索:
- 每行开头的数字是 goroutine ID,配合
created by xxx.go:123可定位到中间件文件和行号 - 状态字段如
[select, 4m23s]表示已卡在 select 上 4 分多钟,大概率是 channel 无人写入或 ctx 未取消 - 堆栈末尾若反复出现
runtime.gopark+ 中间件名,基本可确认是该中间件引发的挂起
注意:debug=2 输出体积大,生产环境别频繁 curl,建议只在怀疑泄漏时手动抓一次,或用脚本定期采样前 100 行做关键词过滤(如匹配 middleware、select、chan receive)。
expvar 和自定义指标怎么辅助判断
expvar 本身不直接暴露 goroutine 状态,但你可以用它暴露中间件关键路径的计数器,辅助交叉验证:
- 定义
expvar.NewInt("middleware_auth_blocked"),在鉴权中间件里检测到非法 token 时递增——如果这个值长期为 0,但NumGoroutine()持续上涨,说明问题不在鉴权逻辑 - 用
expvar.NewMap("middleware_timeout")记录各中间件超时次数,若某中间件 timeout 飙升,再结合 pprof 查它的 goroutine 堆栈 - 不要只依赖
runtime.NumGoroutine(),它包含 runtime 内部 goroutine;更准的是debug.ReadGCStats里的LastGC时间差 +NumGoroutine()趋势对比,排除 GC 暂停干扰
真正难排查的不是“有没有挂起”,而是“挂起在哪一层”——pprof 提供栈,expvar 提供上下文行为,两者缺一不可。别指望单靠一个接口就定位死循环,得像查案一样比对时间线、调用链和资源变化。











