Files

8.0 KiB
Raw Permalink Blame History

tags, create time, update time
tags create time update time
go/lang
context
cancellation
deadline-propagation
goroutine-lifecycle
2026-08-08 19:00 2026-08-08 19:00

Context 包详解

概述

Context 是 Go 1.7 引入的标准库,用于在 goroutine 树中传递上下文信息、取消信号和截止时间。它不是数据传递的载体(那是结构体的职责),而是控制并发生命周期的信号总线。理解 ctx 的链式传播机制和 WithValue 的性能陷阱,对编写健壮的并发程序至关重要。

[!NOTE] 一句话定义 Context 是一个接口,核心方法只有 Done() 和 Err()——它不提供数据传输能力,只提供"告诉下游我该停了"的信号通道。

核心原理

Context 接口定义

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

四个方法各有分工:

  • Deadline:返回取消时间。未设置 deadline 的 ctx 返回 ok=false
  • Done:返回一个只读 channel。ctx 被取消时该 channel 会关闭
  • Err:返回取消原因(Canceled / DeadlineExceeded)
  • Value:键值对查询(不推荐用于业务传参)

[!WARNING] 常见认知错误 Context 不是用来在 goroutine 间传递业务参数的替代品。如果多个 goroutine 需要共享配置或请求数据,应该用结构体或参数列表。WithValue 性能很差且容易引发问题(见后文)。

四种 WithXXX 函数源码级分析

WithCancel — 手动取消

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

实现本质非常简单——创建一个 cancelCtx 节点,挂载到父 ctx 上。当 cancel() 被调用时:

  1. 设置 err = Canceled
  2. 关闭 done channel(所有监听者收到关闭信号)
  3. 递归通知子 ctx 也取消
flowchart LR
    A["parent ctx"] -->|"ctx1, _ := WithCancel"| B["ctx1\n(cancelCtx)"]
    B -->|"ctx2, _ := WithCancel ctx1"| C["ctx2\n(sub-cancelCtx)"]
    C -->|"ctx3, _ := WithCancel ctx2"| D["ctx3\n(sub-sub-cancelCtx)"]
    
    style B fill:#e3f2fd
    style C fill:#e8f5e9
    style D fill:#fce4ec

调用 cancel() 时信号沿虚线向上传播到所有后代。

WithTimeout — 超时取消

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout 内部组合了 WithCancel 和一个 timer:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

底层启动一个 goroutine 等待 timer 触发后自动调用 cancel。这意味着 WithTimeout 本身比 WithCancel 多了一个 goroutine 开销。

WithDeadline — 绝对截止时刻

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

与 WithTimeout 的区别仅在于指定的是绝对时间而非相对时长。两者底层使用同一个 timerCtx 结构体,包含 *cancelCtx 和 time.Timer。

[!TIP] 面试考点 WithTimeout 本质上就是 WithDeadline(now + timeout)。面试官如果追问"两者的区别是什么",这就是标准答案。

WithValue — 附带值传递

func WithValue(parent Context, key, val any) Context

WithValue 不创建新的类型,只是在 context 树上追加一层带有 key-value 对的节点。每次调用都会产生一个新的 context 对象。

链式传播机制

Context 通过嵌套构成一棵树。每个 cancelCtx 维护一个 children 列表来追踪直接子节点:

type cancelCtx struct {
    Context
    mu       sync.Mutex
    done     chan struct{}
    children map[canceler]*cancelCtx // 子节点集合
    err      error                   // 取消时的错误原因
    childCleared bool               // 是否已清理子节点
}

当父 ctx 被取消时,遍历 children 逐个取消子节点。这个传播过程是递归的:

parent.WithCancel → ctx1
ctx1.WithCancel   → ctx2
ctx2.WithCancel   → ctx3

cancel() at parent
    │
    ├── 递归取消 ctx1 ──→ 递归取消 ctx2 ──→ 递归取消 ctx3

[!NOTE] 为什么不用双向指针? 子 ctx 持有父 ctx 引用(嵌入),但父 ctx 通过 children map 持有子引用。这是一种非对称设计——父到子是显式追踪,子到父通过 Context 嵌入间接访问。删除子节点时从父的 children map 移除以避免内存泄漏。

WithValue 的性能坑点

WithValue 有几个需要注意的问题:

  1. 每次调用都分配新对象:WithValue 不会修改已有节点,而是创建整个链路的副本路径。链式调用 N 次会产生 N 个 context 对象。

  2. 查找复杂度 O(depth):Value() 沿链路逐层查找匹配的 key。深层嵌套时性能显著下降。

  3. GC 压力:短期存在的 ctx 虽然存活时间短,但在高 QPS 场景下(如 HTTP handler),大量短期对象会给 GC 带来负担。

  4. key 必须是可比较的:如果使用 slice/map/function 作为 key,会导致 panic。最佳实践是用自定义不可导出的类型:

type userIDKey struct{}
ctx := context.WithValue(r.Context(), userIDKey{}, user.ID)
// 取值
if id, ok := ctx.Value(userIDKey{}).(string); ok {
    // ...
}

代码示例

优雅取消 goroutine 树

func fetchData(ctx context.Context) ([]byte, error) {
    resp, err := http.Get("https://api.example.com/data")
    if err != nil {
        return nil, fmt.Errorf("HTTP failed: %w", err)
    }
    defer resp.Body.Close()

    data, err := io.ReadAll(resp.Body)
    select {
    case <-ctx.Done():
        return nil, ctx.Err() // 请求被取消
    default:
        return data, nil
    }
}

在关键操作前后检查 ctx.Done(),确保即使网络操作完成也能及时响应取消信号。

Timeout 与 Deadline 的实际应用

func handleRequest(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 必须调用,释放资源

    result, err := heavyComputation(ctx)
    if err != nil {
        return fmt.Errorf("computation: %w", err)
    }
    return process(result)
}

defer cancel() 是关键——没有它,context 及其关联的 timer goroutine 永远不会被释放,导致 goroutine leak。

WithValue 的正确用法

func logMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        traceID := generateTraceID()
        ctx := context.WithValue(r.Context(), traceIDKey{}, traceID)
        next(w, r.WithContext(ctx))
    }
}

在 Web 场景中,ctx 最常见的用途是携带 trace ID、用户认证信息等跨中间件需要的元数据。

实践场景

面试高频问题

Q: Context 什么时候应该被传递给函数? 原则是:只要你的函数可能在长时间运行后被中断,就应该接受 Context 作为第一个参数。这是 Go 社区的标准规范。

Q: 可以用 Context 存储数据库连接吗? 不应该。数据库连接的生命周期独立于单次请求的上下文,存储在 struct 字段或通过依赖注入管理更合适。

Q: Context 的 value 能被并发安全地读取吗? 可以。一旦 context 创建完成,它的 key-value 对就不可变了,天然线程安全。但 value 本身的类型需要保证并发安全。

实战建议

  • always pass context as first parameter: (ctx context.Context, ...) -> (...)
  • always defer cancel when you create a derived context: defer cancel() 是不可省略的习惯
  • never store context in a struct: context 描述的是单次操作的上下文,不适合持久化
  • use context for cancellation, not data transfer: 优先用 struct 字段传递业务数据

扩展阅读