Go 基础体系 · 第 22/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go context 完整指南:取消、超时、Deadline 与 Value

本文所有代码与行为均以 Go 1.26.4 为基准。context.Context 是跨 API 边界传递请求生命周期的协议:它携带取消完成信号、截止时间、取消原因和少量请求元数据。它不会杀死 goroutine,也不会替函数关闭连接或回滚事务;收到信号的代码必须主动停止工作并释放自己拥有的资源。

最重要的心智模型是一棵派生树。Background 或入站请求 context 是根,WithCancelWithDeadlineWithTimeout 和带 cause 的变体生成子节点。父节点完成会使所有后代完成,子节点取消不会反向影响父节点或兄弟节点。每次派生都在定义“这项工作最晚活多久、谁有权结束它”。

1. Context 接口表达了什么

接口只有四个方法:Deadline 返回最晚完成时刻;Done 返回完成时关闭的只读 channel;Err 在完成后返回 CanceledDeadlineExceededValue 沿父链查找键。调用方只应依赖这些语义,不应推断具体实现类型。

func wait(ctx context.Context, ready <-chan string) (string, error) {
	select {
	case value := <-ready:
		return value, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

Done 被关闭是广播:任意数量等待者都会醒来,不能向它发送值。部分永不取消的 context,其 Done() 可以为 nil;在 select 中 nil case 永不就绪。只有在 Done 已关闭后,Err 才保证非 nil。不要轮询 Err 配合 Sleep,应让阻塞点直接选择 Done

2. 派生树与取消传播

WithCancel(parent) 返回子 context 和 CancelFunc。第一次调用 cancel 关闭子节点的 Done,后续调用无效果。父节点先取消时,子节点继承完成状态;取消兄弟 A 不会取消兄弟 B。

request ctx
├── database timeout (80 ms)
├── cache timeout (20 ms)
└── response work
    ├── formatter
    └── audit enqueue

取消传播是并发安全的,但业务收尾仍需设计。若 database driver 忽略 context,它可能继续执行;若循环从不观察 Done,也不会停。Context 只负责发信号,等待任务退出通常还需 WaitGroup、结果 channel 或结构化任务组。任务所有权和服务关停属于 goroutine 生命周期主题,本文只说明信号契约。

3. 为什么 CancelFunc 必须调用

每个可取消派生函数都返回 cancel。即使操作成功或父节点稍后也会取消,也应在创建成功后立刻 defer cancel()。这样能及时解除父子引用、停止内部计时器并释放关联资源;遗漏会让资源存活到父节点结束或计时器到期。

func lookup(ctx context.Context, store Store, id string) (Article, error) {
	ctx, cancel := context.WithTimeout(ctx, 150*time.Millisecond)
	defer cancel()

	return store.Get(ctx, id)
}

不要把 cancel 放到长循环的函数级 defer 中反复累积。把单次迭代抽成函数,或在迭代结束显式调用。go vetlostcancel 检查能发现部分丢失路径,但不能替代所有权审查。

4. Deadline 是绝对时刻,Timeout 是便捷写法

WithTimeout(parent, d) 等价于以 time.Now().Add(d) 派生 deadline。子节点的有效 deadline 不会晚于父节点:父只剩 40 ms,即使下游请求 2 秒,仍会在父节点到期时完成。零或负 duration 会得到已完成的 context。

func requireBudget(ctx context.Context, reserve time.Duration) error {
	deadline, ok := ctx.Deadline()
	if !ok {
		return nil
	}
	if time.Until(deadline) < reserve {
		return fmt.Errorf("insufficient budget: %w", context.DeadlineExceeded)
	}
	return nil
}

工程上应传递“剩余预算”,而不是每层重置完整超时。入口可有 800 ms 总预算,数据库最多使用剩余预算中的 300 ms;重试还要为退避和响应编码留余量。Deadline 不是精确定时器:调度延迟、网络栈与下游实现会影响实际返回时刻,因此不能把预算压到正常耗时的极限。

5. 多层超时如何共同工作

HTTP 客户端总超时、请求 context、Transport 的连接/TLS/响应头超时解决不同问题。最先到期者终止对应等待;重复设置相同数字不增加可靠性,反而让错误归因困难。数据库调用应使用 QueryContextExecContextBeginTx,而不是在外层超时后留下无法追踪的普通调用。

request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
	return err
}
response, err := client.Do(request)
if err != nil {
	return fmt.Errorf("get catalog: %w", err)
}
defer response.Body.Close()

Context 取消不能保证远端业务没有执行。例如请求已写出、服务端已提交订单,但响应返回前客户端 deadline 到期。对有副作用操作必须使用幂等键、事务状态查询或协议确认,不能把 DeadlineExceeded 解释成“远端一定未执行”。

6. Canceled、DeadlineExceeded 与取消原因

调用 cancel 通常得到 context.Canceled;有效 deadline 到期得到 context.DeadlineExceeded。包装后的错误使用 errors.Is 判断,不要比较字符串。正常的客户端断开或上游放弃常属于取消,不应全部记录为服务端故障。

Go 1.20 起可用 WithCancelCause,Go 1.21 起还有 WithTimeoutCauseWithDeadlineCausectx.Err() 仍保持稳定分类,context.Cause(ctx) 返回更具体原因。普通 cancel() 不能传原因;带 cause 的 timeout 函数,其返回的 CancelFunc 只负责提前取消,不能改写预设超时原因。

var ErrSuperseded = errors.New("request superseded")

ctx, cancel := context.WithCancelCause(parent)
cancel(ErrSuperseded)

fmt.Println(errors.Is(ctx.Err(), context.Canceled)) // true
fmt.Println(errors.Is(context.Cause(ctx), ErrSuperseded)) // true

Cause 适合进程内诊断和错误传播,但不要直接把内部原因返回给外部用户;仍需映射成稳定的 API 状态与安全消息。

7. Value 只携带请求范围元数据

WithValue 适合 trace ID、认证主体、租户 ID 等穿过 API 边界的请求元数据。它不适合数据库句柄、logger、业务配置、可选参数或可变业务状态,因为这些是真实依赖,应通过字段或参数显式表达。

键类型必须可比较。避免使用 string 等内置类型作为公共键,否则不同包可能碰撞。定义未导出、零尺寸自定义类型,并用访问函数封装类型断言:

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}

