上一篇我们聊了 Mutex,它解决的是"同一时刻只能有一个 goroutine 访问共享资源"的问题。但并发编程中还有另一类常见需求——等待一组任务全部完成后再继续。这就是 sync.WaitGroup 要解决的问题。
一、为什么需要 WaitGroup?
假设你要并行执行三个子任务,全部完成后才能进入下一步。没有 WaitGroup 的话,你可能会这样写:
// ❌ 轮询方案:又慢又浪费 CPU
done1, done2, done3 := false, false, false
go func() { /* 任务1 */ done1 = true }()
go func() { /* 任务2 */ done2 = true }()
go func() { /* 任务3 */ done3 = true }()
for !done1 || !done2 || !done3 {
time.Sleep(100 * time.Millisecond) // 空转等待
}这种轮询方式有两个问题:
问题 1:响应慢 问题 2:浪费 CPU
任务完成 CPU
──●────────────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
│ 等待轮询 │?│ │?│ │?│ │?│ │?│ ← 反复空问
───────────────● └─┘ └─┘ └─┘ └─┘ └─┘
最多延迟 100ms 全是无效检查WaitGroup 的方案则是:任务没完成就阻塞,全部完成后立即唤醒,零延迟、零空转。
二、WaitGroup 的三个方法
WaitGroup 的 API 同样极简,只有三个方法:
| 方法 | 说明 |
|---|---|
Add(delta int) | 计数器 + delta(通常在启动任务前调用) |
Done() | 计数器 - 1(等价于 Add(-1)) |
Wait() | 阻塞直到计数器归零 |
三者的协作流程:
主 goroutine 子 goroutine 1/2/3
│ │
│── Add(3) ──→ 计数器 = 3 │
│ │
│── go task1() ──────────────────→ │ 执行任务1
│── go task2() ──────────────────→ │ 执行任务2
│── go task3() ──────────────────→ │ 执行任务3
│ │
│── Wait() ──→ 阻塞 │
│ (计数器 > 0) │
│ │── Done() → 计数器 = 2
│ 还在等... │── Done() → 计数器 = 1
│ │── Done() → 计数器 = 0
│ ←── 唤醒! │
│ │
│ 继续执行后续逻辑 │三、基本用法
一个完整的示例——并行抓取三个 URL,全部完成后汇总结果:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
urls := []string{
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/products",
}
wg.Add(len(urls)) // 一次性设置计数器
for _, url := range urls {
go func(u string) {
defer wg.Done() // 任务完成,计数器 -1
fmt.Printf("开始抓取: %s\n", u)
time.Sleep(time.Second) // 模拟网络请求
fmt.Printf("完成抓取: %s\n", u)
}(url)
}
wg.Wait() // 阻塞,直到 3 个任务都 Done
fmt.Println("全部完成,开始汇总")
}输出(顺序可能不同):
开始抓取: https://api.example.com/products
开始抓取: https://api.example.com/users
开始抓取: https://api.example.com/orders
完成抓取: https://api.example.com/orders
完成抓取: https://api.example.com/users
完成抓取: https://api.example.com/products
全部完成,开始汇总三个请求并行执行,总耗时约 1 秒而不是 3 秒。
四、实现原理
WaitGroup 的内部结构其实很精巧,核心就是一个 64 位的状态值和一个信号量:
state(64 bit)
| 高 32 位: counter | 低 32 位: waiter |
|---|---|
| 未完成任务数 | 等待的 goroutine 数 |
sema(信号量) —— 用于阻塞和唤醒 Wait() 的 goroutine
三个方法对应的操作:
Add(delta):
counter += delta ← 原子操作
if counter == 0:
唤醒所有 waiter ← runtime_Semrelease
Done():
Add(-1) ← 就这么简单
Wait():
if counter > 0:
waiter++ ← 原子操作
信号量等待 ← runtime_Semacquire (阻塞)关键点是 counter 和 waiter 打包在一个 64 位整数中,通过 atomic 原子操作来保证并发安全,不需要额外加锁,性能很高。
五、常见的 4 个坑
坑 1:计数器变成负数
Add 的值和 Done 的次数不匹配,计数器变为负数直接 panic:
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // panic: sync: negative WaitGroup counter正确做法:Add 的数量必须等于 Done 的次数,先 Add 再启动 goroutine。
坑 2:Add 放在了 goroutine 内部
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // ❌ 可能还没执行到这里,Wait 就返回了
defer wg.Done()
// ... 任务
}()
}
wg.Wait() // 可能立即返回,因为计数器还是 0主 goroutine 子 goroutine
│ │
│── go func() ────→ │ (还没调度到)
│── go func() ────→ │ (还没调度到)
│── go func() ────→ │ (还没调度到)
│ │
│── Wait() │
│ 计数器=0, 直接返回 │ ← 子 goroutine 还没开始!
│ │
│ 继续执行... 💀 │── Add(1) ← 太晚了正确做法:Add 必须在 go 语句之前调用:
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1) // ✅ 在启动 goroutine 之前
go func() {
defer wg.Done()
// ... 任务
}()
}
wg.Wait()坑 3:复用还没归零的 WaitGroup
WaitGroup 可以复用,但必须等上一轮 Wait 返回后才能开始下一轮 Add:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// 任务 1
}()
// ❌ 上一轮还没 Wait 完就开始新一轮
wg.Add(1) // 可能 panic 或行为未定义正确做法:
wg.Add(1)
go func() { defer wg.Done() }()
wg.Wait() // 等第一轮结束
// ✅ 现在可以安全开始第二轮
wg.Add(1)
go func() { defer wg.Done() }()
wg.Wait()坑 4:忘记 Done 导致永久阻塞
和 Mutex 忘记 Unlock 一样,忘记调用 Done 会导致 Wait 永远不返回:
var wg sync.WaitGroup
wg.Add(2)
go func() {
// 做了一些事
// ❌ 忘记 wg.Done()
}()
go func() {
defer wg.Done()
}()
wg.Wait() // 永久阻塞,计数器停在 1最佳实践:始终用 defer wg.Done() 作为 goroutine 函数体的第一行。
六、进阶用法:WaitGroup + 错误收集
实际项目中,并行任务通常需要收集错误。WaitGroup 本身不处理错误,需要配合其他机制:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
tasks := []func() error{
func() error { return nil },
func() error { return fmt.Errorf("任务2: 连接超时") },
func() error { return nil },
func() error { return fmt.Errorf("任务4: 权限不足") },
}
for i, task := range tasks {
wg.Add(1)
go func(id int, fn func() error) {
defer wg.Done()
if err := fn(); err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("任务%d失败: %w", id+1, err))
mu.Unlock()
}
}(i, task)
}
wg.Wait()
if len(errs) > 0 {
fmt.Println("部分任务失败:")
for _, err := range errs {
fmt.Println(" ", err)
}
} else {
fmt.Println("全部任务成功")
}
}提示:如果你觉得 WaitGroup + Mutex 收集错误太繁琐,可以直接用
golang.org/x/sync/errgroup,它把 WaitGroup、错误收集、context 取消封装到了一起。
七、errgroup:WaitGroup 的升级版
errgroup 是官方扩展库提供的增强版 WaitGroup,用起来更优雅:
package main
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
urls := []string{
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/fail", // 模拟失败
}
for _, url := range urls {
u := url
g.Go(func() error {
// ctx 可以感知其他任务的失败
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if u == "https://api.example.com/fail" {
return fmt.Errorf("请求失败: %s", u)
}
fmt.Printf("完成: %s\n", u)
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("发生错误:", err)
}
}errgroup 相比原生 WaitGroup 的优势:
| 特性 | WaitGroup | errgroup |
|---|---|---|
| 等待全部完成 | ✅ | ✅ |
| 收集错误 | 需手动 + Mutex | ✅ 内置 |
| 任一失败取消其余 | ❌ | ✅ context |
| 限制并发数 | ❌ | ✅ SetLimit |
| 无需手动 Add/Done | ❌ | ✅ g.Go() |
八、WaitGroup vs Channel:怎么选?
Go 中等待多个 goroutine 完成,除了 WaitGroup 还可以用 channel。怎么选?
// 方案 A:WaitGroup(适合"等全部完成")
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
}
wg.Wait()
// 方案 B:Channel(适合"需要收集结果")
results := make(chan int, n)
for i := 0; i < n; i++ {
go func() {
results <- doWork()
}()
}
for i := 0; i < n; i++ {
fmt.Println(<-results)
}选择依据:
| 场景 | 推荐方案 |
|---|---|
| 只需要等完成,不关心返回值 | WaitGroup |
| 需要收集每个任务的返回值 | Channel |
| 需要错误处理 + 取消 | errgroup |
九、实战建议
Add在go之前——这是最重要的一条,否则 Wait 可能提前返回defer wg.Done()放第一行——防止任何 panic 或提前 return 导致 Done 遗漏- 不要复制 WaitGroup——和 Mutex 一样,WaitGroup 是值类型,复制会导致计数器不同步
- 生产环境优先考虑 errgroup——自带错误收集、context 取消、并发限制,省心省力
- 用
-race检测——go test -race ./...可以发现 WaitGroup 使用中的数据竞争
# 两个应该加入 CI 的命令(和 Mutex 那篇一样)
go vet ./...
go test -race ./...WaitGroup 虽然只有三个方法,但它是 Go 并发任务编排的基石。理解它的计数器机制和常见陷阱,再结合 errgroup 等高级封装,你就能优雅地处理各种"并行执行、统一等待"的场景。