我们之前在文章 juejin.cn/post/712052… 中已经接触与学习了 WaitGroup 的使用,在这里,我们讲深入学习和理解一下 WaitGroup 的底层实现原理与思想。
1. 数据结构
type WaitGroup struct {
noCopy noCopy
state1 uint64
state2 uint32
}
noCopy
noCopy是一个空的结构体,它实现了 mutex 包中的 Locker 接口,其主要作用是在go vet
工具检验时,表明 WaitGroup 结构体的具体实现是不可被复制的。
type noCopy struct{}
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
state
WaitGroup 中 state 的设计与实现非常复杂,考虑到OS底层内存对齐以及并发安全的设计。在这里,我们先以具象的方式解释一下 state 中都包含了哪些信息:
type State struct {
counter int32
waiter uint32
sema uint32
}
counter
代表目前尚未调用的WaitGroup.Done()
的 groutine 的个数。WaitGroup.Add(n)
将会导致counter += n
, 而WaitGroup.Done()
将导致counter--
。*waiter
代表目前已调用WaitGroup.Wait()
的 goroutine 的个数。sema
对应于 golang 中 runtime 内部的信号量的实现。WaitGroup 中会用到 sema 的两个相关函数,runtime_Semacquire
和runtime_Semrelease
。runtime_Semacquire
表示增加一个信号量,并挂起 当前 goroutine。runtime_Semrelease
表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine。
因此,我们可以将 WaitGroup 的整个调用过程抽象成如下的形式:
-
当调用
WaitGroup.Add(n)
时,counter 将会自增:counter += n
-
当调用
WaitGroup.Wait()
时,会将waiter++
。同时调用runtime_Semacquire(semap)
, 增加信号量,并挂起当前 goroutine。 -
当调用
WaitGroup.Done()
时,将会counter--
。如果自减后的 counter 等于 0,说明 WaitGroup 的等待过程已经结束,则需要调用 runtime_Semrelease 释放信号量,唤醒正在WaitGroup.Wait
的 goroutine。
2. 并发安全
在上述过程中,我们需要保证counter
与waiter
修改时的并发安全,因此WaitGroup将这两个变量维护在了一个int64中,其中 counter
是这个变量的高 32 位,waiter
是这个变量的低 32 位,通过CAS操作保证其并发安全。
WaitGroup 是可以复用的,因此在 Wait 结束的时候需要将 waiter--
,重置状态。但这肯定会涉及到一次原子变量操作。如果调用 Wait 的 goroutine 比较多,那这个原子操作也会随之进行很多次。 但 WaitGroup 这里直接在Done 的时候,当 counter 等于 0 时,直接将 counter+waiter
整个 64 位整数全部置 0,既可以达到重置状态的效果,也免于进行多次原子操作。
3. 内存对齐
刚刚提到,我们需要使用64位的CAS操作,而Go中的这个操作需要我们自己确保CAS的地址值是与64位对齐的,但是对于32位机器而言,有可能会存在没有对齐64位地址的情况,因此 WaitGroup 用了以下的形式进行内存对齐:
- 当
state1
是 32 位对齐:state1
数组的第一位是 sema,第二位是 counter,第三位是 waiter。 - 当
state1
是 64 位对齐:state1
数组的第一位是 counter,第二位是 waiter,第三位是 sema。
本文正在参加技术专题18期-聊聊Go语言框架