231 lines
8.0 KiB
Markdown
231 lines
8.0 KiB
Markdown
|
|
---
|
|||
|
|
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 退出
|