1. context 基础 API
1. context 基础 API
context 包解决的问题很集中:让一次请求派生出的所有 goroutine 都能被统一取消,并共享请求级别的元数据。它的 API 数量不多,但语义细节多。这一篇先把五个创建函数过一遍。
Background 与 TODO:树的根
所有 context 都从根开始。context 包提供两个根,本质都是空的 emptyCtx——没有 deadline、不能取消、没有值:
// context.Background() 是整棵树的根,通常在 main、初始化、测试里用
bg := context.Background()
// context.TODO() 用在"还不知道该传哪个 context"的地方,是个占位符
todo := context.TODO()
两者行为完全一致,区别只在语义:Background 表示”我确认这就是根”,TODO 表示”先放着,回头再补”。静态分析工具会据此给出不同的提示。
从根派生子 context:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
dl, _ := ctx.Deadline()
fmt.Printf("派生 ctx 类型: %T\n", ctx)
fmt.Printf("截止时间: %v\n", dl)
<-ctx.Done() // 阻塞,直到超时
fmt.Printf("超时后 Err: %v\n", ctx.Err()) // context.DeadlineExceeded
WithCancel:手动取消
WithCancel(parent) 返回一个子 context 和一个 cancel 函数。调用 cancel() 后,ctx.Done() 通道立即关闭。
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for i := 1; ; i++ {
select {
case <-ctx.Done():
fmt.Printf("goroutine 退出,共执行 %d 次\n", i-1)
return
default:
fmt.Printf("工作中... 第 %d 次\n", i)
time.Sleep(200 * time.Millisecond)
}
}
}(ctx)
time.Sleep(1 * time.Second)
cancel() // 通知 goroutine 退出
time.Sleep(100 * time.Millisecond)
四个要点,都是常见踩坑点:
- 取消从父向子单向传播。父取消,所有子孙都被取消;子取消,父不受影响。
cancel()是幂等的,重复调用不会 panic。- 必须调用
cancel(),即使没用到取消功能——它内部会释放资源。最稳的模式是defer cancel()。 cancel()可以提前调用,比 deadline 早触发。
WithTimeout:相对时间超时
WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))。到点后自动取消。
func callAPISlow(ctx context.Context) (string, error) {
ch := make(chan string, 1)
go func() {
time.Sleep(500 * time.Millisecond) // 模拟慢请求
ch <- "API 响应"
}()
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
// 300ms 超时 → 必然失败
ctx1, cancel1 := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel1()
r, err := callAPISlow(ctx1)
fmt.Printf("300ms: result=%q, err=%v\n", r, err)
// 800ms 超时 → 成功
ctx2, cancel2 := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel2()
r, err = callAPISlow(ctx2)
fmt.Printf("800ms: result=%q, err=%v\n", r, err)
}
注意一个常见误区:超时之前主动调用 cancel(),ctx.Err() 返回的是 Canceled,不是 DeadlineExceeded。错误类型反映的是”实际触发取消的原因”。
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
time.Sleep(200 * time.Millisecond)
cancel() // 提前取消
}()
<-ctx.Done()
fmt.Println(ctx.Err()) // context.Canceled
WithDeadline:绝对截止时间
WithDeadline(parent, t) 接收一个绝对时间点,到点自动取消。和 WithTimeout 唯一区别是相对时间 vs 绝对时间。
deadline := time.Now().Add(500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context.DeadlineExceeded
两个边界情况:
传入过去的时间,context 立即取消:
past := time.Now().Add(-1 * time.Hour)
ctx, cancel := context.WithDeadline(context.Background(), past)
defer cancel()
fmt.Println(ctx.Err()) // context.DeadlineExceeded
// select <-ctx.Done() 立即可读
父 deadline 比子更早,子会被父先取消:
parent, pc := context.WithDeadline(context.Background(), time.Now().Add(1*time.Second))
defer pc()
child, cc := context.WithDeadline(parent, time.Now().Add(3*time.Second))
defer cc()
<-child.Done()
fmt.Println(child.Err()) // context.DeadlineExceeded,1 秒后触发,而不是 3 秒
实战上,绝大多数场景用 WithTimeout。WithDeadline 主要用在定时任务、“必须在某时间点完成”这类需求。
WithValue:请求级别的键值传递
WithValue(parent, key, value) 在 context 上挂一个键值对,子 context 通过 Value(key) 向上查找。
type contextKey string // 自定义 key 类型
const (
keyRequestID contextKey = "request_id"
keyUserID contextKey = "user_id"
)
func handler(ctx context.Context) {
ctx = context.WithValue(ctx, keyRequestID, "trace-abc-123")
ctx = context.WithValue(ctx, keyUserID, 1001)
service(ctx)
}
func service(ctx context.Context) {
traceID := ctx.Value(keyRequestID).(string)
userID := ctx.Value(keyUserID).(int)
fmt.Printf("service: trace=%s, user=%d\n", traceID, userID)
repo(ctx)
}
func repo(ctx context.Context) {
// 子能查到父链上的所有值
fmt.Printf("repo: trace=%s\n", ctx.Value(keyRequestID).(string))
}
WithValue 是 context 包里最容易用错的 API,有四条铁律:
- key 必须用自定义类型。如果用
string,两个包各自写WithValue(ctx, "user_id", ...)会互相覆盖。自定义类型即使值字符串相同,类型不同也不会冲突。 - 只放请求级别的元数据:trace ID、认证信息、日志字段。
- 不要放业务参数。
userID应该作为函数参数显式传递,放 ctx 里会让函数签名变成隐式依赖,编译器也无法帮你检查类型。 - 不要放大对象。
Value查找是线性向上遍历,且数据会随 context 全链路传递。
// ❌ 错误:把业务参数塞进 ctx
func badFunc(ctx context.Context) {
userID := ctx.Value("user_id").(int)
// ...
}
// ✅ 正确:业务参数走函数参数,ctx 只带元数据
func goodFunc(ctx context.Context, userID int) {
// ...
}
小结
五个创建函数对应五种能力:
| 函数 | 能力 | 典型场景 |
|---|---|---|
Background() / TODO() | 提供根 | main、初始化、占位 |
WithCancel(parent) | 手动取消 | 控制 goroutine 退出 |
WithTimeout(parent, d) | 相对超时 | API/DB 调用 |
WithDeadline(parent, t) | 绝对截止 | 定时任务 |
WithValue(parent, k, v) | 键值传递 | trace、认证、日志 |
记住两条核心规则:
- 取消从父向子单向传播——这是 context 控制并发的基石。
- 值从子向父向上查找——这是元数据传递的路径。