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 完成的状态变化过程:
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.WaitGroup | errgroup |
|---|---|---|
| 错误处理 | 需手动收集 | 自动返回第一个错误 |
| 取消传播 | 不支持 | 通过 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 同步原语选型决策
下图帮助你在不同场景下选择合适的同步原语:
4.6 性能对比与最佳实践
| 原语 | 典型开销(单次操作) | 适用并发规模 | 核心优化点 |
|---|---|---|---|
| WaitGroup | ~10-20 ns | 10-10,000 | 减少 Add/Done 调用次数,避免频繁跨核同步 |
| Once | ~2-5 ns(fast path) | 任意 | 确保 Do 闭包轻量,避免在 Do 内调用 Do |
| Cond | ~50-200 ns | 10-1,000 | 优先使用 channel 替代,Cond 适用于复杂条件 |
| errgroup | ~20-50 ns | 10-10,000 | 合理设置 SetLimit,避免 goroutine 风暴 |
性能建议:
- 优先使用
channel实现简单同步,避免 Cond 的复杂性。 - errgroup 的
SetLimit可有效控制 goroutine 数量,防止资源耗尽。 - WaitGroup 的
Add和Done应成对出现,使用defer确保执行。 - Once 的 fast path 极快,适合高频调用的单例场景。
4.7 本章小结
- WaitGroup:最基础的 goroutine 等待机制,注意 Add 时机和 Done 的 defer 使用。
- Once:保证函数只执行一次,性能极优,适合单例和懒加载。
- Cond:条件变量,适用于复杂等待/通知场景,需配合 for 循环检查条件。
- errgroup:带错误传播和取消的 WaitGroup 增强版,适合批量任务编排。
- 根据场景选择合适的同步原语,避免过度设计或性能浪费。