Golang sync.WaitGroup
sync.WaitGroup 是 Go 标准库 sync 包中一个非常常用的并发原语。它的主要作用是等待一组 Goroutine 全部执行完成。它非常适合于那种“主 Goroutine 派发多个子 Goroutine,并需要等待所有子 Goroutine 完成后再继续执行”的场景。
WaitGroup 对外提供了三个核心方法:
-
Add(delta int): 增加或减少等待组的计数器。 -
Done(): 对计数器减一,是Add(-1)的简写。 -
Wait(): 阻塞当前 Goroutine,直到等待组的计数器归零。
一、 核心数据结构
WaitGroup 的结构定义非常简洁,但其内部状态的管理却十分精巧:
// src/sync/waitgroup.go
type WaitGroup struct {
noCopy noCopy // 一个特殊类型,用于静态分析工具检查 WaitGroup 是否被复制
// 64位的值,分为两部分:
// 高32位:计数器 (counter)
// 低32位:等待者数量 (waiter count)
// 另外还有一个信号量,用于唤醒等待者。
// 为了64位原子操作在32位平台上的对齐问题,这些状态被放在一个数组中。
state1 [3]uint32
}
-
noCopy: 这个字段的存在是为了通过go vet等工具,在编译期间检查出WaitGroup被复制的错误用法。WaitGroup在使用后是不能被复制的。 -
state1 [3]uint32: 这是WaitGroup的核心。它是一个包含3个uint32元素的数组,但概念上,它存储了两个核心状态:-
一个 64 位的状态整数 (
statep): 这个整数由state1的前两个uint32元素组合而成。-
高 32 位: 存储当前的计数器 (counter),即还需要等待多少个
Done()被调用。 -
低 32 位: 存储当前有多少个 Goroutine 正在调用
Wait()方法并处于等待状态 (waiter count)。
-
-
一个 32 位的信号量 (
sema): 由state1的第三个uint32元素表示。当 Goroutine 调用Wait()需要阻塞时,会通过这个信号量进入休眠;当计数器归零时,Add或Done方法会通过这个信号量唤醒所有等待的 Goroutine。
-
将计数器和等待者数量合并到一个 64 位整数中,使得 Go 运行时可以通过一次原子操作同时修改或读取这两个状态,极大地简化了并发控制逻辑,避免了额外的锁。
二、 结构图
graph TD
subgraph wg [sync.WaitGroup 实例]
noCopy["noCopy (禁止复制)"]
state1["state1 ([3]uint32)"]
end
subgraph state_usage [state1 数组的 conceptual 用途]
direction LR
statep_ptr["statep (*uint64)"]
sema_ptr["sema (*uint32)"]
end
subgraph statep_detail [statep 指向的 64位整数 的位布局]
direction LR
counter["Counter (高32位)"]
waiters["Waiter Count (低32位)"]
end
subgraph sema_usage [sema 的用途]
sema_desc["用于阻塞/唤醒等待 'Wait()' 的 Goroutine"]
end
state1 -- "前两个元素组成" --> statep_ptr
state1 -- "第三个元素" --> sema_ptr
statep_ptr --> statep_detail
sema_ptr --> sema_usage
counter --> desc_counter["记录还需等待的 goroutine 数量"]
waiters --> desc_waiters["记录有多少 goroutine 在调用 'Wait()'"]
style wg fill:#f9f,stroke:#333,stroke-width:2px
三、 关键操作流程
A. Add(delta int) / Done()
Done() 内部直接调用 Add(-1)。Add 方法是 WaitGroup 状态变化的核心。
-
原子更新计数器: 使用
atomic.AddUint64操作,将delta加到 64 位state整数的高 32 位(即计数器部分)。 -
检查计数器合法性:
-
delta为正数时,表示要增加等待的 Goroutine 数量。这通常发生在Wait()调用之前。 -
delta为负数时(Done()),表示一个 Goroutine 已完成。 -
Add操作后,会检查新的计数器值。如果变为负数,意味着Done()的调用次数超过了Add的次数,这是一种错误用法,程序会panic。
-
-
判断是否唤醒等待者:
-
如果
Add操作后,新的计数器值变为 0,这意味着所有等待的 Goroutine 都已完成。 -
此时,
Add方法会检查state整数的低 32 位(即等待者数量waiter count)。 -
如果
waiter count大于 0,说明有 Goroutine 正在Wait()方法上阻塞。 -
Add方法会通过runtime_Semrelease操作,根据waiter count的数量,释放信号量sema相应次数,从而唤醒所有正在等待的 Goroutine。 -
唤醒后,会将
state整数重置为 0。
-
B. Wait()
Wait() 方法用于阻塞,直到计数器归零。
-
快速路径 (Fast Path):
-
Wait()首先会通过原子操作读取 64 位的state整数。 -
它检查高 32 位的计数器部分。如果计数器已经为 0,说明无需等待,
Wait()方法立即返回。
-
-
慢速路径 (Slow Path):
-
如果计数器不为 0,
Wait()会进入一个循环,准备阻塞。 -
增加等待者数量: 在循环中,它会尝试通过 CAS (Compare-And-Swap) 原子操作,将
state整数的低 32 位(waiter count)加一。 -
再次检查计数器: CAS 成功后,它会再次检查计数器是否已经变为 0。这一步是为了处理一个竞态条件:可能在当前 Goroutine 准备进入休眠的瞬间,其他 Goroutine 已经完成了所有任务,并将计数器置零了。如果此时发现计数器已为0,就没必要休眠了,它会把刚加上的
waiter count再减回去,然后退出循环并返回。 -
进入休眠: 如果再次检查后,计数器仍然不为 0,则调用
runtime_Semacquire,在sema信号量上阻塞当前 Goroutine,进入休眠状态。 -
被唤醒: 当计数器归零,
Add方法释放了信号量后,这个 Goroutine 会被唤醒,Wait()方法执行完毕并返回。
-
四、 使用注意事项
-
Add必须在Wait之前调用:主 Goroutine 应该在启动子 Goroutine 之前,就调用Add设置好需要等待的数量。如果在子 Goroutine 内部调用Add,可能会因为子 Goroutine 还没来得及执行Add,主 Goroutine 的Wait就已经执行并发现计数器为0,从而提前退出。 -
WaitGroup不可重用:一旦WaitGroup的计数器归零,并且Wait()返回后,它就不能被重用了(例如,再次调用Add增加计数器)。如果需要新的等待组,应该创建一个新的WaitGroup实例。 -
不要拷贝
WaitGroup:WaitGroup在首次使用后就不应该被复制。复制会导致其内部状态不一致,go vet工具可以检查出这种错误。
总结
sync.WaitGroup 的实现非常高效,其精髓在于:
-
状态压缩:通过一个 64 位的原子整数
state,巧妙地同时管理了“需要等待的任务数量”和“正在等待的 Goroutine 数量”。 -
无锁设计:核心状态的变更都通过原子操作完成,避免了使用互斥锁带来的开销和复杂性。
-
高效的阻塞/唤醒机制:利用内部的信号量 (
sema),让等待的 Goroutine 高效地休眠和被唤醒,而不会空耗 CPU。
这使得 WaitGroup 成为 Go 中处理一组并发任务同步的轻量级、高性能且易于使用的标准工具。