go-wait-group

你好, 我是 Bobby.

sync.WaitGroup 是 Go 并发编程中最常用的同步原语之一,用于等待一组 goroutine 完成任务。

但你知道吗?它的内部结构在不同 Go 版本中经历了多次改动,核心原因是一个困扰了开发者多年的问题——内存对齐

什么是 sync.WaitGroup?

假设你有一个大任务,决定把它拆成多个可以并发执行、互不依赖的小任务,自然会用 goroutine 来跑:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Println("Task", i)
}(i)
}

fmt.Println("Done")
}

// 输出:
// Done

问题来了:main goroutine 很可能在子 goroutine 还没跑完之前就退出了。

这就是 WaitGroup 的用武之地——让 main goroutine 等待所有子 goroutine 完成后再退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var wg sync.WaitGroup

wg.Add(10)
for i := 0; i < 10; i++ {
go func(i int) {
defer wg.Done()
fmt.Println("Task", i)
}(i)
}

wg.Wait()
fmt.Println("Done")
}

使用套路很固定:

  • 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
2
3
4
5
6
7
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// ...
}()
}

原因很简单:如果循环逻辑变化了(比如加了 continue 跳过某些迭代),wg.Add(n) 的假设就失效了,程序会永远卡在 wg.Wait() 上,这类 bug 极难排查。

常见错误:在 goroutine 内部调用 Add

1
2
3
4
5
6
7
8
// 错误写法!
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // ❌ 危险
defer wg.Done()
// ...
}()
}

wg.Add(1) 必须在 goroutine 启动之前调用。如果 goroutine 还没来得及执行 Add,main goroutine 就已经调用了 Wait(),就会发生竞态问题。

此外,wg.Done() 务必配合 defer 使用,防止因 panic 或多个 return 路径导致计数器永远无法归零。


sync.WaitGroup 的内部结构

来看源码(Go 1.26):

1
2
3
4
5
6
7
8
9
10
type WaitGroup struct {
noCopy noCopy

// Bits (high to low):
// bits[0:32] counter
// bits[32] flag: synctest bubble membership
// bits[33:64] wait count
state atomic.Uint64
sema uint32
}

你会注意到两个字段:noCopystate

noCopy:防止误拷贝

在 Go 中,struct 赋值默认是值拷贝。但 WaitGroup 不能被拷贝——拷贝后两个实例的内部状态完全独立,会导致同步失效。

noCopy 就是为此设计的防护机制:

1
2
3
4
type noCopy struct{}

func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

它本身不占用有效内存,也不影响运行时行为,只是一个标记。当你运行 go vet 时,它会检测到 noCopy 并报错:

1
2
3
4
5
6
7
8
9
10
func main() {
var a sync.WaitGroup
b := a // 拷贝!

fmt.Println(a, b)
}

// go vet 输出:
// assignment copies lock value to b: sync.WaitGroup contains sync.noCopy
// call of fmt.Println copies lock value: sync.WaitGroup contains sync.noCopy

这个机制由 VictoriaMetrics CTO Aliaksandr Valialkin 贡献,引入于 change #22015

内部状态:state atomic.Uint64

state 是一个 64 位整数,把两个值打包存在一起:

1
2
32 位 → counter:当前等待完成的 goroutine 数量
32 位 → waiter:当前阻塞在 Wait() 的 goroutine 数量

另一个字段 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
2
3
4
5
6
7
8
9
10
11
12
type WaitGroup struct {
state1 [12]byte
sema uint32
}

func (wg *WaitGroup) state() *uint64 {
if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
return (*uint64)(unsafe.Pointer(&wg.state1))
} else {
return (*uint64)(unsafe.Pointer(&wg.state1[4]))
}
}

思路:用 12 个字节的数组保存 state,总空间足够在其中找到一段 8 字节对齐的区域。

  • 如果 state1 的起始地址已经是 8 字节对齐的,直接使用前 8 字节
  • 否则,跳过前 4 字节,使用 state1[4] 开始的 8 字节

