上一篇我们聊了 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 的优势:

特性WaitGrouperrgroup
等待全部完成
收集错误需手动 + 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

九、实战建议

  1. Addgo 之前——这是最重要的一条,否则 Wait 可能提前返回
  2. defer wg.Done() 放第一行——防止任何 panic 或提前 return 导致 Done 遗漏
  3. 不要复制 WaitGroup——和 Mutex 一样,WaitGroup 是值类型,复制会导致计数器不同步
  4. 生产环境优先考虑 errgroup——自带错误收集、context 取消、并发限制,省心省力
  5. -race 检测——go test -race ./... 可以发现 WaitGroup 使用中的数据竞争
# 两个应该加入 CI 的命令(和 Mutex 那篇一样)
go vet ./...
go test -race ./...

WaitGroup 虽然只有三个方法,但它是 Go 并发任务编排的基石。理解它的计数器机制和常见陷阱,再结合 errgroup 等高级封装,你就能优雅地处理各种"并行执行、统一等待"的场景。