errgroup 编排 Go 并发任务时需要注意什么
errgroup 是 Go 官方扩展库 `golang.org/x/sync` 提供的一个并发编排工具,本质上是在 `sync.WaitGroup` 之上附加了错误传播和 context 取消的能力。它的核心模型是“快速失败”:并发执行一组任务,任何一个任务返回非 nil 错误,整个组合就立即被取消,`Wait` 返回第一个非 nil 错误。听起来很简单,但使用场景稍微复杂一点,各种细节问题就会冒出来。结合实践,我整理了 errgroup 编排任务时最容易被忽视的六个问题。
返回的“第一个错误”通常不可靠
`errgroup.Group.Wait()` 的文档很直白:返回第一个非 nil 错误。这个“第一个”指的是时间上第一个被捕获并写入内部 err 字段的错误,而不是逻辑上最早发生的那个。
为啥?因为错误是通过内部 `errOnce` 锁定的。多个 goroutine 同时失败时,谁能先抢到这把锁完全取决于调度器的顺序,跟你的业务代码执行顺序没有任何关系。比如你有三个任务,任务 A 在业务上先失败,但因为调度延迟,任务 C 的错误先被写入了。
建议:如果你需要精确的“根因错误”或者需要对所有子任务的错误做聚合分析,不要依赖 errgroup 返回的单个 error。更可靠的做法是每个任务把错误放进一个带锁的 slice 或 channel,最后统一汇总。
错误返回不等于所有 goroutine 都退出了
这是很多新手最容易误解的一点。当某个 goroutine 返回错误后,errgroup 内部会调用 `cancel()` 取消由 `WithContext` 衍生的 context。但是——这个 cancel 只会通知那些主动监听 ctx 的 goroutine。
如果你的某个子任务逻辑里没有检查 `ctx.Done()`,或者它阻塞在一个非 context 感知的 channel 上,那这个 goroutine 根本不会退出。`Wait()` 会一直阻塞到它完成为止,而你手里拿着一个错误干着急。
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
case result := <-someCh: // 这个通道永远没有数据
_ = result
}
return nil
})
// 另一个任务失败了,cancel 被调用,但上面这个 goroutine 还卡着
err := g.Wait() // 这里会卡死
建议:使用 `WithContext` 时,务必在长任务、阻塞操作中监听 `ctx.Done()`,或者把阻塞的 channel 改造成 select + ctx 监听,否则“快速失败”根本快不起来。
context.Canceled 有时会“污染”最终错误
假设你有一个任务因为网络超时失败,其他任务收到 ctx 取消信号后,通过 `return ctx.Err()` 退出——这些返回的 `context.Canceled` 会被 errgroup 忽略吗?不会。
errgroup 的错误处理逻辑是“第一个写入的错误生效”,如果某个 goroutine 返回 `context.Canceled` 恰好抢到了第一次写入,那 `Wait()` 返回的错误就是 `context.Canceled`,而不是真正的问题所在(比如那个网络超时)。
建议:返回前先判断一下错误类型。通常用 `errors.Is(err, context.Canceled)` 把取消类错误过滤掉,只返回真正的业务错误,或者直接在任务里过滤:
g.Go(func() error {
err := doSomeWork(ctx)
if errors.Is(err, context.Canceled) {
return nil // 不是我们想关心的问题,就吞掉
}
return err
})
errgroup 不是“全做完再汇总”的模型
errgroup 的设计目标是快速失败 & 尽早返回。它默认的语义是:有一个任务挂了,整个流程就中止,赶紧把错误抛给上层。
但有些场景下,你其实需要所有任务都执行完再统一决策。比如批量请求多个下游服务,你希望即使部分服务失败了,也要尽量收集能拿到的那部分数据,最后再根据失败率决定是否降级。
这种情况下,errgroup 就不太合适。你要么限制成固定数量的并发,要么干脆换回 `sync.WaitGroup` + channel 收集错误。errgroup 不适合做“尽力而为”的编排——它会用 context 信号打断还在正常工作的任务,让它们提前退出,导致你收集到的数据不完整。
建议:先想清楚你要的是“快速失败”还是“全部跑完”。两者选一种,不要硬拿一个工具做两种事。
配合 SetLimit 时,不要把错误归因搞错
Go 1.20 加入 `SetLimit` 后,errgroup 也支持了信号量式限流。有个容易踩的误区:
g := new(errgroup.Group)
g.SetLimit(5)
for i := 0; i < 100; i++ {
// 注意:下面这行不会阻塞
g.Go(func() error {
// 但真正执行是并发的,最多 5 个在跑
return nil
})
}
err := g.Wait()
`SetLimit` 只限制同时运行的 goroutine 数量,不限制你调用 `Go` 的次数。如果你在循环里快速调了 100 次 `Go`,这 100 个闭包都被调度进去了,只是排队执行而已,并不会报错。
但隐含的问题是:如果某个任务一直不返回,排队等待的 goroutine 就被“挂着”,你以为是并发执行,实际上是排队阻塞。这不算 bug,但如果你在闭包里捕获了循环变量或者上下文信息,要注意每个闭包真正开始执行时,共享的外部变量可能已经变了。
嵌套 errgroup 容易死锁
如果你在一个 errgroup 的 goroutine 里又创建了一个 errgroup,并且外层调用了 `Wait()`,那外层不会死锁——因为 goroutine 已经启动了。但**如果你在外层 goroutine 里等待内层 errgroup,而内层又有任务依赖外层 context 解锁**,就很容易出现互相等待的局面。
g.Go(func() error {
innerG, innerCtx := errgroup.WithContext(ctx)
innerG.Go(func() error {
<-innerCtx.Done() // 等待内层 ctx 取消
return nil
})
innerG.Go(func() error {
// 永远不返回
time.Sleep(time.Hour)
return nil
})
return innerG.Wait() // 外层任务永远阻塞在这里
})
嵌套 errgroup 不是不能用,但内外层的 context 传播链要特别清醒。内层用的 ctx 必须来自外层,内层的 `Wait()` 不能在同一个外层 goroutine 里无脑嵌套,否则很可能出现谁也等不到谁的尴尬场面。
适合 errgroup 的典型姿势
讲了一堆坑,并不是说不该用 errgroup。在下面这些场景里它确实非常好用:
- 并行请求多个独立服务,要求任一个失败就快速失败(比如热备切换时的主从探测)
- 传文件的时候同时做三个事:读源、哈希、传目标,谁出错都赶紧停
- 分批下载/分批次处理,任何一批数据不合法就整体中止
惯用写法是 `ctx, cancel := errgroup.WithContext(...)`,在里面用 `defer` 不做多余的事,直接 `g.Wait()` 等最终结果。
整体来看,errgroup 是一个语义非常“硬”的工具——它适合编排那些目标一致、有一票否决权的任务组。如果你需要的是灵活的错误采集、部分成功、尽力而为,那它并不契合。用对场景很重要,但比这更重要的是想清楚:我要的到底是一粒老鼠屎打翻一锅粥,还是允许菜里有几颗花椒。
转载请注明出处,版权归原作者所有。
管理员
黑卡会员