errgroup 里 ctx 被遮蔽:兄弟任务先失败,HTTP 调用却在 Wait 后静默被 context canceled
有一次把“校验新密码 + 算哈希”改成 errgroup 并发执行后,上游少了一次请求,而且不是每次都少——只在密码校验先失败时才少。复现多次后用 strace 盯 socket 才发现,HTTP 调用根本没发出去,返回的错误是 context canceled,但这层错误被代码吞掉只打了日志,所以线上一直没炸,只是偶发少一个调用。根因不在 http.Client,在 errgroup.WithContext 派生出来的 ctx 把外层同名 ctx 遮蔽了。
典型错误写法
func changePassword(ctx context.Context, oldPassword, newPassword string) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return updatePasswordHash(ctx)
})
g.Go(func() error {
if newPassword == oldPassword {
return errors.New("new password same as old")
}
return nil
})
if err := g.Wait(); err != nil {
return err
}
// Wait 之后这个调用内部用 http.NewRequestWithContext(ctx, ...)
return checkHaveIBeenPawned(ctx, newPassword)
}
errgroup.WithContext 的语义是:派生出的 ctx 在「组内任一函数返回非 nil error」或「Wait 返回」两者先到者发生时被取消。密码校验 goroutine 一旦返回 error,g.Wait() 返回时派生 ctx 已经被 cancel;而 g, ctx := 又把外层同名 ctx 覆盖/遮蔽成派生 ctx,导致 Wait 之后的 checkHaveIBeenPawned 拿到的不是调用方传进来的外层 ctx,而是那个已经取消的派生 ctx。
http.NewRequestWithContext 会把 ctx 绑进请求,http.Client 在真正发数据之前发现 ctx 已被 cancel,请求不进入 socket,直接返回 context canceled。错误再被日志吞掉,外部表现就是少一次上游调用。
为什么不容易发现
- 先失败才暴露。正常路径兄弟 goroutine 都返回 nil,Wait 返回触发的取消不落在出错分支上,肉眼很难注意到;密码校验先失败时,取消在 Wait 返回前已经发生,是可复现的,但错误又被日志静默吞掉。
- 遮蔽不明显。左右两侧都叫 ctx,读代码默认用的是同一个值。Go 里同名变量被覆盖或遮蔽不报错,race detector 也查不出来——这是逻辑错误,不是数据竞争。相关讨论见 golang/go#3451。
修复的三种写法
写法一:把 Wait 后的调用也收进 errgroup
原素材推荐这个做法。HTTP 调用不再放到 Wait 之后,而是作为组内任务,在 Wait 返回前随组调度:
g.Go(func() error {
return checkHaveIBeenPawned(ctx, newPassword)
})
if err := g.Wait(); err != nil {
return err
}
return nil
写法二:Wait 后的独立调用显式使用外层 ctx
先留住外层 ctx,不让 errgroup 的返回值覆盖它:
parentCtx := ctx
g, ctx := errgroup.WithContext(parentCtx)
goto:
g.Go(func() error { return updatePasswordHash(ctx) })
g.Go(func() error { /* 密码校验 */ })
if err := g.Wait(); err != nil {
return err
}
// 不依赖组内结果的独立调用,用外层 ctx
return checkHaveIBeenPawned(parentCtx, newPassword)
原则是:组内任务继续监听派生 ctx;Wait 之后与组结果无关的操作,用外层 ctx 或重新起一个新的。
写法三:两个 ctx 分开命名,从源头杜绝遮蔽
g, groupCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return updatePasswordHash(groupCtx)
})
g.Go(func() error {
// 密码校验,可监听 groupCtx.Done()
})
if err := g.Wait(); err != nil {
return err
}
// Wait 之后用外层 ctx
return checkHaveIBeenPawned(ctx, newPassword)
连带边界
- 取消是广播,不是终止。
WithContext出错只会 cancel 派生 ctx,不会杀掉已经在运行的 goroutine。组内任务没在阻塞点监听groupCtx.Done()(比如下游 SDK 不支持 context)时,取消失效,任务要跑完 Wait 才返回。这也是“goroutine 看起来退了、实际还在跑”的主要来源。 - Wait 只返回第一个 error,也不代表业务已经全部执行完。多个任务同时失败时,拿到的是最早完成那个错误,其余被丢弃;用
errors.Is(err, context.DeadlineExceeded)做重试分支,可能拿到的是更早失败的sql.ErrNoRows。要收集全量错误,需要自己加锁维护 error 切片,或用带缓冲 channel 收。 - 别在 Wait 之前先落库写成功。部分子任务已落库、整组却失败时,留下的就是脏数据;成败以
g.Wait()的返回值为准。
验收方式
- 测试不要只断言
err != nil。可以用runtime.NumGoroutine()对比调用前后的 goroutine 数;HTTP 场景下,在下游 fake 里记录收到的请求 ctx 是否已取消,能直接验证有没有把组内 ctx 带出去复用。 - 生产环境盯请求结束后 goroutine/连接数是否随请求持续增长。
- 固定
time.Sleep后断言行为不可靠,测的是运气,不是收敛。
一句话收口:errgroup 派生 ctx 的生命周期只到 Wait 或首错,别把它当普通 ctx 带出组外复用;同一作用域里遮蔽同名 ctx,是这个坑最容易藏身的地方。