Value 查找沿派生链向上,深链和大对象都会增加成本或延长引用生命周期。值应小、不可变,并且是请求不可缺少的元数据。不能用 context 值绕开鉴权:取出身份后仍要验证权限和租户边界。

8. WithoutCancel 与后台延续工作的边界

context.WithoutCancel(parent) 保留父节点的 Value,但不继承取消、deadline 或 cause;返回 context 的 Done 为 nil。它适用于请求结束后确实需要延续、且另有明确生命周期的少数任务,例如把小型审计事件提交给受控队列。

它不是“防止请求取消”的通用开关。直接把它交给无限后台任务会失去停止协议,也可能让请求值引用大对象。应再派生独立的短 deadline,并由进程级管理器等待:

detached := context.WithoutCancel(requestCtx)
auditCtx, cancel := context.WithTimeout(detached, 500*time.Millisecond)
defer cancel()
if err := audit.Write(auditCtx, event); err != nil {
	return fmt.Errorf("write audit: %w", err)
}

涉及资金、订单等可靠副作用,不应靠脱离请求的 goroutine 完成;先持久化到事务内 outbox 或可靠队列,再异步消费。

9. AfterFunc 的竞态与幂等收尾

context.AfterFunc(ctx, f) 安排在 ctx 完成后用独立 goroutine 调用 f,并返回 stop 函数。stop() 返回 true 表示成功阻止调用;返回 false 表示函数已经启动、已经完成,或本来就无法停止。stop 不会等待正在运行的 f。

因此资源收尾必须幂等,且 stop 与 f 可能并发:

var once sync.Once
cleanup := func() { once.Do(resource.Close) }

stop := context.AfterFunc(ctx, cleanup)
defer func() {
	if !stop() {
		cleanup()
	}
}()

