Go 1.21 context.WithoutCancel 实战:父 context 取消了,收尾任务还要继续跑
HTTP 请求处理到一半,客户端断开连接,r.Context() 被取消------这是好事,能及时停掉没用的工作。但有个尴尬场景:你在这个请求里还想异步记一条审计日志、或者把结果写回缓存,这些收尾工作不该 跟着请求一起被取消。Go 1.21 新加的 context.WithoutCancel 就是为这个来的。这篇讲清楚它解决什么、怎么用,以及顺带认识同批加入的 WithDeadlineCause 和 Cause。
先看问题:取消会顺着 context 树往下传
Go 的 context 取消是级联的:父 context 一取消,所有从它派生的子 context 全部取消。平时这正是我们想要的,但看这段:
go
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result := doWork(ctx)
// 想异步记审计日志,但用了同一个 ctx
go func() {
// 如果客户端此刻断开,ctx 被取消,这条日志就写不进去了
writeAuditLog(ctx, result)
}()
w.Write(result)
}
问题:writeAuditLog 用的是请求的 ctx。一旦客户端断连或请求结束,ctx.Done() 触发,数据库驱动看到 context 取消会直接放弃写入,审计日志丢了。而审计日志恰恰是"请求都结束了也得落库"的东西。
朴素的错误修法:直接用 context.Background()
很多人会这样绕过:
go
go func() {
writeAuditLog(context.Background(), result) // 换成全新的 ctx
}()
日志确实能写了,但你把父 context 里携带的所有值 都丢了:trace ID、租户信息、请求级的 logger......全没了。因为 context.Background() 是一张白纸,它不知道原来那条链上挂了什么。审计日志里少了 trace ID,出了问题根本关联不起来。
正确写法:context.WithoutCancel
WithoutCancel 返回一个新的 context:它保留父 context 的所有 Value,但切断取消信号。父被取消,它不受影响;父携带的值,它照样能读。
go
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result := doWork(ctx)
// 派生一个"不会被取消"的 ctx,但 trace ID 等值都还在
detached := context.WithoutCancel(ctx)
go func() {
// 请求结束/客户端断连都不影响它,审计日志照样落库
writeAuditLog(detached, result)
}()
w.Write(result)
}
验证一下它的行为:
go
func main() {
parent, cancel := context.WithCancel(context.Background())
parent = context.WithValue(parent, "traceID", "abc-123")
detached := context.WithoutCancel(parent)
cancel() // 取消父 context
fmt.Println("parent Err: ", parent.Err()) // context canceled
fmt.Println("detached Err:", detached.Err()) // <nil> ------ 没被取消
fmt.Println("traceID: ", detached.Value("traceID")) // abc-123 ------ 值还在
}
detached.Err() 是 nil、detached.Done() 永远不会关闭,但 traceID 依然读得到。这就是它和 context.Background() 的本质区别:一个继承了值,一个是白纸。
别忘了给脱离的任务单独设超时
WithoutCancel 切断的是"来自父的取消",不代表这个任务可以永远跑下去。收尾任务同样可能卡住,所以通常再给它套一个自己的超时:
go
detached := context.WithoutCancel(ctx)
// 给收尾任务一个独立的 10 秒预算,防止它自己卡死
bgCtx, bgCancel := context.WithTimeout(detached, 10*time.Second)
go func() {
defer bgCancel()
writeAuditLog(bgCtx, result)
}()
这样:父取消 → 不影响;但收尾任务超过 10 秒 → 自己被取消。两条超时线互相独立,互不干扰。
顺带:同批加入的 WithDeadlineCause 和 Cause
Go 1.21 还补齐了"取消原因"这条链。以前你只知道 context 被取消了(context.Canceled),但不知道为什么。现在可以带上原因:
go
ctx, cancel := context.WithTimeoutCause(
context.Background(), 100*time.Millisecond,
errors.New("下游数据库连接超时"), // 自定义原因
)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context deadline exceeded(标准错误)
fmt.Println(context.Cause(ctx)) // 下游数据库连接超时(你的真实原因)
ctx.Err() 返回的还是标准的 DeadlineExceeded,但 context.Cause(ctx) 能拿到你埋进去的具体原因。排查线上超时时,这一句话能省掉一堆猜测。手动取消也一样:cancel(errors.New("用户主动关闭")) 配合 context.WithCancelCause 使用。
什么时候用、什么时候别用
- 该用 WithoutCancel:审计日志、指标上报、缓存回写、清理临时资源------这些"请求结束也要做完"、且需要保留 trace 上下文的收尾工作。
- 不该用:主链路上的正常调用。如果一个操作本就该随请求取消而停止(比如查询数据库返回给用户),用它反而会让无用工作继续跑,浪费资源。
- 记得配超时 :脱离取消 ≠ 可以无限跑,几乎总要再叠一层
WithTimeout。
小结
context.WithoutCancel(Go 1.21)派生一个保留父 Value 但不继承取消信号的 context,专治"请求结束了收尾工作还得跑完"。- 它和
context.Background()的关键区别:后者丢掉所有值(trace ID 等),前者原样继承。 - 脱离取消的任务通常要再套
WithTimeout,给它一条独立的超时线,别让它卡死。 - 同批的
WithTimeoutCause/WithCancelCause+context.Cause能带上并取回具体取消原因,排查超时不再靠猜。 - 一句话记忆:WithoutCancel = 只断取消、不丢上下文;用它做收尾,别忘了给收尾也设个闹钟。