1. 概念与准备
errgroup 的定位与使用场景
在Golang 并发任务的实际应用中,errgroup提供了一种简洁的方式来对一组协程进行错误聚合与取消协作。通过将多个任务统一提交到一个组中,可以在任意一个任务返回错误时,自动传递取消信号并尽快结束其它未完成的任务,提升系统的鲁棒性与响应性。本文聚焦于 Golang 并发任务:如何用 errgroup 收集协程结果?实战指南这一主题,帮助你从理论到代码落地。
在开始编码前,理解几个关键点很重要:errgroup并不直接返回每个子任务的结果,它更像一个错误与取消的容器。要实现“收集协程结果”的能力,通常需要将结果通过共享变量、通道或其它并发安全的结构来回传或聚合。把握这一点,你就能在不牺牲并发度的前提下,得到完整的执行视图。
2. errgroup 的核心机制与 API
核心方法与并发取消
errgroup 的核心在于 Group 结构体与 Go()、Wait() 两个主要 API。g.Go(func() error { ... }) 用于提交一个任务,返回一个错误用于聚合;g.Wait() 等待所有任务结束并返回其中任意一个任务的错误(若全部成功则返回 nil)。这是实现“错误第一”的并发控制的基础。
为了在发生错误时并发地取消其它任务,通常会结合上下文 context 使用。通过 errgroup.WithContext(ctx) 可以获得一个衍生的上下文 ctx,当任一子任务返回非 nil 错误时,ctx.Done() 将被关闭,其他子任务可以监听该信号提前退出。
3. 实战场景:如何收集协程结果
方案一:共享切片 + 互斥锁
在某些场景下,直接将结果写入到一个预先分配好的切片是最直观的做法。每个子任务维护自己的下标写入到结果数组,主协程在 Wait() 结束后处理聚合后的结果。需要注意的是写入切片的动作必须通过互斥锁保护,避免并发写入造成数据竞争。
以下示例演示了通过 errgroup 与互斥锁来收集多个任务的结果。你可以把任务看作是对外部资源的访问、数据处理或网络请求等。
package mainimport ("context""fmt""sync""time""golang.org/x/sync/errgroup"
)func main() {// 待处理的任务标识,例如 URLs 或任务编号items := []string{"task-A", "task-B", "task-C"}results := make([]string, len(items))// 用于保护对 results 的并发写入var mu sync.Mutex// errgroup 与带上下文的取消g, _ := errgroup.WithContext(context.Background())for i, it := range items {idx := iitem := itg.Go(func() error {// 模拟工作负载time.Sleep(100 * time.Millisecond)// 将结果写回共享结构,需加锁保护mu.Lock()results[idx] = "processed: " + itemmu.Unlock()// 返回 nil 表示该任务成功完成return nil})}if err := g.Wait(); err != nil {fmt.Println("遇到错误:", err)return}fmt.Println("聚合结果:", results)
}在这个示例中,结果写入是通过互斥锁保护的,确保多个并发写操作不会冲突;同时通过 g.Wait() 等待所有任务完成并聚合结果。此模式简单直观,适合结果数据量不大、写入冲突容易控制的场景。
方案二:结果通道(推荐在高并发场景下的解耦方式)
当每个任务需要输出大量或不确定结构的结果时,用通道作为“产出—消费”的解耦手段往往更稳健。子任务将结果写入一个缓冲通道,由单独的协程消费并组装最终的结果。借助 ctx 的取消信号,可以在任一任务失败时关闭通道,停止后续的处理。
下面的代码展示了如何使用通道来收集协程结果,并在 errgroup 取消时安全关闭通道。
package mainimport ("context""fmt""time""golang.org/x/sync/errgroup"
)type Result struct {Index intData string
}func main() {items := []string{"task-1", "task-2", "task-3"}results := make([]string, len(items))ch := make(chan Result, len(items))g, ctx := errgroup.WithContext(context.Background())for i, item := range items {idx := iit := itemg.Go(func() error {// 模拟一个耗时操作select {case <-time.After(120 * time.Millisecond):ch <- Result{Index: idx, Data: "done: " + it}// 返回 nil 表示任务成功return nilcase <-ctx.Done():// 取消后提前退出return ctx.Err()}})}// 在一个单独的协程中等待所有任务结束后关闭通道go func() {_ = g.Wait()close(ch)}()// 从通道消费结果并聚合for r := range ch {results[r.Index] = r.Data}// 使用聚合后的 results(实际应用中你可能直接处理或返回)fmt.Println("聚合结果:", results)
}通过这种模式,结果的生产与消费解耦,在高并发下更容易扩展。你可以把结果编码成结构化对象,或者直接序列化输出到文件或数据库。
4. 错误处理与取消逻辑
错误聚合与上下文取消
在实际使用中,最关键的需求之一是在任一任务返回错误时快速取消其他任务,从而避免资源浪费或对外部系统产生压力。使用 errgroup.WithContext 获取的上下文是实现这一点的关键工具。ctx.Done() 提供了一个统一的取消信号,所有在组内提交的任务都可以监听该信号并尽快退出。

另外,当一个任务失败时,g.Wait() 会返回该错误。此时你可以决定是否对外暴露该错误,或在记录日志后继续优雅退出。 错误处理策略应与系统的容错级别、幂等性设计相匹配。
5. 性能要点与调优
并发度控制与资源限制
为了避免对系统造成突发的压力,合理控制并发度是必选项。尽管 errgroup 能同时提交大量任务,但实际执行数量应结合系统资源、网络带宽以及目标服务的承载能力来确定。常见做法包括:设定一个固定的工作队列、使用带缓冲的通道、或利用信号量对并发度进行限流。
在设计阶段,建议先以低并发测试,再逐步提升到生产水平,以观察瓶颈是否来自 CPU、网络、磁盘 I/O 还是数据库并发连接数等外部资源。 监控指标如 RPS、错误率、任务延迟等,是评估并发策略是否有效的关键。
6. 常见坑点与调试技巧
常见坑点
最常见的问题包括:没有正确保护共享状态导致数据竞争,没有考虑取消导致子任务仍在跑,以及在错误发生后没有正确清理资源。通过将共享状态的写入、通道消费等关键路径用 go test 和 race 检测,可以提前发现并修正。
另一个坑点是对 上下文取消的误解:取消并不等同于强制中断某个具体操作,具体的中断需要在子任务中对 ctx.Done() 做显式检查,并在收到取消信号时尽快返回。
本文通过多个实战片段展示了如何在 Golang 并发任务场景中,结合 errgroup 收集协程结果的两种主流模式:共享切片写入与结果通道解耦。你可以据此在自己项目中快速落地,提升并发任务的可观测性与健壮性。


