12.2 超时、截止时间与 WithValue
12.1 节的取消是手动的:调用方说停才停。但真实服务里更常见的是自动取消——「这次数据库查询最多给 200 毫秒,超了就别等了」。没有超时预算,一个慢查询就能拖垮整个请求处理链。
除了超时,还有一个需求:在取消之外传递请求级数据。比如每个请求生成一个 request ID,从入口一路带到日志和数据库层,出问题时才能把散落的日志串成一条链。这个需求由 ctx.Value 承载。
这两件事都容易用错,本节把规则讲清楚。
本节把 TaskAPI 推进到:批量关闭任务带 200 毫秒超时预算,并用
WithValue把 request ID 贯穿到每个任务的日志里。
12.2.1 为什么必须有超时
先看一个没有超时会发生什么。假设 CloseDueTasks 的循环体里调了一次慢查询:
for _, t := range tasks {
if err := slowQuery(ctx); err != nil { // 这里可能卡 30 秒
return closed, err
}
closed++
}
如果 slowQuery 卡住 30 秒,这个请求就占着连接和 goroutine 30 秒。上游调用方早就超时返回了,服务器还在做无用功——更糟的是,如果每个请求都这样,连接池很快被占满,整个服务雪崩。
超时的作用就是给一次操作设定时间预算,超了就主动放弃,把资源让出来。这不是「优化」,而是服务稳定性的基本要求。
12.2.2 WithTimeout 与 WithDeadline
两个函数,一个用「时长」,一个用「绝对时间点」:
// 从「现在」开始算,200 毫秒后取消
ctx, cancel := context.WithTimeout(parent, 200*time.Millisecond)
defer cancel()
// 取消发生在某个绝对时间点
ctx, cancel := context.WithDeadline(parent, time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC))
defer cancel()
WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))。选哪个取决于你的信息形态:
| 场景 | 用哪个 |
|---|---|
| 「这个查询最多给 200ms」 | WithTimeout |
| 「必须在 12:00 前完成」(对账、定时任务) | WithDeadline |
| 需要把剩余预算传给下游 | 两者都可以,用 ctx.Deadline() 读 |
超时触发后,ctx.Err() 返回 context.DeadlineExceeded,与手动取消的 context.Canceled 区分开。用 errors.Is 判断:
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := slowQuery(ctx); err != nil {
fmt.Println("超时路径:", err)
fmt.Println("errors.Is DeadlineExceeded:", errors.Is(err, context.DeadlineExceeded))
}
实测输出:
超时路径: context deadline exceeded
errors.Is DeadlineExceeded: true
这里 slowQuery 内部就是 12.1 节那个模式——用 select 同时等结果和 ctx.Done():
func slowQuery(ctx context.Context) (string, error) {
select {
case <-time.After(200 * time.Millisecond):
return "查询结果", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
注意最后一行返回的是 ctx.Err(),而不是自己编一个「查询超时」错误。这样调用方才能用 errors.Is(err, context.DeadlineExceeded) 做统一判断,而不用去猜每个函数自定义的错误文案。
12.2.3 Deadline 的继承规则
子 ctx 的截止时间由父和子中更早的那个决定。实测三种组合:
父 (1s): 剩余 1s
子 (100ms, 父 1s): 剩余 100ms
子 (1s, 父 100ms): 剩余 100ms
规则一句话:子 ctx 只能缩短预算,不能延长。
这条规则是超时链正确性的基础。假设入口给了 500 毫秒,中间某层又设了 2 秒——如果子能延长,那整个链路的时间预算就形同虚设了。有了这条规则,你可以在每一层都放心地设一个「这一层最多允许多久」,而不必担心破坏上游的约束。
由此得到两个实践要点:
- 中间层不要重复设超时,除非确实需要更紧的约束。如果上游已经给了 500ms,你在中间再设一个 500ms,实际拿到的是「从你设置那一刻起的 500ms」,可能比上游的剩余预算还长——那就毫无意义(会被父的截止时间截断),也可能更短(提前失败)。正确做法是把同一个 ctx 传下去。
- 读剩余预算用
ctx.Deadline():
if d, ok := ctx.Deadline(); ok {
remaining := time.Until(d)
if remaining < 50*time.Millisecond {
return errors.New("剩余预算不足,放弃本次尝试")
}
}
这个技巧在「重试」逻辑里特别有用:与其重试到超时被硬砍,不如先判断「还剩多少时间,够不够再试一次」。
12.2.4 给取消一个业务化的原因
context.Canceled 和 context.DeadlineExceeded 只说明「被取消 / 超时」,不说明为什么。当你有多个取消来源时(用户主动取消、上游超时、服务正在关闭),光靠这两个值分不清。
WithCancelCause(Go 1.20+)与 WithTimeoutCause(Go 1.21+)允许携带一个自定义原因:
var ErrSlowStore = errors.New("store 响应过慢")
ctx, cancel := context.WithTimeoutCause(
context.Background(), 50*time.Millisecond, ErrSlowStore)
defer cancel()
select {
case <-time.After(200 * time.Millisecond):
fmt.Println("正常完成")
case <-ctx.Done():
fmt.Println("Err() :", ctx.Err())
fmt.Println("Cause():", context.Cause(ctx))
}
实测输出:
Err() : context deadline exceeded
Cause(): store 响应过慢
errors.Is(Cause, ErrSlowStore): true
区别很清楚:
ctx.Err()返回标准错误(DeadlineExceeded),用于通用判断;context.Cause(ctx)返回你设的业务原因,用于诊断和日志。
两者是互补的,不是替代关系。上游的通用逻辑仍然用 errors.Is(err, context.DeadlineExceeded),而日志里记 context.Cause(ctx) 才能知道「到底是哪一层、因为什么超时」。
对应的取消函数是 WithCancelCause,它返回的 CancelCauseFunc 接收一个 error:
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil) // 正常结束,不设原因
// 或者
cancel(errors.New("store 已关闭"))
注意 cancel(nil) 的写法:取消原因不设时传 nil,此时 context.Cause(ctx) 会返回 context.Canceled。
12.2.5 WithValue:键必须是自定义类型
WithValue 的签名是 WithValue(parent, key, val any) Context。三个参数都是 any,这带来了很大的自由度,也带来了很大的坑。
正确做法:用一个不导出的自定义类型当键。
type ctxKey int
const (
requestIDKey ctxKey = iota
userIDKey
)
ctx := context.WithValue(context.Background(), requestIDKey, "req-42")
ctx = context.WithValue(ctx, userIDKey, int64(7))
fmt.Println(ctx.Value(requestIDKey)) // req-42
fmt.Println(ctx.Value(userIDKey)) // 7
为什么不用 string?因为 string 键会跨包冲突:WithValue(ctx, "request_id", "req-1") 之后再 WithValue(..., "request_id", "req-2"),前一个会被静默覆盖(实测打印出 req-2)。而自定义类型 ctxKey 只在定义它的包里可见,别的包根本构造不出同类型的键,从根上避免了冲突。
如果确实需要在包之间共享键,就导出一个带方法的类型,或者导出一个返回键的函数,而不是导出变量本身:
// 在 auth 包里
type ctxKey struct{}
func WithUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
func UserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(ctxKey{}).(int64)
return id, ok
}
这样使用方只看到 WithUserID / UserID 两个函数,键和类型断言都被封装在包内部——这才是 Value 的正确封装方式。
12.2.6 Value 的三条纪律
| 纪律 | 说明 |
|---|---|
| 只放请求级元数据 | request ID、trace ID、认证信息、语言偏好 |
| 不放业务参数 | 「要处理的 task ID」应该是函数参数,不是 ctx 值 |
| 不放可选配置 | 配置走显式参数或依赖注入(第 15 章) |
判断标准很简单:如果这个值对「所有下游函数」都可能是必需的,它是元数据;如果只有某一层用得上,它就是参数。另外 Value 的查找是沿 ctx 链逐层向上的,每层一次类型断言,有 O(depth) 的成本——把它当参数传会同时损失性能和类型安全。
12.2.7 两个实用工具
context.AfterFunc(Go 1.21+):注册一个「ctx 结束时执行」的回调。
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
fmt.Println("[AfterFunc] 关闭 store 连接池")
})
_ = stop
cancel()
实测输出:
[AfterFunc] 关闭 store 连接池
它的价值在于把「资源清理」和「取消事件」绑定,而不用起一个专门等待的 goroutine。返回的 stop 函数可以取消这个注册(如果清理逻辑不该执行了)。
context.WithoutCancel(Go 1.21+):脱掉取消,但保留 Value。
// cancelled 是一个已经被 cancel() 过的 ctx
detached := context.WithoutCancel(cancelled)
fmt.Println("Value:", detached.Value(key("req"))) // req-7,Value 保留
fmt.Println("Err :", detached.Err()) // <nil>,取消被脱掉
fmt.Println("Done :", detached.Done()) // <nil>,永远不会关闭
实测输出三行分别是 req-7、<nil>、<nil>——Value 保留、取消被脱掉、Done() 永远不关闭。
典型用途是**「请求已经结束了,但这个操作必须做完」**——比如把审计日志写到磁盘、把 metrics 上报到服务端。这些操作不应该因为用户断开连接而被取消,但可能仍然需要 request ID 来做关联。这时 WithoutCancel 正好。
要克制使用它:多数时候「请求取消就该停止一切」,只有明确知道「这件事必须完成」时才用。
12.2.8 组装进 TaskAPI
把超时和 request ID 一起用到批量关闭上:
func CloseDueTasks(ctx context.Context, tasks []Task) (int, error) {
requestID, _ := ctx.Value(requestIDKey).(string)
closed := 0
for _, t := range tasks {
select {
case <-ctx.Done():
logf("[%s] 取消,已完成 %d 个", requestID, closed)
return closed, ctx.Err()
default:
}
if d, ok := ctx.Deadline(); ok && time.Until(d) < 20*time.Millisecond {
return closed, fmt.Errorf("剩余预算不足: %w", context.DeadlineExceeded)
}
time.Sleep(20 * time.Millisecond)
closed++
logf("[%s] 已关闭 #%d %s", requestID, t.ID, t.Title)
}
return closed, nil
}
调用方设置预算并注入 request ID:
ctx := context.WithValue(context.Background(), requestIDKey, "req-42")
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
n, err := CloseDueTasks(ctx, tasks)
20 个任务、每个 20 毫秒、总预算 200 毫秒,实测输出(logf 就是包一层 fmt.Printf):
[req-42] 已关闭 #1 到期任务1
...
[req-42] 已关闭 #9 到期任务9
完成 9 个, err = 剩余预算不足: context deadline exceeded, isDeadline = true
注意 WithValue 和 WithTimeout 的嵌套顺序:先放 Value 再设超时,子 ctx 会同时带上 Value 和 deadline。反过来(先超时后 Value)也一样能拿到两者,但推荐「Value 在外、超时在内」,因为超时是更贴近具体操作的一层约束。
三个设计点:
- request ID 通过 ctx 传,而不是每个函数都加一个
requestID string参数。它是「所有下游都可能要记日志」的元数据,正是Value的适用场景。 - 剩余预算检查用
%w包装context.DeadlineExceeded,这样调用方既能看到「剩余预算不足」这个具体原因,又能用errors.Is(err, context.DeadlineExceeded)做统一处理——第 6 章学的%w在这里和 context 完美配合。 - 取消时返回已完成数量,语义与 12.1 节保持一致。
12.2.9 小结与练习
WithTimeout是WithDeadline的时长版;超时返回DeadlineExceeded,手动取消返回Canceled。- 子 ctx 的截止时间取父与子中更早的那个,只能缩短不能延长。
WithTimeoutCause/WithCancelCause+context.Cause提供业务化的取消原因,与ctx.Err()互补。WithValue的键必须用不导出的自定义类型,并对使用方封装成函数。Value只放请求级元数据,不放业务参数。AfterFunc绑定清理回调,WithoutCancel让「必须完成的操作」脱离取消——两者都要克制使用。
练习:
- 给 12.2.8 加一个「剩余预算不足时重试一次」的逻辑,用
ctx.Deadline()判断是否还有空间。 - 用
WithTimeoutCause把「store 响应过慢」作为原因,在日志里同时打印ctx.Err()和context.Cause(ctx),体会两者的分工。 - 写一个包,导出
WithUserID/UserID两个函数并内部封装键类型,然后从另一个包调用它,验证你无法构造出同类型的键。
下一节我们把 context 和信号处理结合起来:Ctrl-C 之后如何停止接收新任务、等待在途任务排空,然后干净地退出。
阅读导航:上一节:12.1 context 的取消与传播 · 下一节:12.3 优雅退出与信号处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。