一、先建立 WaitGroup 的核心认知
sync.WaitGroup 本质上解决的是一个非常简单的问题:
一个 goroutine 如何等待一组 goroutine 全部执行完?
最经典的例子:
go
func main() {
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
fmt.Println("任务1完成")
}()
go func() {
defer wg.Done()
fmt.Println("任务2完成")
}()
go func() {
defer wg.Done()
fmt.Println("任务3完成")
}()
wg.Wait()
fmt.Println("所有任务完成")
}
这里可以把 WaitGroup 想象成一个计数器:
scss
Add(3)
计数器:
3
│
├── goroutine 1 Done() → 2
│
├── goroutine 2 Done() → 1
│
└── goroutine 3 Done() → 0
│
▼
Wait() 返回
所以:
scss
wg.Add(3)
意思是:
我要等待 3 个任务。
scss
wg.Done()
意思是:
我完成了一个任务。
scss
wg.Wait()
意思是:
如果还有任务没完成,我就阻塞;全部完成后我再继续。
二、WaitGroup 到底是什么?
定义在:
bash
package sync
类型:
go
type WaitGroup struct {
// 内部状态
}
它是一种同步原语(Synchronization Primitive) 。
和你已经学习过的 Channel 不一样:
Channel
核心是:
goroutine 之间传递数据 / 同步
例如:
go
ch := make(chan int)
go func() {
ch <- 100
}()
x := <-ch
这里 Channel 既负责:
diff
数据传递
+
同步
WaitGroup
核心是:
等待一组 goroutine 完成
它本身不负责传递业务数据。
三、WaitGroup 的三个核心 API
WaitGroup 真正需要掌握的 API 其实只有三个:
scss
Add(delta int)
Done()
Wait()
scss
wg.Add(1)
增加等待计数。
例如:
scss
wg.Add(5)
表示:
当前有 5 个任务需要等待
它实际上可以传负数:
scss
wg.Add(-1)
所以:
scss
wg.Done()
本质上就是:
scss
wg.Add(-1)
为什么强烈推荐:
go
defer wg.Done()
而不是:
scss
go func() {
doSomething()
wg.Done()
}()
因为任务中间可能发生多个 return:无论从哪里退出,都能够执行:
否则就可能出现:
scss
任务已经结束
↓
忘记 Done()
↓
WaitGroup counter 永远 > 0
↓
Wait() 永久阻塞
这是一类非常典型的死锁问题。
scss
wg.Wait()
作用:
等待 WaitGroup 的计数器变成 0。
执行过程:
scss
main goroutine
wg.Add(2)
│
│
├──────────────→ goroutine A
│ sleep 1s
│ Done()
│
└──────────────→ goroutine B
sleep 2s
Done()
main
│
Wait()
│
│ 阻塞
│
▼
counter = 0
│
▼
Wait() 返回
│
▼
全部完成
四、Add 应该放在哪里?
初学者最容易写成:
scss
for i := 0; i < 10; i++ {
go func() {
wg.Add(1)
defer wg.Done()
doSomething()
}()
}
wg.Wait()
这是错误的。
为什么?
因为:
go
go func()
只是创建了一个 goroutine。
这个 goroutine 什么时候真正执行,由 Go scheduler 决定。
所以可能出现:
csharp
main goroutine
启动 goroutine
启动 goroutine
启动 goroutine
Wait()
│
▼
发现 counter = 0
直接返回
而那些 goroutine 甚至还没来得及执行:wg.Add(1)
应该:
scss
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
doSomething()
}()
}
wg.Wait()
正确方式也就是说:
Add 必须在启动 goroutine 之前完成。
这是 WaitGroup 最重要的使用规则之一:
Add(1) 必须在 go 前面
五、WaitGroup 和 Channel 的一个重要区别
假设:
启动 10 个 goroutine,等它们全部完成。
方法一:WaitGroup
scss
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()
语义非常直接:
markdown
启动任务
↓
等待所有任务完成
方法二:Channel
也可以:
css
done := make(chan struct{}, 10)
for i := 0; i < 10; i++ {
go func() {
work()
done <- struct{}{}
}()
}
for i := 0; i < 10; i++ {
<-done
}
也能实现。
但是语义变复杂了:
markdown
Channel
↓
用消息通知任务完成
而 WaitGroup:
markdown
WaitGroup
↓
专门表达"等待一组任务完成"
所以:
如果只是等待一批 goroutine 完成,WaitGroup 通常比 Channel 更合适。
六、WaitGroup 不会处理错误
这是一个非常重要的限制。
例如:
scss
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if err := task1(); err != nil {
fmt.Println(err)
}
}()
go func() {
defer wg.Done()
task2()
}()
wg.Wait()
WaitGroup 只知道:
task1 Done
task2 Done
它不知道:
task1 成功
task1 失败
所以如果你需要:
diff
并发执行
+
错误收集
+
错误取消
通常应该考虑:
vbnet
errgroup.Group
这也是后面非常值得学习的内容。
可以形成这样的知识体系:
markdown
WaitGroup
↓
等待一组任务完成
errgroup
↓
等待 + 错误处理 + 取消
七、waitGroup 不会限制并发数量
例如:
scss
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()
WaitGroup 不会说:
最多只能运行 100 个 goroutine
它只是:
markdown
100000 个任务
↓
全部完成以后
↓
Wait 返回
如果想限制并发数量,需要:
Channel semaphore
或者其他并发控制方式。
例如:
go
sem := make(chan struct{}, 100)
于是:
diff
WaitGroup
+
Channel semaphore
可以组合出:
diff
等待所有任务
+
限制最大并发数
八、WaitGroup 最重要的几个坑
坑 1:忘记 Done
scss
wg.Add(1)
go func() {
doSomething()
}()
wg.Wait()
结果:
ini
counter = 1
永远不会变成:
0
所以:
go
defer wg.Done()
是最常见的安全写法。
坑 2:Add 放到 goroutine 内部
错误:
scss
go func() {
wg.Add(1)
defer wg.Done()
}()
正确:
scss
wg.Add(1)
go func() {
defer wg.Done()
}()
坑 3:Done 次数超过 Add 次数
例如:
scss
wg.Add(1)
go func() {
defer wg.Done()
wg.Done()
}()
相当于:
scss
counter = 1
Done()
→ 0
Done()
→ -1
会触发:
go
panic
典型错误:
bash
sync: negative WaitGroup counter
所以:
scss
Add(3)
必须对应:
scss
Done()
Done()
Done()
坑 4:复制 WaitGroup
这是非常重要的。
例如:
scss
func doSomething(wg sync.WaitGroup) {
wg.Add(1)
go func() {
defer wg.Done()
}()
}
这里发生了:
css
main wg
│
├── copy
│
▼
doSomething 的 wg
你操作的是:
副本
而不是原来的 WaitGroup。
因此:
WaitGroup 不应该被复制。
通常应该传指针:
scss
func doSomething(wg *sync.WaitGroup) {
wg.Add(1)
go func() {
defer wg.Done()
}()
}
因为 WaitGroup 内部包含:
diff
状态
+
semaphore / waiter 信息
它是一个具有内部同步状态的对象。
复制之后:
css
wg A
wg B
它们的状态就不再共享。
因此:
css
你 Add 的是 A
你 Wait 的可能是 B
最终当然会出现问题。
这也是为什么官方文档有非常重要的一句话:
A WaitGroup must not be copied after first use.
也就是说:
WaitGroup 第一次使用之后不能复制。
九、wg 为什么通常声明成变量而不是指针?
你经常会看到:
dart
var wg sync.WaitGroup
而不是:
css
wg := &sync.WaitGroup{}
因为:
dart
var wg sync.WaitGroup
已经足够。
调用:
scss
wg.Add(1)
wg.Done()
wg.Wait()
Go 会自动处理方法调用中的取地址。
所以:
dart
var wg sync.WaitGroup
是最推荐、最自然的写法。
十、waitgroup 的正确生命周期
例如:
go
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println("task", i)
}(i)
}
wg.Wait()
fmt.Println("all done")
}
这就是最标准的 WaitGroup 模式。
十一、WaitGroup 和 Go Memory Model
并发编程里非常重要的:
happens-before(先行发生)关系
所以 WaitGroup 不只是:
等一等
它还承担了:
同步内存访问
的作用。
假设:
go
var result int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
result = 100
}()
wg.Wait()
fmt.Println(result)
为什么这里读取:result。是安全的?因为:Done 与 Wait 建立了必要的同步关系,因此完成 goroutine 中的写入,对等待方可见。
但是注意,即使你最后:
scss
wg.Wait()
也不能解决两个 goroutine 同时修改 x 的问题。
WaitGroup 只能保证:
任务完成之后
主 goroutine 再继续
不能保证:
多个 goroutine 同时访问共享数据是安全的