1. context 基础 API

context 包解决的问题很集中:让一次请求派生出的所有 goroutine 都能被统一取消,并共享请求级别的元数据。它的 API 数量不多,但语义细节多。这一篇先把五个创建函数过一遍。

Background 与 TODO:树的根

所有 context 都从根开始。context 包提供两个根,本质都是空的 emptyCtx——没有 deadline、不能取消、没有值:

// context.Background() 是整棵树的根,通常在 main、初始化、测试里用
bg := context.Background()

// context.TODO() 用在"还不知道该传哪个 context"的地方,是个占位符
todo := context.TODO()

两者行为完全一致,区别只在语义Background 表示”我确认这就是根”,TODO 表示”先放着,回头再补”。静态分析工具会据此给出不同的提示。

从根派生子 context:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

dl, _ := ctx.Deadline()
fmt.Printf("派生 ctx 类型: %T\n", ctx)
fmt.Printf("截止时间: %v\n", dl)

<-ctx.Done() // 阻塞,直到超时
fmt.Printf("超时后 Err: %v\n", ctx.Err()) // context.DeadlineExceeded

WithCancel:手动取消

WithCancel(parent) 返回一个子 context 和一个 cancel 函数。调用 cancel() 后,ctx.Done() 通道立即关闭。

ctx, cancel := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for i := 1; ; i++ {
        select {
        case <-ctx.Done():
            fmt.Printf("goroutine 退出,共执行 %d\n", i-1)
            return
        default:
            fmt.Printf("工作中... 第 %d\n", i)
            time.Sleep(200 * time.Millisecond)
        }
    }
}(ctx)

time.Sleep(1 * time.Second)
cancel() // 通知 goroutine 退出
time.Sleep(100 * time.Millisecond)

四个要点,都是常见踩坑点:

  1. 取消从父向子单向传播。父取消,所有子孙都被取消;子取消,父不受影响。
  2. cancel() 是幂等的,重复调用不会 panic。
  3. 必须调用 cancel(),即使没用到取消功能——它内部会释放资源。最稳的模式是 defer cancel()
  4. cancel() 可以提前调用,比 deadline 早触发。

WithTimeout:相对时间超时

WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))。到点后自动取消。

func callAPISlow(ctx context.Context) (string, error) {
    ch := make(chan string, 1)
    go func() {
        time.Sleep(500 * time.Millisecond) // 模拟慢请求
        ch <- "API 响应"
    }()

    select {
    case r := <-ch:
        return r, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func main() {
    // 300ms 超时 → 必然失败
    ctx1, cancel1 := context.WithTimeout(context.Background(), 300*time.Millisecond)
    defer cancel1()
    r, err := callAPISlow(ctx1)
    fmt.Printf("300ms: result=%q, err=%v\n", r, err)

    // 800ms 超时 → 成功
    ctx2, cancel2 := context.WithTimeout(context.Background(), 800*time.Millisecond)
    defer cancel2()
    r, err = callAPISlow(ctx2)
    fmt.Printf("800ms: result=%q, err=%v\n", r, err)
}

注意一个常见误区:超时之前主动调用 cancel()ctx.Err() 返回的是 Canceled,不是 DeadlineExceeded。错误类型反映的是”实际触发取消的原因”。

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

go func() {
    time.Sleep(200 * time.Millisecond)
    cancel() // 提前取消
}()

<-ctx.Done()
fmt.Println(ctx.Err()) // context.Canceled

WithDeadline:绝对截止时间

WithDeadline(parent, t) 接收一个绝对时间点,到点自动取消。和 WithTimeout 唯一区别是相对时间 vs 绝对时间

deadline := time.Now().Add(500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

<-ctx.Done()
fmt.Println(ctx.Err()) // context.DeadlineExceeded

两个边界情况:

传入过去的时间,context 立即取消:

past := time.Now().Add(-1 * time.Hour)
ctx, cancel := context.WithDeadline(context.Background(), past)
defer cancel()

fmt.Println(ctx.Err())     // context.DeadlineExceeded
// select <-ctx.Done() 立即可读

父 deadline 比子更早,子会被父先取消:

parent, pc := context.WithDeadline(context.Background(), time.Now().Add(1*time.Second))
defer pc()
child, cc := context.WithDeadline(parent, time.Now().Add(3*time.Second))
defer cc()

<-child.Done()
fmt.Println(child.Err()) // context.DeadlineExceeded,1 秒后触发,而不是 3 秒

实战上,绝大多数场景用 WithTimeoutWithDeadline 主要用在定时任务、“必须在某时间点完成”这类需求。

WithValue:请求级别的键值传递

WithValue(parent, key, value) 在 context 上挂一个键值对,子 context 通过 Value(key) 向上查找。

type contextKey string // 自定义 key 类型

const (
    keyRequestID contextKey = "request_id"
    keyUserID    contextKey = "user_id"
)

func handler(ctx context.Context) {
    ctx = context.WithValue(ctx, keyRequestID, "trace-abc-123")
    ctx = context.WithValue(ctx, keyUserID, 1001)
    service(ctx)
}

func service(ctx context.Context) {
    traceID := ctx.Value(keyRequestID).(string)
    userID  := ctx.Value(keyUserID).(int)
    fmt.Printf("service: trace=%s, user=%d\n", traceID, userID)
    repo(ctx)
}

func repo(ctx context.Context) {
    // 子能查到父链上的所有值
    fmt.Printf("repo: trace=%s\n", ctx.Value(keyRequestID).(string))
}

WithValue 是 context 包里最容易用错的 API,有四条铁律:

  1. key 必须用自定义类型。如果用 string,两个包各自写 WithValue(ctx, "user_id", ...) 会互相覆盖。自定义类型即使值字符串相同,类型不同也不会冲突。
  2. 只放请求级别的元数据:trace ID、认证信息、日志字段。
  3. 不要放业务参数userID 应该作为函数参数显式传递,放 ctx 里会让函数签名变成隐式依赖,编译器也无法帮你检查类型。
  4. 不要放大对象Value 查找是线性向上遍历,且数据会随 context 全链路传递。
// ❌ 错误:把业务参数塞进 ctx
func badFunc(ctx context.Context) {
    userID := ctx.Value("user_id").(int)
    // ...
}

// ✅ 正确:业务参数走函数参数,ctx 只带元数据
func goodFunc(ctx context.Context, userID int) {
    // ...
}

小结

五个创建函数对应五种能力:

函数能力典型场景
Background() / TODO()提供根main、初始化、占位
WithCancel(parent)手动取消控制 goroutine 退出
WithTimeout(parent, d)相对超时API/DB 调用
WithDeadline(parent, t)绝对截止定时任务
WithValue(parent, k, v)键值传递trace、认证、日志

记住两条核心规则:

  • 取消从父向子单向传播——这是 context 控制并发的基石。
  • 值从子向父向上查找——这是元数据传递的路径。