Files
2026-05-24 11:42:38 +08:00

12 KiB
Raw Permalink Blame History

tags, create time
tags create time
go
context
goroutine
cancellation
2026-05-06 15:30

Go Context

概述

梳理 Go 标准库 context 包的核心机制与最佳实践:涵盖上下文树的创建与传播、取消信号传递、超时控制、请求级值存储,以及常见陷阱的规避方案。

正文

一、为什么需要 Context

[!question] 思考:如果没有 context,如何优雅地停止一组 goroutine?

Go 的 goroutine 像线程一样运行在后台。当某个请求的处理逻辑已经完成,但它在内部启动的多个 goroutine 仍在跑——这些 goroutine 就成了"孤儿",白白消耗 CPU 和内存。

func fetchData() {
    dataCh := make(chan string)
    go func() {
        // 模拟一个永远超时的网络请求
        resp, _ := http.Get("https://example.com")
        dataCh <- resp.Status
    }()

    time.Sleep(1 * time.Second)
    return // 函数返回了,但上面的 goroutine 永远不会结束
}

Context 提供了三个核心能力来解决这个问题:

能力 说明
取消信号 沿调用链向下广播取消通知,让所有子 goroutine 及时退出
截止时间 自动触发取消(超时 / 指定时刻)
请求级值传递 跨 goroutine 安全地携带请求上下文信息(如 TraceID、用户认证信息)

二、根 Context:Background vs TODO

Context 世界从"根节点"开始:

// 大多数情况下使用这个
ctx := context.Background()

// 当你不确定该用哪个,或者代码还在雏形阶段
ctx := context.TODO()
  • Background():正式的根 context,通常用在 main 入口或测试中,作为整棵 context 树的起点。
  • TODO():占位符。当函数暂时无法获取一个有意义的 context 时用它。它只是一个临时方案——如果你发现自己在产品代码中长期使用 TODO(),应该重构为接收上游传入的 ctx。

[!tip] 关键认知

Context 不是全局变量!它是沿着调用链自上而下单向传递的。每个函数只知道自己上方的 context,不直接感知下游。

三、衍生 Context:WithCancel / WithTimeout / WithDeadline

通过三个 WithXxx 函数,可以从父 context 派生出子 context,形成一棵树:

// 手动取消
ctx, cancel := context.WithCancel(parentCtx)
cancel() // 立即向所有后代发送取消信号

// 自动超时取消
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel() // 即使提前返回也要显式取消

// 指定绝对时间取消
ctx, cancel := context.WithDeadline(parentCtx, time.Now().Add(10*time.Minute))
defer cancel()

三者本质上是一个东西:WithTimeout = WithDeadline(now + duration),WithCancel = 无截止时间的纯手动取消。

3.1 Context 树的结构

flowchart TD
    BG["Background()"] --> R1["Request Handler\nWithCancel"]
    R1 --> DB["Database Query\nWithTimeout 3s"]
    R1 --> API["External API Call\nWithTimeout 5s"]
    API --> Cache["Cache Lookup\nWithValue"]

    style BG fill:#eee,stroke:#333
    style R1 fill:#bbf,stroke:#333
    style DB fill:#bfb,stroke:#333
    style API fill:#bbf,stroke:#333
    style Cache fill:#fee,stroke:#333

当 R1 被取消时,DB、API 以及 API 的子 context Cache 都会自动收到取消信号。这是一种递归传播机制——父被取消,所有子孙都会被取消。

[!warning] 资源泄漏警告

WithTimeout 和 WithDeadline 返回的 cancel 函数即使设置了超时也会注册 timer。如果子 context 提前完成但没有调用 cancel(),timer 会一直持有引用直到超时。虽然影响不大,养成 defer cancel() 的习惯是好事情。

3.2 实际效果演示

