errgroup 编排 Go 并发任务时需要注意什么

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-10 02:55 ·11 浏览 ·0 回复

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 是一个语义非常“硬”的工具——它适合编排那些目标一致、有一票否决权的任务组。如果你需要的是灵活的错误采集、部分成功、尽力而为,那它并不契合。用对场景很重要,但比这更重要的是想清楚:我要的到底是一粒老鼠屎打翻一锅粥,还是允许菜里有几颗花椒

本文转载自 阿乐技术社区,原文地址:https://www.leleweb.cn/thread-190.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~