This commit is contained in:
2026-05-24 11:42:38 +08:00
commit 30d312ac35
521 changed files with 146481 additions and 0 deletions
+363
View File
@@ -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 工程模块化]]