worker必须在select中持续监听ctx.done(),而非仅开头检查一次;否则cancel后仍运行。正确做法是每次迭代都用select等待任务或ctx.done(),一旦收到取消信号即退出。

worker 必须在 select 中监听 ctx.Done(),不能只检查一次
现象是调用 cancel() 后 worker 仍在运行、goroutine 数不降。根本原因不是没传 context,而是 worker 没持续响应取消信号。比如只在函数开头判断 if ctx.Err() != nil { return },之后就进长循环,再无检查——这等于把 context 当成一次性开关。
正确做法是每次迭代都通过 select 等待两件事:新任务到来,或上下文被取消。只要 ctx.Done() 就立刻退出循环,不等下一次迭代。
- 别用
for ctx.Err() == nil,它只检查一次,且无法中断阻塞操作 - 所有 I/O、sleep、channel 操作前,必须包裹在
select里,其中至少一个分支是 - 若
handle(job)是耗时操作且业务要求必须完成,那得配合sync.WaitGroup,但监听ctx.Done()仍不可少——它只负责停止“领取新任务”,不干预“执行中任务”
阻塞操作没封装 ctx,会导致 ctx.Done() 形同虚设
常见失效场景:worker 里写了 time.Sleep(5 * time.Second) 或 jobChan (无缓冲 channel),结果 cancel 后卡住不动。因为这些调用不响应 context,<code>select 分支永远等不到 ctx.Done() 被触发。
必须把阻塞点替换成带 context 的等效写法:
- 用
select { case 替代裸 <code>time.Sleep - HTTP 请求必须用
http.Client.Do(req.WithContext(ctx)),不能用默认 client - 数据库查询必须用
db.QueryContext(ctx, query),而非db.Query() - 向 channel 发送数据前,先
select判断是否该退出,避免死锁
多个子 goroutine 共享同一个 ctx,但各自要派生自己的 cancel
现象:主协程调用 cancel(),一级 worker 退出了,但它 spawn 的二级 consumer 却还在跑。这是因为子 goroutine 复用了父级的 ctx 变量,没做派生,导致取消信号无法向下传递。
每个新启动的 goroutine 都应基于上游 ctx 派生独立的 context:
- 用
childCtx, childCancel := context.WithCancel(parentCtx)创建子 context - 把
childCtx传给子 goroutine,并确保它也监听自己的childCtx.Done() - 子 goroutine 退出时调用
childCancel()(可选,用于提前释放资源) - 不要用
context.WithValue()替代取消控制——它不携带取消能力
监听 ctx.Done() 时忽略 default 分支,否则信号可能被“抢走”
如果 select 里有多个就绪 channel(比如任务 channel 刚好有数据、ctx.Done() 也已关闭),Go 运行时会随机选择一个分支执行。若你把 ctx.Done() 放在 default 分支里,它永远得不到执行机会——因为 default 是非阻塞的,只要有其他分支就绪,它就被跳过。
正确结构必须让 ctx.Done() 成为显式可选分支:
- 写成
case ,而不是 <code>default: if ctx.Err() != nil { return } - 任务 channel 接收也要写成
case job, ok := ,并检查 <code>ok == false(通道已关闭) - 避免在
select外层加if判断,把退出逻辑全收束到case分支内
真正难的不是写对第一行 select,而是确保每处可能阻塞的地方都被覆盖——尤其那些藏在工具函数、第三方库回调、或嵌套 goroutine 里的调用点。漏掉一处,整个退出流程就卡在那里。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











