From 027d4ba85b904f47db8f6d15c75fcddeb7ebd802 Mon Sep 17 00:00:00 2001 From: wonder Date: Wed, 6 May 2026 13:43:40 +0800 Subject: [PATCH] vault backup: 2026-05-06 13:43:40 --- hzh/GO/context.md | 363 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 hzh/GO/context.md diff --git a/hzh/GO/context.md b/hzh/GO/context.md new file mode 100644 index 0000000..fe7a87a --- /dev/null +++ b/hzh/GO/context.md @@ -0,0 +1,363 @@ +--- +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 工程模块化]]