4. WaitGroup 与同步原语:并发编排的核心工具

在 Go 并发编程中,同步原语是协调 goroutine 执行顺序、等待任务完成、避免竞态条件的基石。本章深入剖析 sync.WaitGroup、sync.Once、sync.Cond 以及扩展库 errgroup 的实战模式与性能要点。

4.1 sync.WaitGroup:goroutine 完成等待

sync.WaitGroup 是 Go 中最常用的同步原语,用于等待一组 goroutine 全部完成。其核心机制是一个内部计数器:Add(delta) 增加计数,Done() 减少计数,Wait() 阻塞直到计数归零。

4.1.1 标准使用模式

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        // 执行任务
        fmt.Printf("goroutine %d done\n", id)
    }(i)
}
wg.Wait()
fmt.Println("all goroutines finished")
最佳实践:Add 调用必须在启动 goroutine 之前或紧邻启动处,避免在 goroutine 内部调用 Add 导致 Wait 提前返回。推荐在循环中先 wg.Add(1) 再 go func()。

4.1.2 常见陷阱与性能调优

陷阱后果解决方案
Add 放在 goroutine 内部竞态条件,Wait 可能提前结束在启动 goroutine 前调用 Add
忘记调用 Done永久阻塞,goroutine 泄漏使用 defer wg.Done()
计数器负值panic确保 Done 调用次数不超过 Add 总数
大量 goroutine 共享一个 WaitGroup高并发下 Add/Done 竞争激烈按逻辑分组,使用多个 WaitGroup

4.1.3 WaitGroup 内部状态流转

下图展示了 WaitGroup 从创建到所有 goroutine 完成的状态变化过程:

sync.WaitGroup 状态流转图 初始状态 计数器 = 0 Add(N) 计数器 += N 运行中 计数器 > 0 Done() 计数器 -= 1 完成状态 计数器 = 0 Wait() 阻塞 等待计数器归零 关键点:Wait() 在计数器归零时立即返回;计数器不可重用(Go 1.x 设计如此)。 Add 必须在 goroutine 启动前调用,Done 通常配合 defer 使用。

4.2 sync.Once:只执行一次

sync.Once 保证某个函数在整个程序生命周期中只被执行一次,常用于单例初始化、懒加载、配置加载等场景。其内部使用原子操作和互斥锁实现,性能开销极低。

var (
    once     sync.Once
    config   *Config
)

func GetConfig() *Config {
    once.Do(func() {
        config = loadConfig() // 只执行一次
    })
    return config
}
性能特性:sync.Once 在首次调用后,后续调用仅执行一次原子读(fast path),开销约 2-5 ns。相比每次加锁检查,性能提升 10-100 倍。

4.2.1 常见误用

  • 在 Do 内部调用自身:导致死锁,因为 Once 内部持有锁。
  • 依赖 Do 的返回值:Do 不返回错误,错误处理需在闭包内完成。
  • 重置 Once:Once 不可重置,如需重新初始化应创建新实例。

4.3 sync.Cond:条件变量

sync.Cond 用于 goroutine 在满足特定条件前等待,条件满足时被唤醒。它通常与互斥锁配合使用,实现生产者-消费者、任务队列等模式。

4.3.1 核心方法

方法作用注意事项
Wait()释放锁并阻塞,被唤醒后重新获取锁必须在锁保护下调用
Signal()唤醒一个等待的 goroutine无等待者时无操作
Broadcast()唤醒所有等待的 goroutine适用于批量通知场景
var (
    mu      sync.Mutex
    cond    = sync.NewCond(&mu)
    queue   []int
)

func Producer() {
    for i := 0; i < 10; i++ {
        mu.Lock()
        queue = append(queue, i)
        cond.Signal() // 唤醒一个消费者
        mu.Unlock()
    }
}

func Consumer() {
    for {
        mu.Lock()
        for len(queue) == 0 {
            cond.Wait() // 等待条件满足
        }
        item := queue[0]
        queue = queue[1:]
        mu.Unlock()
        fmt.Println("consumed:", item)
    }
}
重要:使用 for 循环检查条件,而非 if。因为 Wait 返回后条件可能已被其他 goroutine 改变(虚假唤醒)。

4.4 errgroup:带错误传播的 WaitGroup

golang.org/x/sync/errgroup 扩展了 WaitGroup 的功能:当任意 goroutine 返回错误时,取消其他 goroutine 并返回第一个非 nil 错误。它通过 context.Context 实现取消传播。

4.4.1 基本用法

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(context.Background())

for _, url := range urls {
    url := url // 捕获循环变量
    g.Go(func() error {
        resp, err := http.Get(url)
        if err != nil {
            return err
        }
        defer resp.Body.Close()
        // 处理响应
        return nil
    })
}

if err := g.Wait(); err != nil {
    log.Printf("one of goroutines failed: %v", err)
}

4.4.2 errgroup 与 WaitGroup 对比

特性sync.WaitGrouperrgroup
错误处理需手动收集自动返回第一个错误
取消传播不支持通过 context 自动取消
goroutine 数量限制无可通过 SetLimit 设置
适用场景简单等待所有完成需要错误处理和取消的批量任务

4.4.3 带并发限制的 errgroup

g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(10) // 最多 10 个并发 goroutine

for _, task := range tasks {
    task := task
    g.Go(func() error {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            return process(task)
        }
    })
}
if err := g.Wait(); err != nil {
    log.Printf("task failed: %v", err)
}

4.5 同步原语选型决策

下图帮助你在不同场景下选择合适的同步原语:

Go 同步原语选型决策树 需要同步什么? 等待 goroutine 完成 条件满足时唤醒 只执行一次 需要错误处理? sync.Cond sync.Once 是 → errgroup 否 → WaitGroup

4.6 性能对比与最佳实践

原语典型开销(单次操作)适用并发规模核心优化点
WaitGroup~10-20 ns10-10,000减少 Add/Done 调用次数,避免频繁跨核同步
Once~2-5 ns(fast path)任意确保 Do 闭包轻量,避免在 Do 内调用 Do
Cond~50-200 ns10-1,000优先使用 channel 替代,Cond 适用于复杂条件
errgroup~20-50 ns10-10,000合理设置 SetLimit,避免 goroutine 风暴
性能建议:
  1. 优先使用 channel 实现简单同步,避免 Cond 的复杂性。
  2. errgroup 的 SetLimit 可有效控制 goroutine 数量,防止资源耗尽。
  3. WaitGroup 的 Add 和 Done 应成对出现,使用 defer 确保执行。
  4. Once 的 fast path 极快,适合高频调用的单例场景。

4.7 本章小结

  • WaitGroup:最基础的 goroutine 等待机制,注意 Add 时机和 Done 的 defer 使用。
  • Once:保证函数只执行一次,性能极优,适合单例和懒加载。
  • Cond:条件变量,适用于复杂等待/通知场景,需配合 for 循环检查条件。
  • errgroup:带错误传播和取消的 WaitGroup 增强版,适合批量任务编排。
  • 根据场景选择合适的同步原语,避免过度设计或性能浪费。