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