context.withcancel需子协程主动监听ctx.done()才能生效,常见失效原因包括未循环检查、select中done()被抢占、阻塞调用未封装、关闭后仍写channel;正确做法是每次循环和i/o前检查、select中必含done()分支、优先使用带context的api。

context.WithCancel 是控制子协程生命周期最可靠的方式,但直接传入 ctx 并不等于自动退出——必须在子协程内部主动监听 ctx.Done(),否则取消信号完全无效。
子协程不响应 cancel 的常见原因
现象:调用 cancel() 后,子协程仍在运行,CPU 占用不降,日志持续输出。
- 没在子协程里检查
ctx.Done(),比如只做了一次判断就进入长循环,后续再无检查 - 用了
select但没把ctx.Done()放在 default 分支之外的可选分支里,导致被其他就绪 channel “抢走”执行权 - 子协程阻塞在无法中断的系统调用上(如
time.Sleep、无缓冲 channel 发送),且未配合ctx做超时或中断封装 - 父 Context 被 cancel 后,子协程仍持有对已关闭 channel 的引用并继续写入,引发 panic(尤其在未加 recover 的场景)
正确监听 ctx.Done() 的写法
核心原则:每次循环迭代、每次 I/O 操作前,都应检查是否该退出。避免“只检查一次”或“只在开头检查”。
- 用
select+ctx.Done()包裹关键操作,而不是仅在函数入口判断 - 对可能阻塞的操作,优先使用带 context 的版本(如
http.Client.Do(req.WithContext(ctx))、time.AfterFunc替代裸time.Sleep) - 若必须用
time.Sleep,改用time.Sleep+select组合,监听ctx.Done()提前唤醒 - 处理 channel 读写时,始终将
ctx.Done()作为select的一个分支,防止死锁或遗漏
示例:
func worker(ctx context.Context, jobs <h3>多级子协程间取消传播的陷阱</h3> <p>现象:一级子协程收到 cancel,但它 spawn 的二级子协程仍在跑。</p>
- 每个新协程都必须用
context.WithCancel(parentCtx)或context.WithTimeout派生自己的 Context,不能复用父协程的ctx变量(虽能传递取消,但无法独立控制) - 派生后,必须把新
ctx传给下级协程,并确保下级也监听自己的ctx.Done(),而非父级的 - 如果某级协程需要提前退出但不影响下游,就不要调用它的
cancel;若要彻底终止整条链,只需调用最上游的cancel,其余会自动级联关闭 - 注意
context.WithValue不影响取消行为,但若混用,容易误以为“传了值=传了控制权”
cancel 函数何时调用、谁来调用
关键点:cancel 函数不是“启动开关”,而是“终止开关”,且只能由创建者负责调用。
- 必须在创建
ctx, cancel := context.WithCancel(...)的同一作用域内安排调用时机,通常用defer cancel()——但仅限于该 Context 生命周期明确结束的场景(如函数返回) - 若 Context 需跨 goroutine 生存(如 HTTP handler 中派生),则 cancel 应由外部信号触发(如
os/signal监听SIGTERM),不能 defer - 绝不能把
cancel函数传给不可信的第三方库或回调,以防被意外调用 - 调用一次
cancel()后,该函数变为幂等,但再调用已无意义;反复调用也不会 panic,但属于冗余逻辑
最容易被忽略的是:cancel 后,ctx.Err() 才会从 nil 变为非空,而很多日志或错误处理逻辑依赖这个值做判断——别只盯着 Done() channel 关闭,忘了查 Err() 确认原因。











