defer cancel() 错误是因为 WithTimeout/WithDeadline 已自动触发 cancel,重复调用会导致 panic 或重复日志;仅 WithCancel 需 defer cancel;HTTP 请求须用 WithContext 创建 req 并传给 Do;long-running 操作必须用 select 监听 ctx.Done()。

用 context.WithTimeout 或 context.WithDeadline 创建的上下文,超时后会自动调用 cancel,不需要、也不应该在 defer 里再手动调用一次。
为什么 defer cancel() 是错的?
很多人看到示例里写了 defer cancel(),就以为这是“标准写法”,其实它只适用于 context.WithCancel 场景。而 WithTimeout 和 WithDeadline 内部已注册定时器,时间一到就会触发 cancel —— 此时再 defer 调用一次,等于对已关闭的通道重复操作,可能 panic(尤其是多个 goroutine 并发调用时)。
常见错误现象:
-
panic: sync: negative WaitGroup counter(如果cancel被多次调用且关联了sync.WaitGroup) -
panic: send on closed channel(cancel内部向Done()通道发送信号,重复调用会重发) - 日志里出现
context canceled两次,但实际只超时了一次
正确做法:
立即学习“go语言免费学习笔记(深入)”;
- 仅在需要提前终止(非超时路径)时调用
cancel(),且确保只调用一次 - 比如 HTTP handler 中,中间校验失败、鉴权不通过,需立刻取消后续子任务
- 超时场景下,完全不用管
cancel,让它由系统自动触发
HTTP client 必须显式传入 context,client.Timeout 不够用
http.Client.Timeout 只控制从连接建立到读完响应体的总耗时,它不覆盖 DNS 解析、TLS 握手、连接池等待等前置阶段。而 context.Context 是唯一能统一贯穿整个请求生命周期的机制。
关键点:
- 必须把 context 注入到
*http.Request中:用req.WithContext(ctx) - 必须把带 context 的 request 传给
client.Do();只改req.Context()但调用client.Do(req)是无效的 - 错误写法:
req = req.WithContext(ctx); client.Do(req)—— 看似对了,但若req是从http.NewRequest构造而来,且未做深拷贝,可能被并发修改导致 context 丢失
推荐写法:
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) resp, err := client.Do(req)
监听取消必须用 select + ctx.Done(),不能只轮询 ctx.Err()
在 long-running 操作(如轮询、流式读取、for-select 循环)中,仅靠 if ctx.Err() != nil 判断是危险的:它只能发现“已经发生的取消”,无法打断当前阻塞操作(比如 time.Sleep、io.Read、chan recv),会导致延迟响应甚至永久卡住。
正确方式永远是:
- 用
select等待ctx.Done()或其他业务事件 - 所有可能阻塞的操作都应放在
select分支里,而非单独执行 -
ctx.Err()只用于退出后检查原因(比如区分context.Canceled和context.DeadlineExceeded)
反例:
for {
if ctx.Err() != nil {
return
}
doWork()
time.Sleep(1 * time.Second) // 这里会卡满 1 秒,哪怕 ctx 已超时
}正例:
for {
select {
case <-ctx.Done():
return
default:
doWork()
select {
case <-time.After(1 * time.Second):
case <-ctx.Done():
return
}
}
}多个 goroutine 共享同一 ctx 时,cancel 只需调一次
只要所有 goroutine 都基于同一个 ctx 调用 ctx.Done(),那么一次 cancel() 就能同时唤醒全部监听者。不需要为每个 goroutine 单独维护 cancel 函数,也不该重复调用。
容易踩的坑:
- 把
ctx存进 struct 字段再传参 —— 违反 “Context 应作为函数第一参数传递” 原则,导致取消信号无法穿透调用链 - 在不同 goroutine 中各自调用
context.WithTimeout创建新 ctx —— 它们互不影响,取消一个不会影响另一个 - 忘记在函数签名中加
ctx context.Context参数,导致下游无法响应取消
真正难的不是写对那几行代码,而是判断什么时候该用 WithCancel、什么时候该用 WithTimeout,以及在哪一层调用 cancel() 才既安全又及时 —— 这得看调用链的控制权在谁手里,而不是看有没有 defer。


















