4、并发错误处理模式:errgroup的使用,WaitGroup与错误收集,select多路复用中的错误处理
在分布式中间件接入链路中,多个并发子任务可能各自失败,而主调方需要聚合错误、感知首个错误或选择性处理。Go 标准库与扩展库提供了三种核心模式:errgroup、sync.WaitGroup + 错误通道、select 多路复用。本章逐一拆解其适用场景、实现细节与常见陷阱。
4.1 errgroup:带错误传播的并发编排
errgroup(golang.org/x/sync/errgroup)是 Go 并发错误处理的首选模式。它在 sync.WaitGroup 基础上增加了错误收集与取消传播能力。
4.1.1 核心接口与语义
| 方法 | 行为 | 关键点 |
|---|---|---|
g.Go(f func() error) | 启动一个 goroutine 执行 f | 内部调用 go;若 f 返回非 nil 错误,errgroup 会记录第一个错误 |
g.Wait() error | 等待所有 goroutine 结束,返回第一个非 nil 错误 | 若所有任务成功则返回 nil;只保留首个错误,后续错误被丢弃 |
g.SetLimit(n int) | 限制最大并发 goroutine 数量 | 超过 n 时 Go 会阻塞;n ≤ 0 表示无限制 |
g.TryGo(f func() error) bool | 尝试启动 goroutine,若已达并发上限则返回 false | 非阻塞版本,适合限流场景 |
关键设计:errgroup 内部使用
sync.WaitGroup 等待所有 goroutine 完成,并通过 sync.Once 保证只记录第一个错误。若需要收集所有错误,必须自行扩展。
4.1.2 基础用法:健康检查聚合
import (
"golang.org/x/sync/errgroup"
)
func checkEndpoints(urls []string) error {
var g errgroup.Group
for _, url := range urls {
url := url // 捕获循环变量
g.Go(func() error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("check %s: %w", url, err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("check %s: status %d", url, resp.StatusCode)
}
return nil
})
}
// 等待所有检查完成,返回第一个错误
return g.Wait()
}
4.1.3 带取消传播的 errgroup
使用 errgroup.WithContext(ctx) 创建时,一旦任意 goroutine 返回错误,errgroup 会自动调用 cancel(),通知其他 goroutine 停止工作。这是熔断降级的典型实现基础。
func fetchWithTimeout(ctx context.Context, services []string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
results := make([]string, len(services))
for i, svc := range services {
i, svc := i, svc
g.Go(func() error {
data, err := callService(ctx, svc)
if err != nil {
return err // 返回错误会触发全局取消
}
results[i] = data
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
实践建议:在中间件链路中,errgroup + WithContext 是“快速失败”的首选模式。当某个下游服务超时或返回 5xx 时,应尽早取消其他无关请求,节省资源。
4.2 WaitGroup + 错误通道:收集所有错误
当需要收集全部错误(而非只取第一个)时,errgroup 无法满足需求。此时应使用 sync.WaitGroup 配合带缓冲的错误通道。
4.2.1 模式结构
func collectAllErrors(tasks []func() error) []error {
var wg sync.WaitGroup
errCh := make(chan error, len(tasks)) // 缓冲通道,避免阻塞
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
if err := task(); err != nil {
errCh <- err
}
}()
}
wg.Wait()
close(errCh) // 关闭通道,通知接收方结束
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errs
}
4.2.2 与 errgroup 的对比
| 维度 | errgroup | WaitGroup + 错误通道 |
|---|---|---|
| 错误收集策略 | 只保留第一个错误 | 收集所有错误 |
| 取消传播 | 内置(WithContext) | 需手动实现 |
| 并发限制 | 内置 SetLimit | 需自行控制 |
| 适用场景 | 快速失败、熔断 | 批量校验、审计、日志聚合 |
| 代码简洁度 | 高 | 中 |
常见陷阱:使用 WaitGroup 时,
wg.Add 必须在 go 之前调用,否则可能出现竞态条件。错误通道必须使用缓冲通道或确保接收方在发送前已就绪,否则会导致 goroutine 泄漏。
4.3 select 多路复用中的错误处理
在 select 语句中处理多个 channel 时,错误通常通过专用错误通道或结果结构体传递。这是构建超时控制、优雅关闭、多源聚合的基础。
4.3.1 专用错误通道模式
func selectWithError(ctx context.Context, ch1, ch2 <-chan int) (int, error) {
errCh := make(chan error, 1) // 用于传递异步错误
// 启动一个 goroutine 监听外部错误源
go func() {
select {
case <-ctx.Done():
errCh <- ctx.Err()
}
}()
select {
case val := <-ch1:
return val, nil
case val := <-ch2:
return val, nil
case err := <-errCh:
return 0, err
case <-time.After(3 * time.Second):
return 0, fmt.Errorf("select timeout")
}
}
4.3.2 结果结构体模式(推荐)
将数据和错误封装在同一个结构体中,通过单一 channel 传递,避免多 channel 竞争。
type Result struct {
Value int
Err error
}
func selectWithResult(ctx context.Context, ch1, ch2 <-chan Result) (int, error) {
select {
case r := <-ch1:
return r.Value, r.Err
case r := <-ch2:
return r.Value, r.Err
case <-ctx.Done():
return 0, ctx.Err()
}
}
模式选择:在中间件链路中,推荐使用结果结构体模式。它减少了 channel 数量,避免 select 语句中因多个 channel 同时就绪导致的随机选择问题,且错误与数据天然绑定,语义清晰。
4.4 三种模式的选型决策
4.5 实战:中间件链路中的错误聚合
以下示例展示一个熔断降级中间件如何组合使用三种模式:
type CircuitBreakerMiddleware struct {
halfOpenLimit int
timeout time.Duration
}
func (m *CircuitBreakerMiddleware) BatchCall(ctx context.Context, endpoints []string) ([]string, error) {
// 使用 errgroup 实现快速失败
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(m.halfOpenLimit)
results := make([]string, len(endpoints))
for i, ep := range endpoints {
i, ep := i, ep
g.Go(func() error {
// 内部使用 select 实现超时控制
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
data, err := callEndpoint(ctx, ep)
if err != nil {
errCh <- err
return
}
resultCh <- data
}()
select {
case res := <-resultCh:
results[i] = res
return nil
case err := <-errCh:
return err
case <-ctx.Done():
return ctx.Err()
case <-time.After(m.timeout):
return fmt.Errorf("endpoint %s timeout", ep)
}
})
}
if err := g.Wait(); err != nil {
// 若需要收集所有错误,可改用 WaitGroup + 错误通道
return nil, err
}
return results, nil
}
模式组合:errgroup 负责并发编排与取消传播,内部每个 goroutine 使用 select 处理超时与错误,外层通过 errgroup.Wait 获取首个错误。这种组合在熔断降级中间件中最为常见。
4.6 常见陷阱与避坑指南
| 陷阱 | 表现 | 解决方案 |
|---|---|---|
| errgroup 只返回第一个错误 | 后续错误丢失 | 需要全量错误时改用 WaitGroup + 错误通道 |
| WaitGroup 中 wg.Add 位置错误 | 竞态条件、panic | wg.Add(1) 必须在 go 之前调用 |
| select 中多个 channel 同时就绪 | 随机选择,不符合预期 | 使用结果结构体合并到单一 channel |
| 错误通道未缓冲 | goroutine 泄漏 | 使用缓冲通道,容量等于 goroutine 数量 |
| errgroup 未使用 WithContext | 取消信号不传播 | 始终使用 errgroup.WithContext 创建 |