不要在回调里做无界阻塞工作。若必须确认回调完成,自己提供 channel 或 WaitGroupAfterFunc 可用于唤醒不直接支持 context 的等待,但首先应优先使用原生接收 context 的 API。

10. API 设计约定与反模式

接收 context 的函数通常把它作为第一个参数并命名 ctx;不要把它设为可选参数,不传 nil,无父节点时使用 context.Background()TODO() 表示调用链尚未理清,不应成为生产代码长期默认值。

不要把 context 存进结构体:结构体可能跨多个请求复用,使 deadline 和 Value 串线。例外是某些标准库兼容 API 的特殊设计,但业务代码应让每次操作显式传入。也不要在函数内部用 Background 切断调用方取消,除非该操作的所有权确实转移,并有独立的停止与等待机制。

循环检查 ctx.Err() 适合 CPU 分块计算;每次迭代都检查可能过于频繁,可按合理批次检查。但任何可能永久阻塞的 channel、锁外等待或 I/O 都必须有可取消路径。Mutex 本身不能带 context,需缩短临界区而不是等待时另开 goroutine 泄漏。

11. 诊断取消链与错误模式

排查“超时但任务仍运行”时按链路逐层确认:入口是否有 deadline;派生是否意外用了 Background;下游 API 是否接收 ctx;循环和 channel 发送是否选择 Done;错误是否保留 %w;是否把远端执行状态误判为失败。

日志至少记录请求 ID、操作名、预算或 deadline、耗时、稳定错误分类和 cause。不要把 context 整体格式化到日志,它可能携带敏感值且输出不稳定。指标应区分调用方取消、deadline、下游超时和本地过载;大量 deadline 可能源于上游预算太短,也可能是本服务排队过久。

测试不要依赖很长 Sleep。用已经取消的 context 验证立即退出,用短而有余量的 deadline 验证超时,用 channel 精确确认工作已开始。测试结束前等待 goroutine 退出,并结合 go test -race 检查清理路径的共享状态。

go test -race -count=20 ./...
go vet ./...
GODEBUG=schedtrace=1000 go test -run TestCancellation ./...

12. 可运行综合示例:预算、原因与受控并发

下面程序模拟在总预算内并发查询两个后端。任一后端失败会用具体 cause 取消兄弟任务;每个发送和等待点都响应 context,协调者等待全部任务后关闭结果 channel。它展示的是 context 协议,生产 worker pool 的容量与队列策略还要按下游能力设计。

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type result struct {
	name  string
	value string
	err   error
}

func query(ctx context.Context, name string, delay time.Duration) (string, error) {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-timer.C:
		return "reply-from-" + name, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func gather(parent context.Context) ([]string, error) {
	ctx, timeout := context.WithTimeoutCause(parent, 250*time.Millisecond, errors.New("query budget exhausted"))
	defer timeout()
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	specs := []struct {
		name  string
		delay time.Duration
	}{{"cache", 30 * time.Millisecond}, {"database", 60 * time.Millisecond}}

	results := make(chan result, len(specs))
	var wg sync.WaitGroup
	for _, spec := range specs {
		wg.Add(1)
		go func() {
			defer wg.Done()
			value, err := query(ctx, spec.name, spec.delay)
			if err != nil {
				cancel(fmt.Errorf("%s: %w", spec.name, err))
			}
			// 每个任务只发送一次,容量等于任务数,因此取消后也不会阻塞。
			results <- result{spec.name, value, err}
		}()
	}
	go func() {
		wg.Wait()
		close(results)
	}()

	values := make([]string, 0, len(specs))
	var firstErr error
	for item := range results {
		if item.err != nil && firstErr == nil {
			firstErr = context.Cause(ctx)
			continue
		}
		values = append(values, item.value)
	}
	if firstErr != nil {
		return nil, firstErr
	}
	return values, nil
}

func main() {
	values, err := gather(context.Background())
	if err != nil {
		panic(err)
	}
	fmt.Println(values)
}

Context 使用得是否正确,可以用四个问题复核:谁创建预算,谁调用 cancel,所有阻塞点是否观察完成信号,创建者如何确认任务已经退出。回答不完整时,添加更多 WithTimeout 只会遮住生命周期缺口。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。