golang,go,博客,开源,编程

errgroup详解

Updated on with 0 views and 0 comments

Go 中的 errgroup 包详解

Golang 的扩展并发库 golang.org/x/sync/errgroup 提供了对多协程任务进行管理和错误处理的便利功能。

与基础的 sync.WaitGroup 相比,errgroup.Group 在等待所有任务完成的同时,还会自动捕获第一个非 nil 错误并返回。如果通过 WithContext 创建 Group,当任一子任务返回错误时,errgroup 会取消关联的 Context,从而通知其他协程提前退出。

简言之,errgroup 封装了错误传播、上下文取消和并发控制等功能,使并发编程更加简洁易用。

基本用法

使用 errgroup 时,首先需要创建一个 Group 实例。可以直接用零值初始化:var g errgroup.Group,或者调用 errgroup.WithContext(ctx) 同时获取一个基于 ctx 派生的新 Context。典型的用法是对每个并发任务调用 g.Go(func() error) 来启动 goroutine。Group.Go 方法内部会自动执行 WaitGroup.Add(1),并在函数返回时执行 Done()。最后使用 g.Wait() 等待所有任务完成:若有任务返回了错误,Wait 会返回该第一个非 nil 错误,否则返回 nil。例如:

import (
    "context"
    "fmt"
    "net/http"
    "golang.org/x/sync/errgroup"
    "io/ioutil"
)

func main() {
    // 创建带取消功能的 errgroup 和 Context
    g, ctx := errgroup.WithContext(context.Background())

    urls := []string{
        "https://example.com",
        "https://example.org",
        "https://example.net",
    }
    // 用于收集响应结果的切片
    results := make([]string, len(urls))

    for i, url := range urls {
        i, url := i, url  // 捕获循环变量
        g.Go(func() error {
            // 在请求中使用 errgroup 的 Context,以支持取消
            req, _ := http.NewRequest("GET", url, nil)
            req = req.WithContext(ctx)
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return err
            }
            defer resp.Body.Close()
            data, _ := ioutil.ReadAll(resp.Body)
            results[i] = string(data)
            return nil
        })
    }

    // 等待所有并发任务完成
    if err := g.Wait(); err != nil {
        fmt.Println("执行过程中发生错误:", err)
        return
    }
    fmt.Println("所有请求完成,结果:", results)
}

此外,errgroup.Group 提供了并发限制的功能。可以使用 g.SetLimit(n) 设置最多同时运行的协程数,配合 g.TryGo(f) 在不超限时才启动任务。例如:

var g errgroup.Group
g.SetLimit(5)  // 限制最多 5 个并发 goroutine

for i := 0; i < 10; i++ {
    g.TryGo(func() error {
        // 模拟任务
        fmt.Println("任务执行")
        return nil
    })
}

if err := g.Wait(); err != nil {
    fmt.Println("执行出错:", err)
} else {
    fmt.Println("所有任务成功完成")
}

示例

  • 并发执行多个 HTTP 请求并收集结果: 在多个 API 请求或微服务调用场景下,可以用 errgroup 并发发送请求,并在所有请求完成后统一处理结果。例如上面的代码示例即同时请求多个 URL,并把结果保存在 results 切片中。如果任意一个请求出错,Wait() 会返回错误(并在使用 WithContext 时取消其他请求)。

  • 遇到错误立即取消: 如果需要在一旦发现错误就中止其他任务的执行,可通过 WithContext 创建带取消信号的组。在下面示例中,第一个任务在 2 秒后返回错误,errgroup 会取消关联的 ctx,使第二个任务立刻退出等待并返回 ctx.Err()

    import (
        "context"
        "fmt"
        "time"
        "golang.org/x/sync/errgroup"
    )
    
    func main() {
        g, ctx := errgroup.WithContext(context.Background())
        // 第一个协程 2s 后返回错误
        g.Go(func() error {
            time.Sleep(2 * time.Second)
            return fmt.Errorf("第一个任务失败")
        })
        // 第二个协程检查 ctx.Done()
        g.Go(func() error {
            select {
            case <-ctx.Done():
                fmt.Println("第二个任务被取消:", ctx.Err())
                return ctx.Err()
            case <-time.After(3 * time.Second):
                fmt.Println("第二个任务完成")
                return nil
            }
        })
        if err := g.Wait(); err != nil {
            fmt.Println("errgroup 返回错误:", err)
        }
    }
    

    运行结果中会看到“第二个任务被取消”和错误信息。通过 WithContext,一旦有任务返回非 nil 错误,关联的 Context 会立即被取消。

  • 超时控制和上下文取消:errgroup 常与 Context 组合使用来实现超时或取消。例如,如果将父上下文设置超时,当时间到达限制后,所有正在等待的协程会收到取消信号。下面示例为两个任务设置 2 秒超时,其中一个任务故意睡眠 3 秒,会被取消:

    import (
        "context"
        "fmt"
        "time"
        "golang.org/x/sync/errgroup"
    )
    
    func main() {
        baseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()
        g, ctx := errgroup.WithContext(baseCtx)
    
        // 长任务:需 3 秒
        g.Go(func() error {
            time.Sleep(3 * time.Second)
            return nil
        })
        // 观察超时信号
        g.Go(func() error {
            select {
            case <-ctx.Done():
                fmt.Println("任务取消:", ctx.Err())
                return ctx.Err()
            }
        })
        if err := g.Wait(); err != nil {
            fmt.Println("errgroup 返回:", err)
        }
    }
    

    当超时触发时,第二个任务检测到 ctx.Done() 并返回 context.DeadlineExceeded。使用 WithContext 和超时上下文可以很方便地控制整个任务组的执行期限。