代价是浪费了 4 个字节。


Go 1.11:state1 [3]uint32

1
2
3
4
5
6
7
8
9
10
11
12
type WaitGroup struct {
noCopy noCopy
state1 [3]uint32
}

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
} else {
return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
}
}

改为 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
2
3
4
5
6
7
8
9
10
11
12
13
14
type WaitGroup struct {
noCopy noCopy
state1 uint64
state2 uint32
}

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
if unsafe.Alignof(wg.state1) == 8 || uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
return &wg.state1, &wg.state2
} else {
state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
return (*uint64)(unsafe.Pointer(&state[1])), &state[0]
}
}

优先为更普遍的 64 位系统优化:直接用 uint64 存 state,uint32 存 sema。在 32 位系统上回退到 Go 1.11 的方案。


Go 1.20:state atomic.Uint64(现代方案)

Go 1.19 引入了 atomic.Uint64,由 Russ Cox 设计:

1
2
3
4
5
6
7
8
9
type Uint64 struct {
_ noCopy
_ align64
v uint64
}

// align64 是一个特殊标记,被编译器识别
// 用于强制要求结构体 8 字节对齐
type align64 struct{}

align64 是空结构体,不占内存,但编译器认识它——一旦结构体中包含 align64,编译器就会自动在内存布局中插入必要的 padding,确保整个结构体从 8 字节对齐的地址开始。

有了 atomic.Uint64WaitGroup 的结构彻底简化,state() 方法也不再需要:

1
2
3
4
5
6
type WaitGroup struct {
noCopy noCopy

state atomic.Uint64
sema uint32
}

优雅,干净,不再需要任何对齐 hack。


WaitGroup 内部如何工作?

你可能会问:为什么要把 counter 和 waiter 打包进同一个 uint64,而不是分开用两个 uint32

用两个独立变量配合 mutex 来保护确实更简单,但 mutex 有加锁开销,在高频操作下会成为瓶颈。

打包成一个 uint64 后,可以用原子操作同时读写两个字段,完全无锁。

wg.Add(delta int)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (wg *WaitGroup) Add(delta int) {
// 将 delta 加到高 32 位(counter)
state := wg.state.Add(uint64(delta) << 32)

v := int32(state >> 32) // 取 counter
w := uint32(state) // 取 waiter

if v < 0 {
panic("sync: negative WaitGroup counter")
}
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
if v > 0 || w == 0 {
return
}
if wg.state.Load() != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}

// counter 归零,唤醒所有等待的 goroutine
wg.state.Store(0)
for ; w != 0; w-- {
runtime_Semrelease(&wg.sema, false, 0)
}
}

关键点:

  • counter 存在高 32 位,所以 delta << 32 才是对 counter 的操作
  • 当 counter 降到 0 且有等待者时,重置 state 并逐个释放信号量唤醒所有 waiter
  • wg.Done() 就是 wg.Add(-1) 的别名

注意:如果要复用 WaitGroup 处理多批任务,必须等上一轮所有 Wait() 调用返回后,才能开始下一轮的 Add(正数) 调用。

wg.Wait()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (wg *WaitGroup) Wait() {
for {
state := wg.state.Load()
v := int32(state >> 32) // counter
w := uint32(state) // waiter

if v == 0 {
return // counter 已为零,直接返回
}

// CAS:尝试将 waiter+1
if wg.state.CompareAndSwap(state, state+1) {
runtime_Semacquire(&wg.sema) // 阻塞,等待被唤醒

if wg.state.Load() != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
return
}
// CAS 失败(其他 goroutine 修改了 state),重试
}
}

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。


go-wait-group
https://blog.zhangliangliang.cc/post/go-wait-group.html
作者
Bobby Zhang
发布于
2026年4月22日
许可协议