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 的对比

维度errgroupWaitGroup + 错误通道
错误收集策略只保留第一个错误收集所有错误
取消传播内置(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 三种模式的选型决策

并发错误处理模式选型决策树 并发错误处理 是否需要收集所有错误? 是 → WaitGroup + 错误通道 批量校验、审计、日志聚合 否:是否需要取消传播? 是 → errgroup + WithContext 快速失败、熔断、超时控制 否 → select 多路复用 多源聚合、优雅关闭、超时 选型要点 • 快速失败 + 取消传播 → errgroup.WithContext • 收集全部错误 → WaitGroup + 缓冲错误通道 • 多 channel 竞争 + 超时 → select + Result 结构体 • 混合场景:errgroup 内嵌 select 处理子任务

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 位置错误竞态条件、panicwg.Add(1) 必须在 go 之前调用
select 中多个 channel 同时就绪随机选择,不符合预期使用结果结构体合并到单一 channel
错误通道未缓冲goroutine 泄漏使用缓冲通道,容量等于 goroutine 数量
errgroup 未使用 WithContext取消信号不传播始终使用 errgroup.WithContext 创建