《GO併發程式設計實戰》—— WaitGroup
宣告:本文是《Go併發程式設計實戰》的樣章,感謝圖靈授權併發程式設計網站釋出樣章,
我們在第6章多次提到過sync.WaitGroup型別和它的方法。sync.WaitGroup型別的值也是開箱即用的。例如,在宣告
var wg sync.WaitGroup
之後,我們就可以直接正常使用wg變量了。該型別有三個指標方法,即Add、Done和Wait。
型別sync.WaitGroup是一個結構體型別。在它之中有一個代表計數的欄位。當一個sync.WaitGroup型別的變數被宣告之後,其值中的那個計數值將會是0。我們可以通過該值的Add方法增大或減少其中的計數值。例如:
wg.Add(3)
或
wg.Add(-3)
雖然Add方法接受一個int型別的值,並且我們也可以通過該方法減少計數值,但是我們一定不要讓計數值變為負數。因為這樣會立即引發一個執行恐慌。這也代表著我們對sync.WaitGroup型別值的錯誤使用。
除了呼叫sync.WaitGroup型別值的Add方法並傳入一個負數之外,我們還可以通過呼叫該值的Done來使其中的計數值減一。也就是說,下面這三條語句與wg.Add(-3)的執行效果是一致的:
wg.Done() wg.Done() wg.Done()
使用該方法禁忌與Add方法的一樣——不要讓相應的計數值變為負數。例如,這段程式碼中的第5條語句會引發一個執行時恐慌:
var wg sync.WaitGroup wg.Add(2) wg.Done() wg.Done() wg.Done()
我們現在知道,使用sync.WaitGroup型別值的Add方法和Done方法可以變更其中的計數值。那麼變更這個計數值有什麼用呢?
當我們呼叫sync.WaitGroup型別值的Wait方法的時候,它會去檢查該值中的計數值。如果這個計數值為0,那麼該方法會立即返回,且不會對程式的執行產生任何影響。 但是,如果這個計數值大於0,那麼該方法的呼叫方所屬的那個Goroutine就會被阻塞。直到該計數值重新變為0之時,為此而被阻塞的所有Goroutine才會被喚醒。
這個型別的值一般被用來協調多個Goroutine的執行。假設,在我們的程式中啟用了4個Goroutine,分別是G1、G2、G3和G4。其中,G2、G3和G4是由G1中的程式碼啟用並被用於執行某些特定任務的。G1在啟用這3個Goroutine之後要等待這些特定任務的完成。在這種情況下,我們有兩個方案。
第一個方案是使用前文講到的通道來傳遞任務完成訊號。例如,我們在啟用G2、G3和G4之前宣告這樣一個通道:
sign := make(chan byte, 3)
然後,在G2、G3和G4執行的任務完成之後立即向該通道傳送代表了某個任務已被執行完成的元素值:
go func() { // G2 // 省略若干條語句 sign }() go func() { // G3 // 省略若干條語句 sign }() go func() { // G4 // 省略若干條語句 sign }()
最後,在啟用這幾個Goroutine之後,我們還要在G1執行的函式中新增類似這樣的程式碼以等待相關的任務完成訊號:
for i := 0; i < 3; i++ { fmt.Printf("G%d is ended.\n", <-sign) }
// 省略若干條語句
這樣的方法固然是有效的。上面的這條for語句會等到G2、G3和G4都被執行結束之後才會被執行結束,繼而其後面的語句才會得以執行。sign通道起到了協調這4個Goroutine的執行的作用。
不過,對於這樣一個簡單的協調工作來說,使用通道是否過重了?或者說,通道sign是否被大材小用了?通道的實現中包含了很多專為併發安全的資料而建立的資料結構和演算法。原則上說,我們不應該把通道當做互斥鎖或訊號燈來說用。在這裡使用它並沒有體現出它的優勢,反而會在程式碼易讀性和程式效能方面打一些折扣。
該需求的第二個方案就是使用sync.WaitGroup型別值。對應的程式碼如下:
var wg sync.WaitGroup wg.Add(3) go func() { // G2 // 省略若干條語句 wg.Done() }() go func() { // G3 // 省略若干條語句 wg.Done() }() go func() { // G4 // 省略若干條語句 wg.Done() }() wg.Wait() fmt.Println("G2, G3 and G4 are ended.")
可以看到,我們在啟用G2、G3和G4之前先聲明瞭一個sync.WaitGroup型別的變數wg,並呼叫其值的Add方法以使其中的計數值等於將要額外啟用的Goroutine的個數。然後,在G2、G3和G4的執行即將結束之前,我們分別通過呼叫wg.Done方法將其中的計數值減去1。最後,我們在G1中呼叫wg.Wait方法以等待G2、G3和G4中的那3個對wg.Done方法的呼叫的完成。待這3個呼叫完成之時,在wg.Wait()處被阻塞的G1會被喚醒,它後面的那條語句也會被立即執行。
不論是Add方法還是Done方法,它們在被執行的時候都會在增大或減小其所屬值中的那個計數值之後對它進行判斷。如果該計數值為0,那麼該方法就會喚醒所有已為此而被阻塞的Goroutine(如果有的話)。這些Goroutine即是在從該計數值最近一次變為正整數到此時(即重新變為0)的時間段內執行了同一個sync.WaitGroup型別值的Wait方法的Goroutine。
顯然,我們的第二個方案更加適合這裡的應用場景。它在程式碼的清晰度和效能損耗方面都會更勝一籌。
在這裡,我們可以總結出一些使用一個sync.WaitGroup型別值的方法和規則。
• 對一個sync.WaitGroup型別值的Add方法的的第一次呼叫應該發生在對該值的Done方法進行呼叫之前。因為如果先呼叫了Done方法,那麼就會使該值中的計數值小於0,繼而引發執行時恐慌。由於這兩個方法通常不會在同一個Goroutine中被呼叫,所以呼叫Add方法的時機還應該提前到將會呼叫該值的Done方法的那個或那些Goroutine被啟用之前。
• 對一個sync.WaitGroup型別值的Add方法的第一次呼叫同樣應該發生在對該值的Wait方法進行呼叫之前。如果在我們呼叫Wait方法的時候該值的計數值等於0,那麼該方法將會直接返回而不會阻塞呼叫方所屬的Goroutine。這往往是與我們的期望相反的。
• 在一個sync.WaitGroup型別值的生命週期內,其中的計數值總是由起初的0變為某個正整數(或先後變為某幾個正整數),然後再回歸為0。我們把完成這樣一個變化曲線所用的時間稱為一個計數週期。關於此的一個示意如圖8-1所示。
如圖所示,計數值的每次變化都是由對其所屬值的Add方法或Done方法的呼叫引起的。一個計數週期總是從對其所屬值的Add方法的呼叫開始的,並且也總是以對其所屬值的Add方法或Done方法的呼叫為結束標誌的。我們若在一個計數週期之內(不包含計數值等於0的兩端)呼叫其所屬值的Wait方法則會使呼叫方所在的Goroutine被阻塞,直至該計數週期結束的那一刻。
• sync.WaitGroup型別值是可以被複用的。也就是說,此類值的生命週期可以包含任意個計數週期。一旦一個計數週期結束,我們在前面對該值的那些方法的呼叫所產生的作用也將消失。也就是說,它們不會影響到後續計數週期中的該值的計數值以及參與改變該計數值的各方。換句話講,一個sync.WaitGroup型別值在其每個計數週期中的狀態和作用都是獨立的。
最後,值得說明的是,在sync.WaitGroup型別及其方法中也用到了在前面章節中提到的互斥鎖、原子操作和訊號燈機制。這使得我們總是可以在任意個Goroutine中併發的呼叫同一個sync.WaitGroup型別值的那些方法。也就是說,它們都是併發安全的。
本節所講的sync.WaitGroup型別提供了一種方式,使我們可以對多個Goroutine的執行進行簡單的協調。這得益於它提供的那幾個以計數值為基礎的易用方法,以及它的併發安全特性。只要理解了每個方法對計數值的操縱方式以及意義,我們就可以用好該型別的值了。我們剛剛說明的那些使用方法和規則對理解該型別及其方法應該是非常有幫助的。