Go WaitGroup底层原理详解

我们之前在文章 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_Semreleaseruntime_Semacquire 表示增加一个信号量,并挂起 当前 goroutine。runtime_Semrelease 表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine。

因此,我们可以将 WaitGroup 的整个调用过程抽象成如下的形式:

  1. 当调用 WaitGroup.Add(n) 时,counter 将会自增: counter += n

  2. 当调用 WaitGroup.Wait() 时,会将 waiter++。同时调用 runtime_Semacquire(semap), 增加信号量,并挂起当前 goroutine。

  3. 当调用 WaitGroup.Done() 时,将会 counter--。如果自减后的 counter 等于 0,说明 WaitGroup 的等待过程已经结束,则需要调用 runtime_Semrelease 释放信号量,唤醒正在 WaitGroup.Wait 的 goroutine。

2. 并发安全

在上述过程中,我们需要保证counterwaiter修改时的并发安全,因此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 用了以下的形式进行内存对齐:

image.png

  • 当 state1 是 32 位对齐:state1 数组的第一位是 sema,第二位是 counter,第三位是 waiter。
  • 当 state1 是 64 位对齐:state1 数组的第一位是 counter,第二位是 waiter,第三位是 sema。

本文正在参加技术专题18期-聊聊Go语言框架

猜你喜欢

转载自juejin.im/post/7120548108966035470