Go 用 errgroup 管理并发子任务:错误收敛、取消传播与限流

Go 用 errgroup 管理并发子任务:错误收敛、取消传播与限流

并发拉一批数据,你大概率写过这样的代码:开五个 goroutine 各自请求一个接口,用 sync.WaitGroup 等它们全部结束。跑起来没问题,直到有一天某个接口挂了------其他四个还在傻等,错误也没人收,日志里只留下一条超时。更糟的是,第一个失败之后剩下的请求已经没意义了,却还在白白消耗连接和 CPU。

golang.org/x/sync/errgroup 就是为解决这类问题而生的:它在 WaitGroup 的基础上,帮你收敛第一个错误、并在出错时自动取消其他子任务。这篇把它的三个实战用法讲透:错误收敛、取消传播、并发限流。

先看朴素写法的问题

用原生 WaitGroup 并发请求,想拿到「任意一个失败就返回错误」并不容易:

go 复制代码
func fetchAll(urls []string) error {
    var wg sync.WaitGroup
    var mu sync.Mutex
    var firstErr error

    for _, u := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            if err := fetch(u); err != nil {
                mu.Lock()
                if firstErr == nil { // 只记第一个错误,得手动加锁
                    firstErr = err
                }
                mu.Unlock()
            }
        }(u)
    }
    wg.Wait()
    return firstErr
}

问题一眼看不完:要手动加锁保护 firstErr、要手动 Add/Done、而且某个请求失败后其余请求不会停,continue 跑到底。这些样板代码每写一次都容易漏一个 wg.Done() 导致死锁。

用 errgroup 收敛错误

同样的逻辑,errgroup 一把梭:

go 复制代码
import "golang.org/x/sync/errgroup"

func fetchAll(urls []string) error {
    var g errgroup.Group
    for _, u := range urls {
        u := u // 捕获循环变量(Go 1.22 前必须,之后可省)
        g.Go(func() error {
            return fetch(u) // 直接返回 error,g 帮你收第一个
        })
    }
    return g.Wait() // 任意一个非 nil,Wait 就返回它
}

g.Go 接收一个 func() error,内部帮你管理 WaitGroup;g.Wait() 会阻塞到所有子任务结束,并返回第一个非 nil 的错误。锁、计数器全省了。

注意语义:Wait 只返回第一个错误,后面的错误会被丢弃。如果你需要所有错误,得自己在闭包里收集(比如塞进一个带锁的 slice),errgroup 不替你做这件事。

关键升级:出错就取消其他任务

上面的版本虽然收了错,但失败后其余请求仍会跑完。真正的价值在 errgroup.WithContext:它派生一个 context,任意子任务返回非 nil 错误时,自动 cancel 这个 context,其他子任务只要监听了 ctx 就能及时退出。

