必须手动轮询ctx.Done()——因计算类函数不自动响应Context,仅靠time.AfterFunc或return无法取消goroutine;需在for循环每次迭代及递归每层入口检查ctx.Done(),否则超时后仍占用CPU。

不能靠 time.AfterFunc 或单纯加个 return 就算取消——Go 协程无法被强制终止,必须在函数内部每处可能卡住的地方主动检查退出信号。
为什么耗时计算函数必须手动轮询 ctx.Done()
计算类函数(比如大数组排序、递归遍历、数值积分)通常不涉及 I/O,也就不会自动响应 Context;http.Client 或 time.Sleep 这类封装好的函数内部已集成 Context 检查,但你自己写的 for 循环、递归调用、密集计算逻辑不会自动停。一旦没检查,哪怕超时了,goroutine 仍在后台跑,CPU 白占,资源不释放。
常见错误现象:
- 调用了
context.WithTimeout,但函数仍跑满 10 秒才返回 - 用
select包了一次ctx.Done(),结果只在开头判断,中间循环完全无视 - 把
ctx传进去了,但函数体里根本没用它
怎么在 for 循环和递归中插入取消检查
关键不是“加一次”,而是“在每次迭代/每层递归入口都加”。尤其当单次迭代耗时不确定(比如带条件分支、外部调用、内存分配)时,延迟检查等于放弃控制权。
立即学习“go语言免费学习笔记(深入)”;
实操建议:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- for 循环:每个迭代开始前用
select { case ,不要用 <code>if ctx.Err() != nil—— 因为ctx.Err()只在取消后才变非 nil,而是阻塞等待信号的正确方式 - 递归函数:每次进入函数第一行就做
select检查,避免深层调用堆栈积压 - 如果计算逻辑本身可拆成小块(如分段处理 slice),就在每块之间插一次检查;不可拆则至少保证每 N 次迭代检查一次(N 根据业务容忍度定,比如 1000 次)
示例(简化版素数筛):
func sieve(ctx context.Context, limit int) ([]int, error) {
primes := make([]int, 0, limit/2)
for i := 2; i <= limit; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
isPrime := true
for j := 2; j*j <= i; j++ {
if i%j == 0 {
isPrime = false
break
}
}
if isPrime {
primes = append(primes, i)
}
}
return primes, nil
}
cancel() 必须调用,且不能漏掉 defer 场景
很多人以为 WithTimeout 返回的 cancel 是“可选清理”,其实它直接控制底层 timer 是否停止。不调用,timer 会一直活着,goroutine 和内存缓慢泄漏——长期运行的服务里,几天就能积出明显 GC 压力。
注意点:
- 函数有多个 return 路径(error early return、正常 return、panic),必须确保每个出口都触发
cancel() -
defer cancel()只适合单出口函数;多出口时要么显式调用,要么用匿名函数包装主逻辑,再统一 defer - 如果把
ctx传给了其他 goroutine,且由它们负责取消,那当前函数就不该defer cancel(),否则可能提前中断别人
别忽略计算函数里的隐式阻塞点
除了显式循环,还要警惕这些“看起来不阻塞,实际会卡住”的地方:
- 调用第三方库函数(比如某个 math 库的
Root()),不确定它是否响应 Context,就默认它不响应,自己包一层select - 使用
sync.Mutex.Lock():锁竞争可能长时间阻塞,应在尝试获取锁前先检查ctx.Done(),或改用带超时的TryLock(如果支持) - GC 压力大时,runtime 会 STW,这时即使你检查了
ctx.Done(),也可能被卡住几毫秒——这不是 Context 能解决的,但要知道它的边界
真正难的不是写那几行 select,而是想清楚:你的函数在哪会停、停多久、谁该决定它停。Context 不是开关,是协商机制;你得让每一步都愿意听。

















