Go怎么用errgroup做并发错误处理?

文章导读
errgroup 是 Go 官方扩展库提供的并发错误处理工具,适合同时启动多个 goroutine、并且在任意一个失败时尽早取消其余任务的场景。但用不好容易留下 panic 导致进程崩溃、context 信号传不下去、或者收不到完整错误。下面根据实际排查中碰到的问题整理几条处理路径和检查点。
📋 目录
  1. 先确认是不是真的需要 errgroup
  2. 使用场景与基本用法
  3. 创建 Group 与 Context 传递
  4. 常见陷阱:Panic 与错误传播
  5. 改完后看这几个信号
  6. 边界:并发控制和动态任务
A A

errgroup 是 Go 官方扩展库提供的并发错误处理工具,适合同时启动多个 goroutine、并且在任意一个失败时尽早取消其余任务的场景。但用不好容易留下 panic 导致进程崩溃、context 信号传不下去、或者收不到完整错误。下面根据实际排查中碰到的问题整理几条处理路径和检查点。

先确认是不是真的需要 errgroup

errgroup 适用于任务数量确定的并发批处理,比如批量拉取多个 API、并行处理一批文件。如果任务数量不确定、动态生成,或者需要更灵活的并发限流和错误收集,errgroup 可能不够,需要自己用 channel 组合 sync.WaitGroup。另外 errgroup 本身不限制并发 goroutine 数量,所有通过 g.Go 提交的任务会立即启动,如果任务数量很大(比如上千个),需要自行控流,否则可能耗尽系统资源。

使用场景与基本用法

(以下直接引用素材原文)
errgroup 适用于需要并发执行多个任务,且当任意一个任务失败时,希望尽早取消其他任务的场景。它基于 sync.WaitGroup 并整合了 context 的取消机制。使用时需调用 errgroup.WithContext 创建 Group 和派生 context,然后通过 g.Go 提交任务,最后用 g.Wait 等待所有任务完成并获取第一个非 nil 错误。如果所有任务成功返回 nil,否则返回第一个出现的错误。

常见的错误:“任务失败后发现其他 goroutine 还在跑”。这是因为创建的派生 context 没有被传递给子任务,或者子任务没有监听 context 取消信号。解决办法是确保每个 g.Go 内部使用的 context 都是从 WithContext 返回的 ctx 传下来的,并且在关键阻塞操作(如 http.Get、数据库查询、文件 I/O)中传入该 ctx,或者手动检查 ctx.Err() 来提前退出。例如:

g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
    url := url
    g.Go(func() error {
        // 使用 ctx 进行可取消的网络请求
        req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        defer resp.Body.Close()
        // 处理响应...
        return nil
    })
}
if err := g.Wait(); err != nil {
    log.Fatalf("任务失败: %v", err)
}

创建 Group 与 Context 传递

(以下直接引用素材原文)
通过 errgroup.WithContext(ctx) 创建 Group 时,返回的 ctx 是带有取消信号的派生 context。这个 ctx 需要显式传递给各个子任务,以便在任意任务返回错误后,其他任务能感知到取消。典型做法是在 g.Go 内部的函数里使用该 ctx 执行网络请求或数据库查询,并检查 ctx.Err() 提前退出。如果不传递 context,则取消信号无法传播,可能导致资源浪费。

Go怎么用errgroup做并发错误处理?

检查点:在 g.Wait 返回后,可以用 select { case <-ctx.Done(): ... } 判断是否发生了取消,但不做额外判断也不要紧,因为 g.Wait 返回非 nil 就说明出错了。注意不要在任务外部错误处理时又调用 ctx 的 cancel 函数——errgroup 内部已经自动调用 cancel 了,重复调用不会导致问题,但可能混淆逻辑。

常见陷阱:Panic 与错误传播

(以下直接引用素材原文)
g.Go 内部不会自动 recover panic,一旦某个 goroutine 发生 panic,整个进程都会崩溃。因此需要在提交的函数内显式使用 defer recover 来捕获 panic,并将其包装为 error 返回。另外,如果任务内部使用了延迟资源释放(如关闭文件),应确保在函数返回前执行,否则因取消而提前退出可能造成泄漏。一个常见错误是直接返回 nil 而不检查 context 是否已取消,这可能导致错误被吞没。

处理 panic 的典型模式:

Go怎么用errgroup做并发错误处理?
g.Go(func() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()
    // 任务逻辑...
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()  // 别直接返回 nil
        default:
        }
        // 处理数据...
    }
    return nil
})

注意:如果任务内部有嵌套的 goroutine,需要在每个 goroutine 里都做 recover,errgroup 只管理当前函数内部的 panic,不会跨 goroutine 捕获。

改完后看这几个信号

  • 日志中出现 unhandled panic:说明任务里漏了 recover,检查所有 g.Go 的函数体,必须在顶层函数加 defer recover。
  • g.Wait 返回 nil 但有些任务没执行完:检查 context 是否被正确传递,以及任务内是否检查了 ctx.Done()。如果任务没有监听取消,它会继续执行直到完成,但结果会被 g.Wait 忽略(只要没返回错误),这样可能浪费资源。
  • goroutine 数量持续增长不下降:errgroup 不会主动杀死 goroutine,它只通过 context 取消信号提示。如果任务内阻塞调用没有响应 cancel(比如使用标准库中不支持 context 的旧版函数),goroutine 会一直阻塞到超时或完成。建议所有可能阻塞的地方都使用支持 context 的版本。
  • 第一个错误被覆盖:errgroup 内部使用 sync.Once 只记录第一个非 nil 错误,后续的错误被丢弃。如果需要收集全部错误,可以在任务内自己收集到 slice 中,并加锁,最后在外部统一处理。例如:
var mu sync.Mutex
var errs []error
g.Go(func() error {
    // ...
    if err != nil {
        mu.Lock()
        errs = append(errs, err)
        mu.Unlock()
        return err
    }
    return nil
})
// 之后可以遍历 errs

边界:并发控制和动态任务

如果任务数量过大(比如几千个 URL),直接 g.Go 会瞬间创建大量 goroutine,可能耗尽内存或触发系统限制。可以通过 buffered channel 作为信号量来限制并发数:

sem := make(chan struct{}, 10) // 最大并发 10
for _, url := range urls {
    url := url
    g.Go(func() error {
        sem <- struct{}{}
        defer func() { <-sem }()
        // 执行任务...
        return nil
    })
}

注意这种写法仍然会立即启动所有 goroutine,只是它们会卡在发送 sem 上。如果任务数太多,建议使用 worker pool 模式,或者用其他库(如 errgroup 的限定并发版本)。如果任务流是动态生成的(比如不断有新的任务加入),errgroup 不太适合,应该考虑用 channel 配合 WaitGroup 自己实现生产消费模式。

最后,errgroup 适合绝大多数常见的并发请求批处理场景,只要注意 context 传递、panic 保护和资源释放,用起来很顺手。如果发现 goroutine 泄漏或错误被吞,优先检查任务内是否监听了 ctx.Done(),以及 recover 是否写在了正确的位置。