在使用Golang一段时间之后,很少人不认识一个叫sync.WaitGroup的结构体。
使用场景
WaitGroup用来编排多个并发任务,举个例子,一个业务逻辑,依赖三个HTTP请求,且这三个HTTP请求可以并发执行,之间并无依赖关系,这就是WaitGroup使用的场景。
var urls = []string{"url1", "url2", "url3"}
g := sync.WaitGroup{}
g.Add(len(urls))
for i := 0; i < len(urls); i++ {
go func(url string) {
defer g.Done() // or g.Add(-1)
request(url)
}(urls[i])
}
g.Wait() // 到这里的时候,所有的请求都执行完毕
使用WaitGroup,能轻易的编排并发,使得主业务逻辑等待的时间等于三个请求中最慢的时间。
信号量
在剖析WaitGroup之前,我们必须先说下信号量,给后面的内容打下坚实的基础。
信号量(semaphore)是一个许可集。至少,这是许多文章里都提到的一个概念,许可集其实是许可集合,既然是集合,那就是有限的,也就是说,多个线程或者协程竞争许可集合里的许可,如果有富余许可,就给等待的线程,线程拿到许可后,用完放回,没有拿到线程,则休眠等待唤醒,重新竞争。
如果你了解一点Java的话,下面的例子就是最好的阐述,如果不懂也没有关系,这几行代码你一定能看懂:
Semaphore s = new Semaphore(3); // 许可个数
Runnable run = new Runnable() {
public void run() {
try {
s.acquire(); // 拿不到则阻塞
} catch (InterruptedException e) {
} finally {
s.release(); // 用完放回
}
}
}
需要注意的是,在Java中,new Runnable有点像golang里的go func,是跑在一个单独的线程中的,许可,或者说资源是有限的,多个线程争抢,就必须有一个机制能编排它们,这就是信号量的作用。
让我们来归纳一下。
信号量就是一个有限许可集,信号量的使用场景有以下特点:
资源有限。
并发(多线程)。
在Golang中,信号量也是实现锁所依赖的基本函数。在其他场景下,比如说连接池,也是使用信号量非常合适的业务场景。
数据结构和算法
WaitGroup的结构体是这样的:
type WaitGroup struct {
noCopy noCopy
state1 [3]uint32
}
noCopy只是标记下WaitGroup是不可以被拷贝的,state1中分为一个64位和一个32位,64位用来计数worker和waiter,另外的32位用来作信号量的底层数据结构。
通常情况下,使用WaitGroup都是在主协程中执行Add和Wait,在并发的协程中执行Done,Wait方法可以是出现在多个协程中,被重复调用,但通常出现在主协程中。
worker的数量可以认为是在执行中的协程的数量,waiter的数量可以认为是有多少个协程调用了Wait方法在等待所有的worker执行完毕。
Add函数的主要逻辑有2个:
修改worker的值,这里只所以说修改,而不说递增,是因为Add的参数可能是正的,也可能是负的。
如果worker的值变为了0,说明所有的协程都执行完毕,就要释放许可,释放的数量,就是waiter的数量。
Done函数的逻辑其实是调用了Add(-1),所以Done的逻辑参考Add的逻辑。
Wait函数的主要逻辑也是2个:
waiter的值递增。
等待信号量,阻塞获取许可,获取到以后就返回,函数结束。
归纳WaitGroup的实现,本质上是围绕信号量实现的,但是什么时候释放,释放几个,什么时候获取信号量,这些都是用state1这个field来实现的。算法的实现总是需要用数据结构作为依托。
内存对齐
关于内存对齐的文章很多,这里并不打算宣兵夺主,还是回到我们的WaitGroup上来。之所以提内存对齐,是因为考虑了内存对齐,所以结构体里的state1是一个长度为3的数组才有一个合理的解释。
内存对齐的知识点可能很多,但我们今天只关心其中一个。
On ARM, x86-32, and 32-bit MIPS, it is the caller's responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.
也就是说32位架构想要原子性的操作8bytes,需要由调用方保证其数据地址是64位对齐的,否则原子访问会有异常。
在阅读WaitGroup源码的时候,会注意到,state1在64位架构下,前64位是worker和waiter计数器,后32位是sema,而在32位架构下,前32位是sema,后64位是worker和waiter计数器。不得不感叹思路之精巧、精密,计算机基础知识之深,我们还有很长的路要走。
参考
Java semaphore:
https://segmentfault.com/a/1190000023038654
WaitGroup:
https://www.ququ123.xyz/2022/04/golang_wait_group_principle/
内存对齐: