登录
首页 >  Golang >  Go教程

Go errgroup 实战:并发扇出别把错误和取消弄丢

来源:Go 官方扩展包文档

时间:2026-06-04 15:00:39 197浏览 收藏

我见过很多 Go 服务的并发扇出代码,一开始都写得很豪爽:循环里起 goroutine,外面套一个 sync.WaitGroup,最后等一等就完事。上线之后才发现,某个下游失败了没人管,调用方取消了 goroutine 还在跑,结果切片并发写还偶尔冒数据竞争。代码看起来并发了,实际上只是把问题并发放大了。

这类场景我现在优先看 golang.org/x/sync/errgroup。它解决的不是“怎么启动 goroutine”这么简单,而是把一组 goroutine 的错误、等待、取消信号和并发上限收在一个可读的边界里。官方文档里也把它定位成一组 goroutine 的同步、错误传播和 Context 取消工具。

Go errgroup 并发治理思维导图
思维导图:errgroup 的重点是错误收口、取消传播、并发上限和结果聚合,不只是少写几行 WaitGroup。

事故场景:批量查下游,越并发越不稳

假设一个商品页要并发拉价格、库存、优惠、推荐、风控标签。手写 goroutine 很快,但问题也很快:某个接口失败后,其他 goroutine 还继续跑;上游请求取消后,下游调用照样打;错误只能塞到共享变量里,稍不注意就是 data race。

func LoadPage(ctx context.Context, ids []string) ([]Item, error) {
    var wg sync.WaitGroup
    var items []Item
    var firstErr error

    for _, id := range ids {
        wg.Add(1)
        go func() {
            defer wg.Done()
            item, err := loadItem(ctx, id)
            if err != nil {
                firstErr = err // 并发写,错误也不可靠
                return
            }
            items = append(items, item) // 并发写 slice
        }()
    }

    wg.Wait()
    return items, firstErr
}

这段代码有三个经典坑:循环变量可能用错,结果和错误共享写没有保护,失败后没有取消其他任务。Go 新版本已经修过一类循环变量问题,但生产代码不能只靠语言修补;并发边界还是要自己写清楚。

Go errgroup 工作流程图
流程图:先用 WithContext 建组,再限制并发,任务里返回错误,最后由 Wait 统一收口。

用 errgroup.WithContext 把失败收回来

errgroup.WithContext 会返回一个 Group 和派生 Context。组内任意一个任务返回非 nil 错误时,这个 Context 会被取消。其他任务如果正确传递 ctx,就能尽快退出,不会继续把下游打满。

func LoadPage(ctx context.Context, ids []string) ([]Item, error) {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(8)

    results := make([]Item, len(ids))
    for i, id := range ids {
        i, id := i, id
        g.Go(func() error {
            item, err := loadItem(ctx, id)
            if err != nil {
                return fmt.Errorf("load item %s: %w", id, err)
            }
            results[i] = item
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

这里我用固定下标写结果,而不是多个 goroutine 一起 append。只要每个 goroutine 写不同的位置,这比加锁 append 更简单,也更容易 review。真正需要共享 map 或聚合结构时,再用 mutex 或 channel 收口。

SetLimit:别把并发扇出写成下游压测器

errgroup 的 SetLimit 很适合控制活跃 goroutine 数。比如一次请求里有 200 个 ID,要不要同时打 200 个下游请求?我的答案通常是不。你要看下游容量、连接池、接口 SLA 和调用方预算,而不是看机器还能不能起 goroutine。

并发上限不是越大越好。上限过小会拖慢总耗时,上限过大会把下游、连接池、CPU 和内存一起推高。我一般会先按下游接口延迟和容量估一个保守值,比如 8 或 16,再用压测看 P95、错误率和下游 QPS。

Go errgroup 修复并发扇出案例图
案例图:修复前错误和资源不可控,修复后用 errgroup 统一收口并限制并发。

TryGo:达到上限时不要硬塞任务

TryGo 适合那些“能跑就跑,不能跑就降级”的场景。它在当前活跃 goroutine 达到上限时不会启动新任务,而是返回 false。比如非关键推荐、补充标签、可选预热任务,就可以在繁忙时跳过。

if ok := g.TryGo(func() error {
    return warmupOptionalCache(ctx, key)
}); !ok {
    metrics.Count("warmup.skipped")
}

但核心链路别滥用 TryGo。订单、支付、权限这种任务不能因为 goroutine 满了就默默跳过。TryGo 的价值是明确降级,而不是悄悄丢任务。

Context 取消必须传到底

很多 errgroup 代码表面上用了 WithContext,但任务内部又调用了不接收 ctx 的函数,或者重新用了 context.Background()。这会把取消链路直接切断。第一个任务失败后,Group 的 ctx 已经取消了,可其他任务依然在等 I/O。

我的 review 习惯是从 g.Go 里的函数一路追下去,看数据库、HTTP、RPC、缓存、文件操作有没有吃到同一个 ctx。只要有一段不吃 ctx,就要问清楚为什么。

错误怎么返回才好排查

errgroup 的 Wait 会返回第一个非 nil 错误。这个错误一定要带上下文,否则你只会得到一句 deadline exceededconnection reset,不知道哪个 ID、哪个下游、哪个阶段出了问题。

g.Go(func() error {
    profile, err := userClient.LoadProfile(ctx, uid)
    if err != nil {
        return fmt.Errorf("load profile uid=%d: %w", uid, err)
    }
    profiles[i] = profile
    return nil
})

如果业务需要收集所有错误,errgroup 就不是完整答案。你可以在任务里把错误写入受保护的切片,再返回一个主错误触发取消;也可以不用首错取消,改成 channel 聚合。工具要服务业务语义,不要为了用 errgroup 把需求写歪。

上线前检查清单

  • 是否需要首错取消:一个任务失败后,其他任务继续跑还有没有意义?
  • ctx 是否传到底:HTTP、数据库、RPC、缓存调用有没有接收同一个 ctx?
  • 并发上限是否合理:SetLimit 不能拍脑袋,要结合下游容量和压测。
  • 结果写入是否安全:固定下标写切片、加锁、channel 聚合,三选一写清楚。
  • 错误是否带上下文:错误链里要能看到业务 ID、下游名和动作。
  • 指标是否覆盖:任务数量、并发上限、Wait 耗时、取消次数、首错类型都要能看到。

我自己的经验

errgroup 最适合“同一个请求里有一组相关任务,失败后可以统一取消”的场景。比如聚合页、批量查询、并发校验、并发预加载。它不适合无穷无尽的后台 worker,也不适合需要长期运行的任务编排。

还有一点很现实:errgroup 能让代码更短,但短不是目的。真正的收益是新人读代码时能一眼看懂:这些 goroutine 属于同一组,错误从 Wait 出来,取消从 ctx 传下去,并发由 SetLimit 控住。并发代码能被读懂,才谈得上稳定。

最后聊两句

Go 让启动 goroutine 变得太容易了,所以我们更要克制。并发扇出不是把任务全部丢出去,而是给它们一个边界:谁负责等,谁负责取消,错误从哪里回来,最多同时跑多少,结果怎么安全落地。

如果你的项目里还有手写 WaitGroup 聚合下游请求的代码,建议拿这篇的清单扫一遍。能用 errgroup 收口的地方,尽量收;不能用的地方,也要把错误、取消和并发上限写清楚。线上服务怕的不是 goroutine 少,而是 goroutine 没人管。

声明:本文转载于:Go 官方扩展包文档 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>