必须加?debug=2才能获取全量goroutine栈,否则默认仅返回running/runnable状态的协程,漏掉大量卡在chan receive、select、锁等待或netpoll的泄漏协程。

访问 /debug/pprof/goroutine?debug=2 返回的 goroutine 数量远超预期
线上服务 RSS 持续上涨、HTTP 延迟飙升,但 pprof 默认路径 /debug/pprof/goroutine 显示只有几十个 goroutine?这是典型漏报——默认只返回 running 或 runnable 状态的 goroutine,而泄漏的绝大多数卡在 chan receive、select、semacquire(锁等待)或 netpoll(网络 I/O 阻塞)上。
必须加 ?debug=2 参数获取全量栈:
curl "http://localhost:8080/debug/pprof/goroutine?debug=2" > goroutines.pb.gz
重点关注以下特征的调用栈:
- 大量重复出现
runtime.gopark+sync.(*Mutex).Lock或sync.(*RWMutex).RLock—— 写锁争用或读写锁混用导致阻塞 - 堆栈含
context.WithValue+gin.(*Context).Set+http.readRequest—— Gin context 被误传给长期 goroutine 且未 cancel - 出现
net/http.(*persistConn).readLoop或net.(*netFD).Read但无对应 handler 返回 —— HTTP 连接未被及时关闭或 client 超时设置不合理
用 gin-contrib/pprof 注册后仍 404,或返回空 HTML
Gin 默认不接管 net/http/pprof 的路由,直接 import _ "net/http/pprof" 无效;自己手写 gin.WrapH(http.DefaultServeMux) 又容易漏 endpoint 或引发超时中断。
正确做法是使用官方维护的 gin-contrib/pprof:
- 确保在
router.Run()之前调用pprof.Register(router) - 不要在中间件里注册,它注册的是全局路由,不是请求级逻辑
- 生产环境务必加访问控制:用
router.Group("/debug", authMiddleware)包裹后再注册,否则/debug/pprof/heap?seconds=60这类高危 endpoint 会直接暴露 runtime 信息 - 若改了前缀(如
pprof.Register(router, "admin/pprof")),所有路径都需同步调整,包括 curl 和 pprof 工具里的 URL
top 输出显示某函数占 goroutine 总数 99%,但代码里只开了一次 go
比如 go handleEvent(c) 在 handler 里只调用一次,但 pprof 显示 handleEvent 占了 46 万个 goroutine —— 说明该 goroutine 没退出,且在循环中不断新建同类实例。
常见根因和检查点:
- 函数内部用了无缓冲 channel 发送但无接收方:
ch := make(chan int); go func() { ch → 永久阻塞 - HTTP client 请求后没关
resp.Body,导致底层连接池耗尽、后续请求在net/http内部卡住并堆积 goroutine - 用
c.Request.Context()直接传给后台 goroutine,但没设超时或 cancel;一旦请求提前结束,goroutine 仍持有已失效 context 引用,无法感知退出信号 - 在中间件或 handler 中调用
c.Copy()后传入 goroutine,副本仍引用原始 request body,GC 无法回收,且 goroutine 生命周期脱离请求控制
pprof 抓到 goroutine 栈但看不出谁启动的,怎么回溯源头
pprof 的 goroutine profile 本身不带调用链起点,只能看到当前阻塞点。要定位“谁 spawn 了它”,得结合三类线索交叉验证:
- 看 goroutine 栈里是否有
gin.(*Context).HandlerFunc或gin.(*Engine).ServeHTTP—— 表明来自某个 handler,再查对应路由的代码是否用了go xxx(c) - 搜索日志中高频出现的 traceID 或 requestID,匹配其时间窗口内的 goroutine 快照,缩小范围
- 临时加
runtime.Stack打印启动点:在疑似位置插入buf := make([]byte, 4096); runtime.Stack(buf, false); log.Printf("spawned at: %s", buf),上线后采样几次即可锁定模式 - 若用
errgroup.Group或sync.WaitGroup,检查是否漏了eg.Wait()或wg.Done(),导致主 goroutine 提前退出,子 goroutine 失去同步锚点
最易被忽略的是:Gin context 生命周期与 goroutine 生命周期错配——handler 返回后,context 被标记 done,但 goroutine 里还在用 c.Value 或 c.Get,此时值可能已失效,更关键的是 GC 无法回收 context 持有的所有引用,泄漏就藏在这里。