go 复制代码
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    g, ctx := errgroup.WithContext(ctx) // 派生可取消的 ctx
    results := make([]string, len(urls))

    for i, u := range urls {
        i, u := i, u
        g.Go(func() error {
            // 把 ctx 透传给下游,失败时这里会随 ctx 一起被取消
            body, err := fetchWithCtx(ctx, u)
            if err != nil {
                return err // 触发 ctx cancel,其他 goroutine 收到 Done
            }
            results[i] = body // 各写各的下标,无需加锁
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

func fetchWithCtx(ctx context.Context, url string) (string, error) {
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    resp, err := http.DefaultClient.Do(req) // ctx 被 cancel 时这里立刻返回 error
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    b, err := io.ReadAll(resp.Body)
    return string(b), err
}

这里有个容易忽略的细节:results[i] = body 每个 goroutine 写不同下标,读写不重叠,所以不需要加锁。这是并发写 slice 安全的少数场景之一------前提是长度预分配好、各 goroutine 下标互不相同。

要让取消真正生效,子任务里的阻塞操作必须监听 ctx。HTTP 用 NewRequestWithContext,数据库用 QueryContext,自己的循环用 select { case <-ctx.Done(): return ctx.Err() ... }。如果下游不吃 ctx,cancel 就是一纸空文,任务照样跑到底。

用 SetLimit 限流,别一次开几千个 goroutine

如果 urls 有几千个,上面的写法会瞬间开几千个 goroutine 和连接,把下游打挂。Go 1.20 起 errgroup 内置了 SetLimit,限制同时运行的子任务数:

go 复制代码
func fetchAll(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10) // 最多 10 个 goroutine 并发

    for _, u := range urls {
        u := u
        g.Go(func() error { // 超过 10 个时,g.Go 会阻塞直到有空位
            return fetchWithCtx2(ctx, u)
        })
    }
    return g.Wait()
}

SetLimit(n) 之后,g.Go 在并发数达到上限时会阻塞 ,直到有子任务结束腾出名额。这等价于自己维护一个容量为 n 的信号量 channel,但代码干净得多。注意 SetLimit 必须在任何 g.Go 之前调用,中途改会 panic。

如果你不想让 g.Go 阻塞,而是希望「没名额就跳过」,可以用 TryGo,它返回 bool 表示是否成功启动。

一个真实的坑:漏传 ctx 导致取消失效

最常见的翻车是这样:用了 WithContext 拿到新 ctx,却在子任务里图省事用了外层的旧 ctx 或 context.Background()

go 复制代码
g, ctx := errgroup.WithContext(parentCtx)
g.Go(func() error {
    // 错误:用了 parentCtx,errgroup 的 cancel 传不进来
    return fetchWithCtx(parentCtx, u)
})

errgroup.WithContext 返回的 ctx 才是挂了 cancel 钩子的那个。子任务必须用这个新 ctx,取消才会传播。记住口诀:g, ctx := errgroup.WithContext(...) 之后,组内一律用左边这个新 ctx

小结

  • 并发子任务要「收敛第一个错误」,别再手写 WaitGroup + 锁,用 errgroup.Group
  • 需要「一个失败就停掉其他」时用 errgroup.WithContext,并把返回的新 ctx 透传给每个子任务的阻塞调用。
  • 任务数很大时用 SetLimit(n) 限流,避免 goroutine 与连接爆炸;要非阻塞就用 TryGo
  • 各 goroutine 写不同 slice 下标可以免锁,但共享变量仍要自己保护。
  • 一句话记忆点:errgroup = WaitGroup + 第一个错误 + 自动取消 + 限流,但取消能不能生效,全看你有没有把新 ctx 传下去。
相关推荐
Chester_19994 小时前
CSP202203C.计算资源调度器
开发语言·数据结构·c++·蓝桥杯
2601_962055975 小时前
跟据spring boot版本,查看对应的tomcat,并查看可支持的tomcat的版本范围
spring boot·后端·tomcat
EXI-小洲6 小时前
Java 操作 Word:字符串替换、图片插入、动态生成表格与API接口下载
java·开发语言·spring boot·word
会周易的程序员6 小时前
给 PLC 写一个字节码虚拟机:STVM 虚拟机架构设计
开发语言·c++·虚拟机·软plc·iec61131·stvm
机器视觉知识推荐、就业指导6 小时前
为什么老说“纯 Qt 没啥就业市场”?
开发语言·qt
telepan6 小时前
Qt 开发避坑与性能指南:如何优雅、安全地定义全局常量字符串?
开发语言·qt
m0_695251496 小时前
Qt MaintenanceTool 使用国内镜像加速下载(超详细教程)
开发语言·qt
Lost of 程序猿7 小时前
ASP.NET Core API 幂等性设计深度实战:从一次重复申领事故说起
后端·asp.net
大衛說8 小时前
《Python 从入门到精通》系列总览与学习路线
开发语言·python·学习
QQ_21696290968 小时前
【源码编号:project79475】SpringBoot校内二手交易平台:商品发布、分类检索、留言交流、订单管理全流程实战
java·spring boot·后端