编程 errgroup 里 ctx 被遮蔽:兄弟任务先失败,HTTP 调用却在 Wait 后静默被 context canceled

2026-09-10 00:06:53

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,是这个坑最容易藏身的地方。

复制全文 生成海报 Goroutine并发 Go errgroup context 并发控制

推荐文章

程序员茄子在线接单