在使用Golang一段時間之后,很少人不認識一個叫sync.WaitGroup的結構體。
使用場景
WaitGroup用來編排多個并發(fā)任務,舉個例子,一個業(yè)務邏輯,依賴三個HTTP請求,且這三個HTTP請求可以并發(fā)執(zhí)行,之間并無依賴關系,這就是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() // 到這里的時候,所有的請求都執(zhí)行完畢
使用WaitGroup,能輕易的編排并發(fā),使得主業(yè)務邏輯等待的時間等于三個請求中最慢的時間。
信號量
在剖析WaitGroup之前,我們必須先說下信號量,給后面的內容打下堅實的基礎。
信號量(semaphore)是一個許可集。至少,這是許多文章里都提到的一個概念,許可集其實是許可集合,既然是集合,那就是有限的,也就是說,多個線程或者協(xié)程競爭許可集合里的許可,如果有富余許可,就給等待的線程,線程拿到許可后,用完放回,沒有拿到線程,則休眠等待喚醒,重新競爭。
如果你了解一點Java的話,下面的例子就是最好的闡述,如果不懂也沒有關系,這幾行代碼你一定能看懂:
Semaphore s = new Semaphore(3); // 許可個數(shù)
Runnable run = new Runnable() {
public void run() {
try {
s.acquire(); // 拿不到則阻塞
} catch (InterruptedException e) {
} finally {
s.release(); // 用完放回
}
}
}
需要注意的是,在Java中,new Runnable有點像golang里的go func,是跑在一個單獨的線程中的,許可,或者說資源是有限的,多個線程爭搶,就必須有一個機制能編排它們,這就是信號量的作用。
讓我們來歸納一下。
信號量就是一個有限許可集,信號量的使用場景有以下特點:
資源有限。
并發(fā)(多線程)。
在Golang中,信號量也是實現(xiàn)鎖所依賴的基本函數(shù)。在其他場景下,比如說連接池,也是使用信號量非常合適的業(yè)務場景。
數(shù)據(jù)結構和算法
WaitGroup的結構體是這樣的:
type WaitGroup struct {
noCopy noCopy
state1 [3]uint32
}
noCopy只是標記下WaitGroup是不可以被拷貝的,state1中分為一個64位和一個32位,64位用來計數(shù)worker和waiter,另外的32位用來作信號量的底層數(shù)據(jù)結構。
通常情況下,使用WaitGroup都是在主協(xié)程中執(zhí)行Add和Wait,在并發(fā)的協(xié)程中執(zhí)行Done,Wait方法可以是出現(xiàn)在多個協(xié)程中,被重復調用,但通常出現(xiàn)在主協(xié)程中。
worker的數(shù)量可以認為是在執(zhí)行中的協(xié)程的數(shù)量,waiter的數(shù)量可以認為是有多少個協(xié)程調用了Wait方法在等待所有的worker執(zhí)行完畢。
Add函數(shù)的主要邏輯有2個:
修改worker的值,這里只所以說修改,而不說遞增,是因為Add的參數(shù)可能是正的,也可能是負的。
如果worker的值變?yōu)榱?,說明所有的協(xié)程都執(zhí)行完畢,就要釋放許可,釋放的數(shù)量,就是waiter的數(shù)量。
Done函數(shù)的邏輯其實是調用了Add(-1),所以Done的邏輯參考Add的邏輯。
Wait函數(shù)的主要邏輯也是2個:
waiter的值遞增。
等待信號量,阻塞獲取許可,獲取到以后就返回,函數(shù)結束。
歸納WaitGroup的實現(xiàn),本質上是圍繞信號量實現(xiàn)的,但是什么時候釋放,釋放幾個,什么時候獲取信號量,這些都是用state1這個field來實現(xiàn)的。算法的實現(xiàn)總是需要用數(shù)據(jù)結構作為依托。
內存對齊
關于內存對齊的文章很多,這里并不打算宣兵奪主,還是回到我們的WaitGroup上來。之所以提內存對齊,是因為考慮了內存對齊,所以結構體里的state1是一個長度為3的數(shù)組才有一個合理的解釋。
內存對齊的知識點可能很多,但我們今天只關心其中一個。
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,需要由調用方保證其數(shù)據(jù)地址是64位對齊的,否則原子訪問會有異常。
在閱讀WaitGroup源碼的時候,會注意到,state1在64位架構下,前64位是worker和waiter計數(shù)器,后32位是sema,而在32位架構下,前32位是sema,后64位是worker和waiter計數(shù)器。不得不感嘆思路之精巧、精密,計算機基礎知識之深,我們還有很長的路要走。
參考
Java semaphore:
https://segmentfault.com/a/1190000023038654
WaitGroup:
https://www.ququ123.xyz/2022/04/golang_wait_group_principle/
內存對齊: