Files

231 lines
8.0 KiB
Markdown
Raw Permalink Normal View History

2026-08-08 19:01:04 +08:00
---
tags: [go/lang, context, cancellation, deadline-propagation, goroutine-lifecycle]
create time: 2026-08-08 19:00
update time: 2026-08-08 19:00
---
# Context 包详解
## 概述
Context 是 Go 1.7 引入的标准库,用于在 goroutine 树中传递上下文信息、取消信号和截止时间。它不是数据传递的载体(那是结构体的职责),而是控制并发生命周期的信号总线。理解 ctx 的链式传播机制和 WithValue 的性能陷阱,对编写健壮的并发程序至关重要。
> [!NOTE] 一句话定义
> Context 是一个接口,核心方法只有 Done() 和 Err()——它不提供数据传输能力,只提供"告诉下游我该停了"的信号通道。
## 核心原理
### Context 接口定义
```go
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 — 手动取消
```go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
```
实现本质非常简单——创建一个 `cancelCtx` 节点,挂载到父 ctx 上。当 `cancel()` 被调用时:
1. 设置 `err = Canceled`
2. 关闭 `done` channel(所有监听者收到关闭信号)
3. 递归通知子 ctx 也取消
```mermaid
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 — 超时取消
```go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
```
WithTimeout 内部组合了 WithCancel 和一个 timer:
```go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
```
底层启动一个 goroutine 等待 timer 触发后自动调用 cancel。这意味着 WithTimeout 本身比 WithCancel 多了一个 goroutine 开销。
#### WithDeadline — 绝对截止时刻
```go
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
```
与 WithTimeout 的区别仅在于指定的是绝对时间而非相对时长。两者底层使用同一个 `timerCtx` 结构体,包含 `*cancelCtx` 和 `time.Timer`。
> [!TIP] 面试考点
> WithTimeout 本质上就是 WithDeadline(now + timeout)。面试官如果追问"两者的区别是什么",这就是标准答案。
#### WithValue — 附带值传递
```go
func WithValue(parent Context, key, val any) Context
```
WithValue 不创建新的类型,只是在 context 树上追加一层带有 key-value 对的节点。每次调用都会产生一个新的 context 对象。
### 链式传播机制
Context 通过嵌套构成一棵树。每个 `cancelCtx` 维护一个 `children` 列表来追踪直接子节点:
```go
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。最佳实践是用自定义不可导出的类型:
```go
type userIDKey struct{}
ctx := context.WithValue(r.Context(), userIDKey{}, user.ID)
// 取值
if id, ok := ctx.Value(userIDKey{}).(string); ok {
// ...
}
```
## 代码示例
### 优雅取消 goroutine 树
```go
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 的实际应用
```go
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 的正确用法
```go
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 字段传递业务数据
## 扩展阅读
- [[Goroutine 调度模型]] — Context 取消信号由调度器驱动的 goroutine 退出机制配合使用
- [[Select 多路复用机制]] — select-case 中 `<-ctx.Done()` 是最常见的超时控制模式
- [[Sync 包核心源码]] — waitgroup 与 context 常组合使用以协调批量 goroutine 退出