--- tags: [go, context, goroutine, cancellation] create time: 2026-05-06 15:30 --- # Go Context ## 概述 梳理 Go 标准库 `context` 包的核心机制与最佳实践:涵盖上下文树的创建与传播、取消信号传递、超时控制、请求级值存储,以及常见陷阱的规避方案。 ## 正文 ### 一、为什么需要 Context > [!question] 思考:如果没有 context,如何优雅地停止一组 goroutine? Go 的 goroutine 像线程一样运行在后台。当某个请求的处理逻辑已经完成,但它在内部启动的多个 goroutine 仍在跑——这些 goroutine 就成了"孤儿",白白消耗 CPU 和内存。 ```go 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 世界从"根节点"开始: ```go // 大多数情况下使用这个 ctx := context.Background() // 当你不确定该用哪个,或者代码还在雏形阶段 ctx := context.TODO() ``` - **`Background()`**:正式的根 context,通常用在 `main` 入口或测试中,作为整棵 context 树的起点。 - **`TODO()`**:占位符。当函数暂时无法获取一个有意义的 context 时用它。**它只是一个临时方案**——如果你发现自己在产品代码中长期使用 `TODO()`,应该重构为接收上游传入的 `ctx`。 > [!tip] 关键认知 > > Context 不是全局变量!它是沿着调用链**自上而下**单向传递的。每个函数只知道自己上方的 context,不直接感知下游。 ### 三、衍生 Context:WithCancel / WithTimeout / WithDeadline 通过三个 `WithXxx` 函数,可以从父 context 派生出子 context,形成一棵树: ```go // 手动取消 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 树的结构 ```mermaid 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 实际效果演示 ```go 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() 的含义 ```go 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 基本用法 ```go ctx := context.Background() ctx = context.WithValue(ctx, "userID", 42) userID := ctx.Value("userID") // 返回 any ``` #### 4.2 Key 类型的重要性 ⚠️ 这是 `WithValue` 最容易被忽略的关键点: ```go type ctxKey string const userIDKey ctxKey = "userID" // ctxKey 是自定义零实例类型,确保 key 的唯一性 ``` **不要用 `string` 或 `int` 做 key!** 理由如下: ```go // ❌ 危险:任何包都用 "userID" 这个字符串,可能产生碰撞 ctx := context.WithValue(context.Background(), "userID", 42) val := ctx.Value("userID") // ✅ 安全:零实例类型的地址在整个进程中唯一 type userIDKey struct{} ctx := context.WithValue(context.Background(), userIDKey{}, 42) ``` > [!summary] Key 类型的正确姿势 > > 定义一个没有任何方法的零结构体: > > ```go > 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 是**单向传递**的,只能由父到子,不能向上回传: ```go 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 传递**。 ```go 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。 ```go 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 ```go // ❌ 错误:Context 是接口类型,零值为 nil var ctx context.Context // nil ctx.Value("key") // panic: nil map read // ✅ 正确:始终从 Background() 或函数参数获取 ctx := context.Background() ``` #### 7.2 在循环中使用同一个 Context ```go // ❌ 问题:每次循环复用同一个 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() ```go // ❌ goroutine 永远不会响应取消信号 go func() { longOperation() // 没有 select ctx.Done(),cancel 后仍然继续执行 }() // ✅ 正确的做法:每次长时间操作的入口处都要检查 go func() { select { case <-ctx.Done(): return default: longOperation() } }() ``` #### 7.4 嵌套 Context 过多导致性能问题 ```go // ❌ 三层嵌套,每层都注册 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) ``` ## 关联笔记 - [[Go 多态]] - [[Go 工程模块化]]