go-wait-group
你好, 我是 Bobby.
sync.WaitGroup 是 Go 并发编程中最常用的同步原语之一,用于等待一组 goroutine 完成任务。
但你知道吗?它的内部结构在不同 Go 版本中经历了多次改动,核心原因是一个困扰了开发者多年的问题——内存对齐。
什么是 sync.WaitGroup?
假设你有一个大任务,决定把它拆成多个可以并发执行、互不依赖的小任务,自然会用 goroutine 来跑:
1 | |
问题来了:main goroutine 很可能在子 goroutine 还没跑完之前就退出了。
这就是 WaitGroup 的用武之地——让 main goroutine 等待所有子 goroutine 完成后再退出:
1 | |
使用套路很固定:
wg.Add(n):启动 goroutine 前,告诉 WaitGroup 要等几个wg.Done():每个 goroutine 完成后调用,计数器减一wg.Wait():main goroutine 在此阻塞,直到计数器归零
wg.Add(1) vs wg.Add(n)
实际项目中,更推荐在每次启动 goroutine 前调用 wg.Add(1),而不是在循环外一次性 wg.Add(n):
1 | |
原因很简单:如果循环逻辑变化了(比如加了 continue 跳过某些迭代),wg.Add(n) 的假设就失效了,程序会永远卡在 wg.Wait() 上,这类 bug 极难排查。
常见错误:在 goroutine 内部调用 Add
1 | |
wg.Add(1) 必须在 goroutine 启动之前调用。如果 goroutine 还没来得及执行 Add,main goroutine 就已经调用了 Wait(),就会发生竞态问题。
此外,wg.Done() 务必配合 defer 使用,防止因 panic 或多个 return 路径导致计数器永远无法归零。
sync.WaitGroup 的内部结构
来看源码(Go 1.26):
1 | |
你会注意到两个字段:noCopy 和 state。
noCopy:防止误拷贝
在 Go 中,struct 赋值默认是值拷贝。但 WaitGroup 不能被拷贝——拷贝后两个实例的内部状态完全独立,会导致同步失效。
noCopy 就是为此设计的防护机制:
1 | |
它本身不占用有效内存,也不影响运行时行为,只是一个标记。当你运行 go vet 时,它会检测到 noCopy 并报错:
1 | |
这个机制由 VictoriaMetrics CTO Aliaksandr Valialkin 贡献,引入于 change #22015。
内部状态:state atomic.Uint64
state 是一个 64 位整数,把两个值打包存在一起:
1 | |
另一个字段 sema uint32 是 Go runtime 内部管理的信号量。当 goroutine 调用 wg.Wait() 且 counter 不为零时,会调用 runtime_Semacquire(&wg.sema) 进入睡眠,直到被 runtime_Semrelease(&wg.sema) 唤醒。
核心问题:内存对齐
这是本文的重头戏。WaitGroup 的内部结构在多个 Go 版本中发生变化,根本原因是32 位架构上的内存对齐问题。
什么是内存对齐?
CPU 访问内存时,效率最高的方式是从”对齐”的地址读取数据。对于 64 位的 uint64,理想情况下它的起始地址应该是 8 的倍数——CPU 可以一次取到,否则需要多次操作。
在 64 位系统上,编译器会自动保证 uint64 的 8 字节对齐。但在 32 位架构(ARM、386、MIPS)上,编译器只保证 4 字节对齐,uint64 可能落在不对齐的地址上。
atomic 包的文档明确指出:
“在 ARM、386 和 32 位 MIPS 上,调用方有责任确保通过原子函数访问的 64 位字是 64 位对齐的。”
不对齐就会崩溃。Go 团队为此在多个版本中做出了不同的解决方案。
Go 1.5:state1 [12]byte
1 | |
思路:用 12 个字节的数组保存 state,总空间足够在其中找到一段 8 字节对齐的区域。
- 如果
state1的起始地址已经是 8 字节对齐的,直接使用前 8 字节 - 否则,跳过前 4 字节,使用
state1[4]开始的 8 字节
代价是浪费了 4 个字节。
Go 1.11:state1 [3]uint32
1 | |
改为 3 个 uint32 的数组(共 12 字节),同时把 sema 也打包进来,根据对齐情况动态调整布局:
- 8 字节对齐时:
state1[0..1]存 state,state1[2]存 sema - 4 字节对齐时:
state1[0]存 sema,state1[1..2]存 state
Go 1.18:state1 uint64; state2 uint32
1 | |
优先为更普遍的 64 位系统优化:直接用 uint64 存 state,uint32 存 sema。在 32 位系统上回退到 Go 1.11 的方案。
Go 1.20:state atomic.Uint64(现代方案)
Go 1.19 引入了 atomic.Uint64,由 Russ Cox 设计:
1 | |
align64 是空结构体,不占内存,但编译器认识它——一旦结构体中包含 align64,编译器就会自动在内存布局中插入必要的 padding,确保整个结构体从 8 字节对齐的地址开始。
有了 atomic.Uint64,WaitGroup 的结构彻底简化,state() 方法也不再需要:
1 | |
优雅,干净,不再需要任何对齐 hack。
WaitGroup 内部如何工作?
你可能会问:为什么要把 counter 和 waiter 打包进同一个 uint64,而不是分开用两个 uint32?
用两个独立变量配合 mutex 来保护确实更简单,但 mutex 有加锁开销,在高频操作下会成为瓶颈。
打包成一个 uint64 后,可以用原子操作同时读写两个字段,完全无锁。
wg.Add(delta int)
1 | |
关键点:
- counter 存在高 32 位,所以
delta << 32才是对 counter 的操作 - 当 counter 降到 0 且有等待者时,重置 state 并逐个释放信号量唤醒所有 waiter
wg.Done()就是wg.Add(-1)的别名
注意:如果要复用 WaitGroup 处理多批任务,必须等上一轮所有 Wait() 调用返回后,才能开始下一轮的 Add(正数) 调用。
wg.Wait()
1 | |
Wait() 用 CAS 循环安全地增加 waiter 计数,然后通过信号量进入睡眠。当最后一个 goroutine 调用 Done() 使 counter 归零时,Add() 会调用 runtime_Semrelease 逐个唤醒所有 waiter。
小结
| Go 版本 | state 字段类型 | 对齐方案 |
|---|---|---|
| 1.5 | [12]byte |
运行时检查地址,偏移 0 或 4 字节 |
| 1.11 | [3]uint32 |
运行时检查地址,动态调整布局 |
| 1.18 | uint64 + uint32 |
优先 64 位,fallback 到 1.11 方案 |
| 1.20+ | atomic.Uint64 |
编译器通过 align64 自动保证对齐 |
sync.WaitGroup 的演化历程是一个很好的案例:底层的内存模型约束如何驱动上层 API 实现的持续演进,最终以优雅的编译器支持收尾。
理解这些细节,不仅能帮你用好 WaitGroup,也能让你在设计需要原子操作的数据结构时少踩坑。
如果你觉得有收获,欢迎分享给更多 Gopher。