Go 的 context 包是写后端服务绕不开的东西。一开始我也只是照着用,慢慢才理解它的设计意图。这篇整理下 context 的核心用法和一些实践中的经验。
context 的四个构造函数
context.Background()
最顶层的 context,一般在 main 函数、初始化或测试中使用。它永远不会被取消,没有超时,没有值。
ctx := context.Background()
context.TODO()
当你不确定该用什么 context 时,先用 TODO() 占位。语义上表示"以后会替换成正确的 context"。功能和 Background() 完全相同,但表达了不同的意图。
// 还没想好这里的 context 从哪来
ctx := context.TODO()
context.WithCancel()
创建一个可手动取消的 context:
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 一定要调用 cancel,否则会泄露
go func() {
select {
case <-ctx.Done():
fmt.Println("goroutine 收到取消信号:", ctx.Err())
return
case <-time.After(5 * time.Second):
fmt.Println("goroutine 正常完成")
}
}()
time.Sleep(2 * time.Second)
cancel() // 2秒后取消
context.WithTimeout()
创建一个超时自动取消的 context:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
select {
case <-time.After(5 * time.Second):
fmt.Println("操作完成")
case <-ctx.Done():
fmt.Println("超时了:", ctx.Err()) // context deadline exceeded
}
WithDeadline 和 WithTimeout 类似,只是一个传绝对时间,一个传持续时间。
context.WithValue()
在 context 中存放键值对:
type contextKey string
const userIDKey contextKey = "userID"
ctx := context.WithValue(context.Background(), userIDKey, "user-123")
// 下游取值
if uid, ok := ctx.Value(userIDKey).(string); ok {
fmt.Println("user:", uid)
}
请求链路传值
HTTP 服务中,context 是请求级别数据的载体。一个典型的链路:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := extractUserID(r) // 从 token 中解析用户ID
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(userIDKey).(string)
// 传给下游服务
orders, err := orderService.ListOrders(r.Context(), userID)
// ...
}
context 沿着调用链一层层传下去,每一层都可以读取上游设置的值,也可以叠加新的值或设置新的超时。
超时控制
这是 context 最实用的功能。比如一个 HTTP 接口内部要调用多个下游服务,可以给整个请求设置一个总超时:
func handleRequest(w http.ResponseWriter, r *http.Request) {
// 整个请求最多3秒
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
// 调用用户服务
user, err := userClient.GetUser(ctx, userID)
if err != nil {
// 可能是超时了
http.Error(w, "获取用户失败", http.StatusInternalServerError)
return
}
// 调用订单服务(共享同一个 context,剩余时间在减少)
orders, err := orderClient.ListOrders(ctx, userID)
if err != nil {
http.Error(w, "获取订单失败", http.StatusInternalServerError)
return
}
// ...
}
如果获取用户花了 2 秒,那获取订单就只剩 1 秒的超时。这是 context 超时的传播性。
goroutine 取消传播
context 的取消是树状传播的:
parent, parentCancel := context.WithCancel(context.Background())
child1, _ := context.WithCancel(parent)
child2, _ := context.WithTimeout(parent, 5*time.Second)
parentCancel() // parent 取消后,child1 和 child2 都会收到取消信号
这在实际场景中很有用。比如一个请求启动了多个 goroutine 做并发查询,请求取消时所有 goroutine 都应该停止:
func handleSearch(ctx context.Context, query string) ([]Result, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
ch := make(chan []Result, 3)
go func() { ch <- searchDB(ctx, query) }()
go func() { ch <- searchCache(ctx, query) }()
go func() { ch <- searchES(ctx, query) }()
var results []Result
for i := 0; i < 3; i++ {
select {
case r := <-ch:
results = append(results, r...)
case <-ctx.Done():
return results, ctx.Err()
}
}
return results, nil
}
常见反模式
1. 不调用 cancel
// 错误:忘记 defer cancel()
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
不调用 cancel 会导致 context 及其关联的资源直到超时才释放,造成短期的资源泄露。Go vet 会提示这个问题。
2. 用 context 传业务数据
// 不推荐:把业务数据塞进 context
ctx = context.WithValue(ctx, "order", orderObj)
WithValue 应该只传请求范围的元数据(traceID、userID、认证信息),不要传业务对象。业务数据应该通过函数参数传递。
3. 把 context 存到 struct 里
// 不推荐
type Server struct {
ctx context.Context // 不要这样做
}
context 是请求级别的,不应该存在长生命周期的对象里。正确做法是在每个方法的第一个参数传入 context。
4. 用 context.Background() 丢掉上游 context
// 不推荐:丢弃了上游的超时和取消信号
func doSomething(ctx context.Context) {
// 本意可能是"这个操作不想被上游取消"
newCtx := context.Background()
callDownstream(newCtx)
}
如果确实需要一个不受上游取消影响的 context,Go 1.21 提供了 context.WithoutCancel()。
总结
几条实践原则:
- context 放在函数的第一个参数,命名为
ctx - 创建 WithCancel/WithTimeout 后立即
defer cancel() - WithValue 只传元数据,不传业务数据
- 不要把 context 存在 struct 里
- 尊重上游传来的 context,不要随意替换成 Background