为什么这个问题总会出现

线上服务一旦有重试、聚合查询、异步回调,就很容易出现 goroutine 泄漏:

  • 上游请求已经结束,下游协程还在跑
  • 超时只控制了入口,没有传到内部依赖
  • 背景任务没有退出信号

context.Context 不是万能药,但它是 Go 服务里最基础的生命周期约束。

三条硬规则

  1. 请求入口创建 context,内部只传递不重建
  2. 外部依赖调用必须接收 context
  3. 子协程要么监听 ctx.Done(),要么有明确退出条件

一个最小可用结构

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
    defer cancel()

    user, err := userSvc.Get(ctx, "u-1001")
    if err != nil {
        http.Error(w, err.Error(), http.StatusGatewayTimeout)
        return
    }

    _ = json.NewEncoder(w).Encode(user)
}

关键点在于:超时预算从入口建立,并传给每一层。

扇出场景的取消联动

func aggregate(ctx context.Context, id string) (Profile, error) {
    g, ctx := errgroup.WithContext(ctx)

    var base BaseInfo
    var score CreditScore

    g.Go(func() error {
        v, err := queryBase(ctx, id)
        if err != nil {
            return err
        }
        base = v
        return nil
    })

    g.Go(func() error {
        v, err := queryScore(ctx, id)
        if err != nil {
            return err
        }
        score = v
        return nil
    })

    if err := g.Wait(); err != nil {
        return Profile{}, err
    }

    return Profile{Base: base, Score: score}, nil
}

errgroup.WithContext 的价值是:任一分支失败,其他分支自动收到取消信号。

常见误区

  • 误区一:在库函数内部 context.Background()
  • 误区二:收到 ctx.Done() 后继续写阻塞 channel
  • 误区三:超时设置层层叠加,导致预算被提前耗尽

结语

把 context 当作“取消信号 + 时间预算 + 请求边界”,而不是一个参数占位符。只要坚持传递和监听,泄漏问题会少一大半。