不调用 cancel() 必然导致 timer 和 goroutine 泄露;因其是唯一能关闭 done() 通道、停止 time.timer 并解除父子引用的机制,defer cancel() 是函数级资源释放的默认且必要方式,漏调则泄漏确定发生。

不调用 cancel() 就一定会泄露,不是“可能”,是确定的 timer 和 goroutine 泄露。
为什么 defer cancel() 不是可选项,而是资源释放的唯一出口
Go 的 context.WithTimeout 内部会启动一个 *time.Timer,并 spawn 一个 goroutine 等待超时触发。这个 timer 不会因为业务逻辑提前结束而自动停掉——它只认两个信号:超时时间到,或你显式调用 cancel()。
- 没调
cancel()→ timer 继续跑,goroutine 持续阻塞在timer.C上 - 即使函数已 return,只要没执行
cancel(),timer 就不会被Stop(),也不会被 GC -
defer cancel()是最自然的释放时机:函数退出即清理,无论正常 return 还是 panic - 如果函数里有循环、长连接或 watch 类逻辑,
defer在函数入口声明就失效了(比如etcdWatchLoop场景),必须改用显式调用 + 多出口覆盖
ctx.Done() 通道没关,下游 select 就永远卡住
ctx.Done() 是个只读 channel,它的关闭是取消信号的唯一广播方式。不调 cancel(),哪怕业务早做完,这个 channel 也一直开着,导致所有监听它的 select 永远等不到关闭信号。
- 常见表现:协程 stuck 在
case 分支,CPU 占用低但无法退出 - HTTP handler 中若启动 goroutine 并传入未 cancel 的 ctx,该 goroutine 可能比 request 生命周期还长
-
ctx.Err()一直返回nil,直到超时才变成context.DeadlineExceeded,掩盖提前失败的真实原因 - 错误写法:
ctx, _ := context.WithTimeout(...)—— 丢弃cancel就等于主动放弃资源控制权
父子 context 引用链不断,内存泄漏肉眼可见
每个 context.WithTimeout 创建的子 context 都会被父 context 记录在 children map[*timerCtx]struct{} 中。这个 map 是强引用,GC 不会回收。
- 高频请求场景(如 API server)中,每请求漏一次
cancel(),childrenmap 就涨一个条目 - pprof heap 中能看到大量
*time.Timer、chan struct{}和闭包变量堆积 - goroutine profile 里出现大量
runtime.timerproc,数量随请求数线性增长 - 嵌套
WithTimeout更危险:多个 timer 并存,但只有一个能被 cancel 清理,其余照常泄露
cancel() 被存到结构体字段或全局 map 的后果
把 cancel 函数保存起来“以后再用”,是比忘记调用更隐蔽的泄漏源。
- 结构体字段存
cancel context.CancelFunc→ 该结构体存活一天,timer 就跑一天 - 全局 map 缓存
cancel→ key 不删,timer 永不释放;key 删了但没调cancel(),照样泄漏 - 第三方库回调中接收
cancel但不保证调用 → 你得自己 wrap 一层,确保最终执行 -
cancel()是幂等的:重复调用无副作用,所以“多调一次”比“少调一次”安全得多
真正容易被忽略的点在于:泄漏不是立刻 OOM,而是随着 QPS 累积缓慢上涨;等到监控报警,往往已在线上跑了几天。timer 和 goroutine 看似轻量,但成千上万个堆在一起,就是稳定的内存增长曲线和不可预测的延迟毛刺。











