很多 Go 初学者第一次接触 sync.WaitGroup,会把它理解成“等待 goroutine 结束的小工具”。这个理解方向没错,但如果只停在这个层面,后面很容易在计数时机、复用方式和错误传播上踩坑。

WaitGroup 到底在做什么

它的核心模型其实非常直接:

  • Add(n):把待完成任务数加上 n
  • Done():表示一个任务结束,本质上等价于 Add(-1)
  • Wait():阻塞,直到计数归零

也就是说,WaitGroup 本质上是一个“任务计数器”,而不是 goroutine 本身的管理器。

最常见的基本写法

 1package main
 2
 3import (
 4    "fmt"
 5    "sync"
 6)
 7
 8func main() {
 9    var wg sync.WaitGroup
10
11    for i := 0; i < 3; i++ {
12        wg.Add(1)
13        go func(id int) {
14            defer wg.Done()
15            fmt.Println("worker", id)
16        }(i)
17    }
18
19    wg.Wait()
20}

这段代码里最关键的关系是:

  • 每启动一个 goroutine 之前先 Add(1)
  • goroutine 结束时一定会执行 Done()
  • 主流程最后 Wait()

为什么 Add 通常要放在启动 goroutine 之前

因为如果你把 Add(1) 放到 goroutine 里面,就会产生竞态窗口:

  • 主协程可能已经执行到 Wait()
  • 但子 goroutine 还没来得及 Add(1)

这时程序就可能提前结束等待,逻辑直接失真。

错误示例:

1go func() {
2    wg.Add(1) // 不推荐
3    defer wg.Done()
4    work()
5}()
6wg.Wait()

更稳的写法始终是:

1wg.Add(1)
2go func() {
3    defer wg.Done()
4    work()
5}()

Done 为什么最好配合 defer

因为一旦函数里提前 return、panic 或中途加了分支,手动调用 Done() 很容易漏。

所以最常见的稳定写法就是:

1go func() {
2    defer wg.Done()
3    work()
4}()

这样“任务开始时登记,任务结束时结算”的结构会非常清晰。

WaitGroup 不适合解决什么问题

1. 不适合做错误收集

WaitGroup 本身不传递错误。如果并发任务有错误结果,你需要额外准备:

  • errCh
  • 共享错误变量加锁
  • 或者直接换成 errgroup

2. 不适合做取消控制

如果任务需要超时、取消、主动中止,那应该配合 context,而不是指望 WaitGroup 帮你停掉 goroutine。

3. 不适合限制并发数

WaitGroup 只能等,不会限制同时运行多少个任务。要限流通常要配合:

  • worker pool
  • 带缓冲 channel
  • semaphore

常见误区

误区一:计数加多了或减少了

如果 AddDone 不对称:

  • Done() 会让 Wait() 永远卡住
  • Done()Add(-1) 过头,会直接 panic

误区二:把 WaitGroup 复制传值

WaitGroup 不能在使用中被复制。最常见的稳妥方式是:

  • 用同一个变量
  • 需要传递时传指针

例如:

1func runTask(wg *sync.WaitGroup) {
2    defer wg.Done()
3}

误区三:还没等上一轮结束,就开始复用

WaitGroup 可以复用,但前提是上一轮任务已经彻底结束,也就是 Wait() 已经返回。否则逻辑会变得非常难推理。

一个稍完整的示例

下面这个版本更接近真实项目里“并发拉取数据后统一收尾”的模式:

 1package main
 2
 3import (
 4    "fmt"
 5    "sync"
 6    "time"
 7)
 8
 9func main() {
10    var wg sync.WaitGroup
11    results := make(chan string, 3)
12
13    tasks := []string{"profile", "orders", "settings"}
14    for _, task := range tasks {
15        task := task
16        wg.Add(1)
17        go func() {
18            defer wg.Done()
19            time.Sleep(100 * time.Millisecond)
20            results <- "loaded " + task
21        }()
22    }
23
24    go func() {
25        wg.Wait()
26        close(results)
27    }()
28
29    for result := range results {
30        fmt.Println(result)
31    }
32}

这里 WaitGroup 负责等待所有生产者结束,真正的“消费结束信号”还是通过 close(results) 表达出来。

我的总结

  • WaitGroup 的本质是任务计数器,不是 goroutine 管理框架
  • Add 通常要放在启动 goroutine 之前
  • Done 最好配合 defer
  • 它不负责错误、取消和限流
  • 真正稳定的并发代码,通常是 WaitGroupcontext、channel、worker pool 一起配合使用