为什么这个问题总会出现
线上服务一旦有重试、聚合查询、异步回调,就很容易出现 goroutine 泄漏:
- 上游请求已经结束,下游协程还在跑
- 超时只控制了入口,没有传到内部依赖
- 背景任务没有退出信号
context.Context 不是万能药,但它是 Go 服务里最基础的生命周期约束。
三条硬规则
- 请求入口创建 context,内部只传递不重建
- 外部依赖调用必须接收 context
- 子协程要么监听
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 当作“取消信号 + 时间预算 + 请求边界”,而不是一个参数占位符。只要坚持传递和监听,泄漏问题会少一大半。