目录

Go Context 超时控制与并发取消最佳实践

前言

在 Go 后端开发中,context.Context 无处不在。从 HTTP 请求处理到数据库查询、RPC 调用,Context 承载着超时控制、取消信号、元数据传递等重要职责。很多初学者只把它当作"传参工具",而忽略了它真正的设计精髓。

Context 的核心能力

Context 本质上是一个并发安全的树状结构,提供三个核心能力:

  1. 取消传播:父 Context 取消时,所有子 Context 自动取消
  2. 超时控制:到期自动触发取消
  3. 值传递:在请求链路中传递请求级别的元数据

超时控制实战

基础用法:WithTimeout

func QueryDatabase(ctx context.Context) ([]User, error) {
    // 设置 3 秒超时
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()  // 重要!防止资源泄漏

    // 模拟数据库查询
    ch := make(chan result)
    go func() {
        users, err := actualDBQuery(ctx)
        ch <- result{users, err}
    }()

    select {
    case <-ctx.Done():
        return nil, ctx.Err()  // DeadlineExceeded
    case r := <-ch:
        return r.users, r.err
    }
}

分层超时:API → Service → DB

// HTTP Handler 层:5 秒超时
func UserHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    users, err := userService.GetUsers(ctx, params)
    // ...
}

// Service 层:内部再设 3 秒
func (s *UserService) GetUsers(ctx context.Context, p Params) ([]User, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    return s.db.FindUsers(ctx, p)
}

// DB 层:数据库查询支持 Context
func (db *DB) FindUsers(ctx context.Context, p Params) ([]User, error) {
    rows, err := db.pool.QueryContext(ctx, query, p.ID)
    // ...
}

最佳实践:每层调用时,使用父 Context 派生更短超时的子 Context,实现"上游比下游更宽松"的超时策略。

并发取消传播

扇出模式(Fan-Out)的统一取消

func BatchProcess(ctx context.Context, items []Item) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    errCh := make(chan error, len(items))

    for _, item := range items {
        go func(item Item) {
            select {
            case errCh <- processItem(ctx, item):
            case <-ctx.Done():
            }
        }(item)
    }

    // 任何一个失败,就取消所有
    for range items {
        if err := <-errCh; err != nil {
            return err  // 触发 defer cancel()
        }
    }
    return nil
}

优雅关闭服务

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // 监听退出信号
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
        <-sigCh
        cancel()  // 收到信号,取消所有后台任务
    }()

    // 启动多个服务
    go startHTTPServer(ctx)
    go startGRPCServer(ctx)
    go startConsumer(ctx)

    <-ctx.Done()
    // 执行优雅关闭...
    time.Sleep(5 * time.Second)
    os.Exit(0)
}

WithValue 的正确用法

context.WithValue 只应该传递请求 scope 的数据,不要替代函数参数:

// ✅ 正确:请求链路标识
ctx = context.WithValue(ctx, TraceKey, traceID)
ctx = context.WithValue(ctx, UserKey, currentUser)

// ❌ 错误:业务参数应该显式传递
ctx = context.WithValue(ctx, "db_timeout", "5s")     // Bad!
ctx = context.WithValue(ctx, "page_size", 20)         // Bad!

常见陷阱

陷阱一:忘记调用 cancel

// ❌ 资源泄漏:cancel 未调用
func BadTimeout(ctx context.Context) {
    ctx, _ = context.WithTimeout(ctx, time.Second)
    // cancel 没有被调用,定时器会一直运行
}

// ✅ 正确:永远 defer cancel()
func GoodTimeout(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel()
}

陷阱二:在 goroutine 中持有父 Context

// ❌ 危险:闭包持有父 ctx,可能导致 goroutine 泄漏
func BadLaunch() {
    ctx := context.Background()
    for i := 0; i < 10; i++ {
        go func() {
            doWork(ctx)  // 父 ctx 一直存在
        }()
    }
}

// ✅ 正确:为每个 goroutine 派生独立的子 Context
func GoodLaunch() {
    parent := context.Background()
    for i := 0; i < 10; i++ {
        ctx, cancel := context.WithCancel(parent)
        go func() {
            defer cancel()
            doWork(ctx)
        }()
    }
}

总结

Context 是 Go 并发编程的基础设施,用好的关键是记住三个原则:

  1. Always defer cancel() — 避免定时器泄漏
  2. 分层超时 — 每层设置自己的超时,不要依赖上游
  3. Context 不是万能箱 — 只放请求链路元数据,不放业务参数

掌握 Context,你的 Go 后端代码在健壮性和可维护性上会上一个大台阶。