常见错误

  • 闭包变量捕获:for 循环中直接使用迭代变量启动 goroutine 时,会导致闭包捕获同一个变量,结果通常不符合预期。正确做法是循环内部重新声明变量(例如 v := v)或作为函数参数传入,避免所有协程共享同一个循环变量。例如:
    for _, url := range urls {
        url := url  // 正确捕获
        g.Go(func() error {
            // 使用 url
            return nil
        })
    }
    
  • Context 使用误区: 当用 errgroup.WithContext(ctx) 创建组时,不需要也不应再对同一个 ctx 额外调用 context.WithCancel 等,否则可能导致取消信号影响到外层逻辑。正确的做法是直接传入已有的上下文,WithContext 会返回派生的新 Context 和关联的取消函数。
  • SetLimit 使用不当导致死锁:Group.SetLimit(n) 限制的是组中 持有 协程的数量(包括正在运行和在队列等待的),误用场景下容易出现循环依赖而死锁。尤其当在同一个 errgroup 中嵌套创建更多任务或组时,很可能死锁。最保险的方式是避免嵌套使用同一个 errgroup 实例。另外,将 SetLimit(0) 视为无效设置,会直接导致死锁,因为组里无法再启动任何协程。因此应谨慎使用并发限制,并确保不会使 goroutine 无法继续执行。
  • 错误返回策略:errgroup.Group 只返回第一个发生的非 nil 错误,后续出现的错误会被丢弃。同时,Group.Wait 会调用 sync.WaitGroup.Wait,在返回前会 等待所有 子任务退出。这意味着即使遇到错误,其他未完成的任务也会继续运行(直到收到取消信号退出)。因此在实际使用时,需要对返回的错误进行及时检查,并在必要时主动响应取消信号,让 goroutine 提前结束。

errgroup vs sync.WaitGroup 的区别

  • 错误处理:sync.WaitGroup 只能等待所有协程完成,不会传递错误;而 errgroup.Group 在调用 Wait() 时会返回第一个非 nil 错误。这样可以方便地在有任何子任务出错时处理错误而不必自行同步。
  • 取消支持:errgroup 集成了 context.Context,可以在子任务出错时自动取消其他任务;而 sync.WaitGroup 不涉及上下文和取消逻辑。借助 WithContexterrgroup 能让并发任务响应父上下文的取消信号,避免浪费资源。
  • 并发控制:errgroup 提供了 SetLimitTryGo 等方法,可限制并发 goroutine 的数量。sync.WaitGroup 则只能由用户自行 Add 来记录数量,没有并发池或信号量概念。
  • 使用便捷性:sync.WaitGroup 需要手动调用 Add/Done 不同,errgroup.Group 封装了这些操作,只需调用 Go 即可启动任务。此外,errgroup 还可自动捕获子任务 panic(将其封装为 PanicError 抛出),提高了并发代码的鲁棒性。

应用

在真实项目中,errgroup 常用于在业务逻辑中并行执行若干相互独立的子任务。

例如,在一个 HTTP 服务的处理函数中,可能需要并行调用多个后端服务或查询不同数据源,然后整合结果返回给客户端。

可以这样组织代码:

在请求对应的 Context 上创建一个 errgroup,为每个独立任务调用 g.Go(),并在最后通过 g.Wait() 收集结果或错误。如果某一任务出错,关联的 Context 会自动取消,其它任务会及时退出,从而避免不必要的工作。

举个🌰:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    g, ctx := errgroup.WithContext(ctx)
    var resA, resB string

    // 并行执行两个调用
    g.Go(func() error {
        data, err := serviceA(ctx, ...)
        if err == nil {
            resA = data
        }
        return err
    })
    g.Go(func() error {
        data, err := serviceB(ctx, ...)
        if err == nil {
            resB = data
        }
        return err
    })

    if err := g.Wait(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "Results: %s, %s", resA, resB)
}

总的来说,errgroup 使得 Go 并发编程更易于编写健壮的代码,在出现错误时能够优雅地停止不必要的工作。

注意事项

  • 结合 Context 使用: 通常通过 g, ctx := errgroup.WithContext(ctx) 来创建任务组,确保在某个子任务出错或超时后能够取消其他协程。在每个子任务中,应监听 ctx.Done() 并及时返回,以响应取消信号。
  • 捕获循环变量: 在循环中启动协程时,务必使用 v := v 或函数参数的方式复制循环变量,避免闭包捕获陷阱。这能确保每个 goroutine 拥有正确的参数值。
  • 及时处理取消信号: 在子任务函数内,最好在合适位置检查并响应 ctx.Err()<-ctx.Done(),以便在外部取消后能尽快退出,释放资源。例如,如果任务中有 I/O 操作,可以传递 ctx 给相关 API 来触发取消。
  • 错误检查与日志: 调用 g.Wait() 后,应该检查返回的错误并记录或上报。如果只关注第一个错误,就要明确日志体现是哪一个任务失败;如果需要所有错误信息,则可考虑自行在各个 goroutine 中汇集。总之,不要忽略 Wait() 的返回值。
  • 不要重复创建 errgroup 实例: 每个任务组只应使用一个 errgroup.Group 实例,避免嵌套使用同一个组。若需要多个独立的并发组合任务,应创建多个组实例,以免发生资源冲突或死锁。
  • 代码风格: 建议将每个并发子任务封装为一个返回 error 的函数,使 g.Go() 调用清晰简洁。

标题:errgroup详解
作者:mooncakeee
地址:http://blog.dd95828.com/articles/2025/05/20/1747701267973.html
联系:scotttu@163.com