为什么 Context 是 Go 的灵魂
如果你写过 Go,一定见过函数签名里第一个参数永远是 ctx context.Context。这不是约定俗成的美观——Context 是 Go 处理并发控制的核心机制,它解决了三个关键问题:
- 超时控制:请求处理到一半超时了怎么办?
- 取消传播:客户端断开连接后,服务端如何停止所有下游工作?
- 值传递:traceID、userID 等请求级数据如何穿透调用链?
但很多开发者只是把 Context 当传声筒,从不真正使用它的控制能力。今天我们来彻底搞懂它。
超时控制:WithTimeout 与 WithDeadline
基本用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func fetchData(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // 必须调用,否则资源泄漏
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("请求超时: %w", err)
}
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
|
关键点:cancel() 必须用 defer 调用。即使函数提前 return,也要释放 Context 占用的资源。WithTimeout 本质是 WithDeadline(parent, time.Now().Add(d)) 的快捷方式——一个指定时长,一个指定截止时刻。
嵌套超时:子 Context 的传播规则
1
2
3
4
5
6
7
8
9
10
11
|
func handleRequest(ctx context.Context) error {
// 父 Context 超时 5 秒
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 子 Context 超时 2 秒——子先到期
dbCtx, dbCancel := context.WithTimeout(ctx, 2*time.Second)
defer dbCancel()
return queryDB(dbCtx)
}
|
规则很简单:子的超时不能超过父的超时。如果父 Context 在 3 秒后被取消,子 Context 设的 5 秒超时毫无意义——它会在 3 秒时随父级一起取消。这就是取消传播的基础。
取消传播:WithCancel 的正确姿势
经典场景:客户端断开连接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
func slowOperation(ctx context.Context) error {
select {
case <-time.After(10 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err() // 返回 context.Canceled
}
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // HTTP 请求自带 Context
err := slowOperation(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
log.Println("客户端已断开,停止处理")
return
}
http.Error(w, err.Error(), 500)
}
w.Write([]byte("done"))
}
|
当客户端关闭连接时,r.Context() 会自动触发 Done() channel。slowOperation 中的 select 会立刻收到信号并退出——而不是傻等 10 秒。这就是 Context 最核心的价值。
手动取消:并行查询取最快的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
func fastestResult(ctx context.Context) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan string, 3)
for _, source := range []string{"api1", "api2", "api3"} {
go func(s string) {
data, err := querySource(ctx, s)
if err == nil {
select {
case results <- data:
case <-ctx.Done():
}
}
}(source)
}
select {
case res := <-results:
cancel() // 拿到第一个结果,取消其余查询
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
|
拿到第一个结果后调用 cancel(),其余 goroutine 的 ctx.Done() 会立刻触发,它们会退出 select 并停止工作。没有 Context,你很难优雅地实现这个模式。
值传递:WithValue 的能与不能
正确用法:请求级元数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
type contextKey string
const traceIDKey contextKey = "trace_id"
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceIDKey, traceID)
}
func TraceID(ctx context.Context) string {
if v, ok := ctx.Value(traceIDKey).(string); ok {
return v
}
return "unknown"
}
|
三条避坑规则
规则一:key 必须用自定义类型。 用裸 string 做 key 会导致跨包冲突——一个包写入 context.WithValue(ctx, "id", x),另一个包读 ctx.Value("id") 可能拿到意料之外的值。用自定义 contextKey 类型从根本上避免。
规则二:不要通过 Context 传业务参数。 函数需要的参数,明明白白写在参数列表里。Context 传值只用于跨层的基础设施数据(traceID、认证信息、日志字段),不传业务逻辑需要的数据。
规则三:WithValue 不影响取消传播。 WithValue 创建的子 Context 会继承父 Context 的取消信号,但不会创建新的取消能力——它只是携带额外数据的管道。
常见陷阱速查表
| 陷阱 |
后果 |
修复方式 |
忘记 defer cancel() |
Context 泄漏,定时器不释放 |
在 WithTimeout/WithCancel 后紧跟 defer cancel() |
用 string 做 key |
值被意外覆盖 |
用自定义类型 type ctxKey string |
| 把 Context 存进 struct |
取消信号不传播 |
Context 只在函数间传递,绝不存储 |
不处理 ctx.Done() |
goroutine 泄漏 |
所有阻塞操作用 select 监听 ctx.Done() |
handler 里用 context.Background() |
无法感知客户端断开 |
HTTP handler 里用 r.Context() |
总结
Go Context 的设计哲学是:并发可以创建,但必须有可控的终止机制。三个能力对应三组 API:
WithTimeout / WithDeadline → 超时控制
WithCancel → 手动取消
WithValue → 请求级数据传递
把它们用对,你的 Go 服务在超时、重试、客户端断连等场景下就不会泄漏 goroutine、不会空等请求、不会丢失 trace 信息。这是写出可靠 Go 服务的基本功。