func worker(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("[%s] canceled: %v\n", name, ctx.Err())
            return
        default:
            fmt.Printf("[%s] working...\n", name)
            time.Sleep(200 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
    defer cancel()

    go worker(ctx, "A")
    go worker(ctx, "B")

    time.Sleep(1 * time.Second)
}
// 输出:每个 worker 打印约 3 次后都收到 canceled: context deadline exceeded

select 中对 <-ctx.Done() 的检查是让 goroutine 感知取消的标准写法。

3.3 ctx.Err() 的含义

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
time.Sleep(200 * time.Millisecond)

switch ctx.Err() {
case context.DeadlineExceeded:
    fmt.Println("超时了")
case context.Canceled:
    fmt.Println("被主动取消了")
}
ctx.Err() 返回值 含义
nil context 仍然有效
context.Canceled 被 cancel() 主动取消
context.DeadlineExceeded 到达截止时间自动取消

[!note] 错误语义

在 HTTP handler 或服务框架中,ctx.Err() == context.Canceled 通常表示客户端断开了连接。你可以借此跳过不必要的后端处理来节省资源。

四、WithValue:携带请求级数据

Context 不仅可以传递取消信号,还能携带只在当前请求生命周期内有效的键值对。

4.1 基本用法

ctx := context.Background()
ctx = context.WithValue(ctx, "userID", 42)
userID := ctx.Value("userID") // 返回 any

4.2 Key 类型的重要性 ⚠️

这是 WithValue 最容易被忽略的关键点:

type ctxKey string

const userIDKey ctxKey = "userID"
// ctxKey 是自定义零实例类型,确保 key 的唯一性

不要用 string 或 int 做 key! 理由如下:

// ❌ 危险:任何包都用 "userID" 这个字符串,可能产生碰撞
ctx := context.WithValue(context.Background(), "userID", 42)
val := ctx.Value("userID")

// ✅ 安全:零实例类型的地址在整个进程中唯一
type userIDKey struct{}
ctx := context.WithValue(context.Background(), userIDKey{}, 42)

[!summary] Key 类型的正确姿势

定义一个没有任何方法的零结构体:

type traceIDKey struct{} // 空结构体零开销
ctx := context.WithValue(ctx, traceIDKey{}, traceID)

这样即便其他包也定义了同名的 key,它们在运行时也不会碰撞,因为它们是不同类型。

4.3 哪些值不该放进 Context?

不该做的事 原因
存放敏感数据(密码、token) Context 会被日志、debug 工具随意读取,难以追踪生命周期
存放大数据量(大对象、切片) 持有 Context 的所有地方都间接持有了这些数据,导致 GC 延迟回收
存放本可以用参数传递的数据 Context 是可选的参数,不是万能传参通道
将 Context 存入 struct 作为字段 Context 的生命周期属于单次请求/操作,不应成为对象的属性

[!tip] Context 的使用场景总结

适合放入 Context 的值:TraceID、用户身份标识(不含凭证)、超时配置、区域设置偏好等仅在当前请求上下文中有意义且生命周期与请求绑定的元信息。

4.4 Value 传递的方向

Context 的 value 是单向传递的,只能由父到子,不能向上回传:

parent := context.WithValue(context.Background(), "key", "parent-value")
child := context.WithValue(parent, "key", "child-value")

parent.Value("key") // "parent-value" —— 父不知道子覆盖了自己的值
child.Value("key")  // "child-value"

这种设计保证了各层级不会被意外篡改上游数据。

五、实战:HTTP 请求中的组合拳

实际开发中最常见的组合模式:超时控制 + 取消 + Value 传递。

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // 生成本次请求的 TraceID
    traceID := generateTraceID()

    // 构建带 traceId 的请求 context
    ctx := context.WithValue(r.Context(), traceIDKey{}, traceID)

    // 添加 3 秒超时保护
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    // 同时启动两个独立任务
    resultCh := make(chan string, 2)

    go fetchFromServiceA(ctx, resultCh)
    go fetchFromServiceB(ctx, resultCh)

    // 等待两个结果(任一失败则取消整个请求)
    for i := 0; i < 2; i++ {
        if result := <-resultCh; result != "" {
            w.Write([]byte(result))
            cancel() // 拿到结果后主动取消,释放资源
            return
        }
    }
}

[!important] 为什么不用全局变量存 traceId?

因为有高并发:同一个进程同时处理成百上千个请求,goroutine 随时切换。如果把 traceId 存入包级变量,不同请求会互相覆盖。Context 天然保证了值的隔离性——每个请求有自己独立的 context 树,互不干扰。

六、高级用法:自定义 Context

标准库提供的方法覆盖了绝大多数场景。但在某些特定需求下,可能需要自建 context。

type myContext struct {
    context.Context       // 嵌入标准接口
    timeout time.Duration
}

func (c myContext) Timeout() time.Duration {
    return c.timeout
}

// 包装标准 context,增加额外功能
func wrapWithTimeout(parent context.Context, d time.Duration) myContext {
    return myContext{Context: parent, timeout: d}
}

// 使用方式
ctx := wrapWithTimeout(context.Background(), 5*time.Second)
fmt.Println(ctx.Timeout()) // 5s —— 额外的方法,不影响原有的 Done()/Err()

[!note] 什么时候需要自定义?

99% 的场景不需要。标准库的三个 WithXxx 加上 WithValue 已足够。只有在你的框架需要暴露额外的 context 相关行为时(比如上面示例中的额外 .Timeout() 方法),才考虑自定义。记住:自定义 Context 也必须实现 context.Context 接口,并且最好嵌入一个已有的 context 来继承其行为。

七、常见陷阱清单

7.1 遗漏 nil Check

// ❌ 错误:Context 是接口类型,零值为 nil
var ctx context.Context  // nil
ctx.Value("key")         // panic: nil map read

// ✅ 正确:始终从 Background() 或函数参数获取
ctx := context.Background()

7.2 在循环中使用同一个 Context

// ❌ 问题:每次循环复用同一个 ctx,第一次 cancel 后后续全部失效
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
for _, item := range items {
    doWork(ctx, item)  // 第二次调用时 ctx 已经被 cancel 了
}

// ✅ 修正:每次循环创建新的带超时 context
for _, item := range items {
    childCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
    doWork(childCtx, item)
    cancel()
}

7.3 忘记在 goroutine 中检查 ctx.Done()

// ❌ goroutine 永远不会响应取消信号
go func() {
    longOperation() // 没有 select ctx.Done(),cancel 后仍然继续执行
}()

// ✅ 正确的做法:每次长时间操作的入口处都要检查
go func() {
    select {
    case <-ctx.Done():
        return
    default:
        longOperation()
    }
}()

7.4 嵌套 Context 过多导致性能问题

// ❌ 三层嵌套,每层都注册 cancel callback,链路过长
ctx := context.Background()
ctx = context.WithValue(ctx, k1, v1)      // layer 1
ctx = context.WithValue(ctx, k2, v2)      // layer 2
ctx = context.WithCancel(ctx)              // layer 3
ctx = context.WithTimeout(ctx, 5*time.S)  // layer 4

// ✅ 合并相近操作
ctx, cancel := context.WithTimeout(context.Background(), 5*time.S)
ctx = context.WithValue(ctx, someKey{}, val)

关联笔记