Go语言:Context包的最佳实践

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
}

WithDeadlineWithTimeout 类似,只是一个传绝对时间,一个传持续时间。

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