Files

231 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 退出