最常被误判的goroutine泄漏场景是调用cancel()后goroutine未监听ctx.Done(),导致其卡在http.Client.Do等阻塞调用中持续持有资源,必须用select显式监听ctx.Done()并清理。

goroutine卡在select或chan receive状态,但ctx.Done()没被监听
这是最常被误判为“context已cancel,goroutine却还在”的场景:你调用了cancel(),但目标goroutine里压根没写select去监听ctx.Done()。它就卡在http.Client.Do(req)、或time.Sleep里,既不退出也不释放闭包变量。
排查时先用go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2看堆栈,如果大量goroutine状态是select或chan receive,且调用栈里没有ctx.Done()相关路径,基本就是这个原因。
- 必须在goroutine内部显式写
select { case ,不能只靠外部defer cancel() - 若调用的是阻塞I/O(如
http.Client.Do),要确保传入的是带超时的ctx,而不是context.Background() - 别在goroutine闭包里捕获大对象(比如整个
*http.Request或大slice),哪怕goroutine很快退出,只要ctx还活着,这些变量就无法GC
context取消信号在中间层被截断,下游goroutine收不到Done()
典型表现是pprof看到goroutine卡在数据库查询或HTTP调用上,而客户端早已断开——说明取消信号没传到底层。常见于中间件封装、异步任务分发、日志/监控埋点等环节意外把r.Context()丢掉了,转而用context.Background()重建子context。
检查所有涉及context.WithCancel、context.WithTimeout的地方,重点盯:
- HTTP handler里是否把
r.Context()完整透传给了worker goroutine?还是中途被赋值给了新变量又丢了? - 有没有类似
go doWork(context.Background())这种硬编码写法? - 自定义中间件是否在
next.ServeHTTP前就提前defer cancel(),导致后续逻辑拿不到有效ctx?
timer/ticker绑定context后没Stop(),闭包长期驻留
当你用time.AfterFunc或自己封装超时逻辑时,如果传入了带cancel的context,timer内部会持有该context引用。一旦忘记调用timer.Stop(),即使context已被cancel,timer goroutine仍存活,连带它捕获的所有变量(包括父级闭包里的channel、buffer、struct)都无法回收。
验证方法:pprof -inuse_space里搜*time.Timer或*time.Ticker,再点「Focus」看调用栈是否来自context.WithTimeout或context.WithCancel的上游。
- 所有手动创建的
time.Timer或time.Ticker,必须配对Stop(),建议用defer timer.Stop() - 避免在闭包中直接引用外部变量(如
func() { ch ),改用参数传入,减少捕获范围 - 优先使用
context.WithTimeout+http.NewRequestWithContext这类原生支持context的API,少自己手撸timer逻辑
pprof查到可疑结构体,但调用栈指向context.WithValue上游
如果go tool pprof -inuse_space发现大量*bytes.Buffer、*http.Request或业务struct长期驻留,且调用栈终点是context.WithValue,大概率是context被存进了长期生命周期结构体(比如全局map、sync.Pool、HTTP handler闭包),导致整棵context树无法释放。
关键判断点:
- 检查所有
context.WithValue调用,确认value是不是小元数据(如userID、traceID),绝不是业务实体或大对象 - 搜索代码里是否有
globalCache.Store("key", r.Context())这类操作——context绝不该进cache或单例 - 用pprof Focus后,顺着调用栈往上翻,看
WithCancel是不是在handler初始化或全局init里调用的;如果是,说明根context被长期持有了
真正难定位的不是“谁没监听Done”,而是“谁偷偷把context钉在了内存里”——它不报错、不阻塞、只悄悄吃内存,直到OOM才暴露。











