《Go 语言编程入门》12.2 超时、截止时间与 WithValue

上一节只会「手动取消」,但真实服务更需要「自动超时」和「携带请求级数据」。本节讲透 WithTimeout 与 WithDeadline 的继承规则、WithTimeoutCause 如何给出业务化的取消原因、WithValue 的键类型为什么必须自定义,以及 AfterFunc 与 WithoutCancel 两个实用工具。

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 秒——如果子能延长,那整个链路的时间预算就形同虚设了。有了这条规则,你可以在每一层都放心地设一个「这一层最多允许多久」,而不必担心破坏上游的约束。

由此得到两个实践要点:

  1. 中间层不要重复设超时,除非确实需要更紧的约束。如果上游已经给了 500ms,你在中间再设一个 500ms,实际拿到的是「从你设置那一刻起的 500ms」,可能比上游的剩余预算还长——那就毫无意义(会被父的截止时间截断),也可能更短(提前失败)。正确做法是把同一个 ctx 传下去。
  2. 读剩余预算用 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 小结与练习

  1. WithTimeout 是 WithDeadline 的时长版;超时返回 DeadlineExceeded,手动取消返回 Canceled。
  2. 子 ctx 的截止时间取父与子中更早的那个,只能缩短不能延长。
  3. WithTimeoutCause / WithCancelCause + context.Cause 提供业务化的取消原因,与 ctx.Err() 互补。
  4. WithValue 的键必须用不导出的自定义类型,并对使用方封装成函数。
  5. Value 只放请求级元数据,不放业务参数。
  6. AfterFunc 绑定清理回调,WithoutCancel 让「必须完成的操作」脱离取消——两者都要克制使用。

练习:

  • 给 12.2.8 加一个「剩余预算不足时重试一次」的逻辑,用 ctx.Deadline() 判断是否还有空间。
  • 用 WithTimeoutCause 把「store 响应过慢」作为原因,在日志里同时打印 ctx.Err() 和 context.Cause(ctx),体会两者的分工。
  • 写一个包,导出 WithUserID / UserID 两个函数并内部封装键类型,然后从另一个包调用它,验证你无法构造出同类型的键。

下一节我们把 context 和信号处理结合起来:Ctrl-C 之后如何停止接收新任务、等待在途任务排空,然后干净地退出。

阅读导航:上一节:12.1 context 的取消与传播 · 下一节:12.3 优雅退出与信号处理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练