我們在前幾次講的互斥鎖、條件變量和原子操作都是最基本重要的同步工具。在 Go 語言中,除了通道之外,它們也算是最為常用的并發安全工具了。
說到通道,不知道你想過沒有,之前在一些場合下裡,我們使用通道的方式看起來都似乎有些蹩腳。
比如:聲明一個通道,使它的容量與我們手動啟用的 goroutine 的數量相同,之後再利用這個通道,讓主 goroutine 等待其他 goroutine 的運作結束。
這一步更具體地說就是:讓其他的 goroutine 在運作結束之前,都向這個通道發送一個元素值,并且,讓主 goroutine 在最後從這個通道中接收元素值,接收的次數需要與其他的 goroutine 的數量相同。
這就是下面的coordinateWithChan函數展示的多 goroutine 協作流程。
其中的addNum函數的聲明在 demo65.go 檔案中。addNum函數會把它接受的最後一個參數值作為其中的defer函數。
我手動啟用的兩個 goroutine 都會調用addNum函數,而它們傳給該函數的最後一個參數值(也就是那個既無參數聲明,也無結果聲明的函數)都隻會做一件事情,那就是向通道sign發送一個元素值。
看到coordinateWithChan函數中最後的那兩行代碼了嗎?重複的兩個接收表達式<-sign,是不是看起來很醜陋?
其實,在這種應用場景下,我們可以選用另外一個同步工具,即:sync包的WaitGroup類型。它比通道更加适合實作這種一對多的 goroutine 協作流程。
sync.WaitGroup類型(以下簡稱WaitGroup類型)是開箱即用的,也是并發安全的。同時,與我們前面讨論的幾個同步工具一樣,它一旦被真正使用就不能被複制了。
WaitGroup類型擁有三個指針方法:Add、Done和Wait。你可以想象該類型中有一個計數器,它的預設值是0。我們可以通過調用該類型值的Add方法來增加,或者減少這個計數器的值。
一般情況下,我會用這個方法來記錄需要等待的 goroutine 的數量。相對應的,這個類型的Done方法,用于對其所屬值中計數器的值進行減一操作。我們可以在需要等待的 goroutine 中,通過defer語句調用它。
而此類型的Wait方法的功能是,阻塞目前的 goroutine,直到其所屬值中的計數器歸零。如果在該方法被調用的時候,那個計數器的值就是0,那麼它将不會做任何事情。
你可能已經看出來了,WaitGroup類型的值(以下簡稱WaitGroup值)完全可以被用來替換coordinateWithChan函數中的通道sign。下面的coordinateWithWaitGroup函數就是它的改造版本。
很明顯,整體代碼少了好幾行,而且看起來也更加簡潔了。這裡我先聲明了一個WaitGroup類型的變量wg。然後,我調用了它的Add方法并傳入了2,因為我會在後面啟用兩個需要等待的 goroutine。
由于wg變量的Done方法本身就是一個既無參數聲明,也無結果聲明的函數,是以我在go語句中調用addNum函數的時候,可以直接把該方法作為最後一個參數值傳進去。
在coordinateWithWaitGroup函數的最後,我調用了wg的Wait方法。如此一來,該函數就可以等到那兩個 goroutine 都運作結束之後,再結束執行了。
以上就是WaitGroup類型最典型的應用場景了。不過不能止步于此,對于這個類型,我們還是有必要再深入了解一下的。我們一起看下面的問題。
問題:sync.WaitGroup類型值中計數器的值可以小于0嗎?
這裡的典型回答是:不可以。
為什麼不可以呢,我們解析一下。之是以說WaitGroup值中計數器的值不能小于0,是因為這樣會引發一個 panic。 不适當地調用這類值的Done方法和Add方法都會如此。别忘了,我們在調用Add方法的時候是可以傳入一個負數的。
實際上,導緻WaitGroup值的方法抛出 panic 的原因不隻這一種。
你需要知道,在我們聲明了這樣一個變量之後,應該首先根據需要等待的 goroutine,或者其他事件的數量,調用它的Add方法,以使計數器的值大于0。這是確定我們能在後面正常地使用這類值的前提。
如果我們對它的Add方法的首次調用,與對它的Wait方法的調用是同時發起的,比如,在同時啟用的兩個 goroutine 中,分别調用這兩個方法,那麼就有可能會讓這裡的Add方法抛出一個 panic。
這種情況不太容易複現,也正因為如此,我們更應該予以重視。是以,雖然WaitGroup值本身并不需要初始化,但是盡早地增加其計數器的值,還是非常有必要的。
另外,你可能已經知道,WaitGroup值是可以被複用的,但需要保證其計數周期的完整性。這裡的計數周期指的是這樣一個過程:該值中的計數器值由0變為了某個正整數,而後又經過一系列的變化,最終由某個正整數又變回了0。
也就是說,隻要計數器的值始于0又歸為0,就可以被視為一個計數周期。在一個此類值的生命周期中,它可以經曆任意多個計數周期。但是,隻有在它走完目前的計數周期之後,才能夠開始下一個計數周期。

(sync.WaitGroup 的計數周期)
是以,也可以說,如果一個此類值的Wait方法在它的某個計數周期中被調用,那麼就會立即阻塞目前的 goroutine,直至這個計數周期完成。在這種情況下,該值的下一個計數周期,必須要等到這個Wait方法執行結束之後,才能夠開始。
如果在一個此類值的Wait方法被執行期間,跨越了兩個計數周期,那麼就會引發一個 panic。
例如,在目前的 goroutine 因調用此類值的Wait方法,而被阻塞的時候,另一個 goroutine 調用了該值的Done方法,并使其計數器的值變為了0。
這會喚醒目前的 goroutine,并使它試圖繼續執行Wait方法中其餘的代碼。但在這時,又有一個 goroutine 調用了它的Add方法,并讓其計數器的值又從0變為了某個正整數。此時,這裡的Wait方法就會立即抛出一個 panic。
縱觀上述會引發 panic 的後兩種情況,我們可以總結出這樣一條關于WaitGroup值的使用禁忌,即:不要把增加其計數器值的操作和調用其Wait方法的代碼,放在不同的 goroutine 中執行。換句話說,要杜絕對同一個WaitGroup值的兩種操作的并發執行。
除了第一種情況外,我們通常需要反複地實驗,才能夠讓WaitGroup值的方法抛出 panic。再次強調,雖然這不是每次都發生,但是在長期運作的程式中,這種情況發生的機率還是不小的,我們必須要重視它們。
如果你對複現這些異常情況感興趣,那麼可以參看sync代碼包中的 waitgroup_test.go 檔案。其中的名稱以TestWaitGroupMisuse為字首的測試函數,很好地展示了這些異常情況的發生條件。你可以模仿這些測試函數自己寫一些測試代碼,執行一下試試看。
與sync.WaitGroup類型一樣,sync.Once類型(以下簡稱Once類型)也屬于結構體類型,同樣也是開箱即用和并發安全的。由于這個類型中包含了一個sync.Mutex類型的字段,是以,複制該類型的值也會導緻功能的失效。
Once類型的Do方法隻接受一個參數,這個參數的類型必須是func(),即:無參數聲明和結果聲明的函數。
該方法的功能并不是對每一種參數函數都隻執行一次,而是隻執行“首次被調用時傳入的”那個函數,并且之後不會再執行任何參數函數。
是以,如果你有多個隻需要執行一次的函數,那麼就應該為它們中的每一個都配置設定一個sync.Once類型的值(以下簡稱Once值)。
Once類型中還有一個名叫done的uint32類型的字段。它的作用是記錄其所屬值的Do方法被調用的次數。不過,該字段的值隻可能是0或者1。一旦Do方法的首次調用完成,它的值就會從0變為1。
你可能會問,既然done字段的值不是0就是1,那為什麼還要使用需要四個位元組的uint32類型呢?
原因很簡單,因為對它的操作必須是“原子”的。Do方法在一開始就會通過調用atomic.LoadUint32函數來擷取該字段的值,并且一旦發現該值為1,就會直接傳回。這也初步保證了“Do方法,隻會執行首次被調用時傳入的函數”。
不過,單憑這樣一個判斷的保證是不夠的。因為,如果有兩個 goroutine 都調用了同一個新的Once值的Do方法,并且幾乎同時執行到了其中的這個條件判斷代碼,那麼它們就都會因判斷結果為false,而繼續執行Do方法中剩餘的代碼。
在這個條件判斷之後,Do方法會立即鎖定其所屬值中的那個sync.Mutex類型的字段m。然後,它會在臨界區中再次檢查done字段的值,并且僅在條件滿足時,才會去調用參數函數,以及用原子操作把done的值變為1。
如果你熟悉 GoF 設計模式中的單例模式的話,那麼肯定能看出來,這個Do方法的實作方式,與那個單例模式有很多相似之處。它們都會先在臨界區之外,判斷一次關鍵條件,若條件不滿足則立即傳回。這通常被稱為 “快路徑”,或者叫做“快速失敗路徑”。
如果條件滿足,那麼到了臨界區中還要再對關鍵條件進行一次判斷,這主要是為了更加嚴謹。這兩次條件判斷常被統稱為(跨臨界區的)“雙重檢查”。
由于進入臨界區之前,肯定要鎖定保護它的互斥鎖m,顯然會降低代碼的執行速度,是以其中的第二次條件判斷,以及後續的操作就被稱為“慢路徑”或者“正常路徑”。
别看Do方法中的代碼不多,但它卻應用了一個很經典的程式設計範式。我們在 Go 語言及其标準庫中,還能看到不少這個經典範式及它衍生版本的應用案例。
下面我再來說說這個Do方法在功能方面的兩個特點。
第一個特點,由于Do方法隻會在參數函數執行結束之後把done字段的值變為1,是以,如果參數函數的執行需要很長時間或者根本就不會結束(比如執行一些守護任務),那麼就有可能會導緻相關 goroutine 的同時阻塞。
例如,有多個 goroutine 并發地調用了同一個Once值的Do方法,并且傳入的函數都會一直執行而不結束。那麼,這些 goroutine 就都會因調用了這個Do方法而阻塞。因為,除了那個搶先執行了參數函數的 goroutine 之外,其他的 goroutine 都會被阻塞在鎖定該Once值的互斥鎖m的那行代碼上。
第二個特點,Do方法在參數函數執行結束後,對done字段的指派用的是原子操作,并且,這一操作是被挂在defer語句中的。是以,不論參數函數的執行會以怎樣的方式結束,done字段的值都會變為1。
也就是說,即使這個參數函數沒有執行成功(比如引發了一個 panic),我們也無法使用同一個Once值重新執行它了。是以,如果你需要為參數函數的執行設定重試機制,那麼就要考慮Once值的适時替換問題。
在很多時候,我們需要依據Do方法的這兩個特點來設計與之相關的流程,以避免不必要的程式阻塞和功能缺失。
sync代碼包的WaitGroup類型和Once類型都是非常易用的同步工具。它們都是開箱即用和并發安全的。
利用WaitGroup值,我們可以很友善地實作一對多的 goroutine 協作流程,即:一個分發子任務的 goroutine,和多個執行子任務的 goroutine,共同來完成一個較大的任務。
在使用WaitGroup值的時候,我們一定要注意,千萬不要讓其中的計數器的值小于0,否則就會引發 panic。
另外,我們最好用“先統一Add,再并發Done,最後Wait”這種标準方式,來使用WaitGroup值。 尤其不要在調用Wait方法的同時,并發地通過調用Add方法去增加其計數器的值,因為這也有可能引發 panic。
Once值的使用方式比WaitGroup值更加簡單,它隻有一個Do方法。同一個Once值的Do方法,永遠隻會執行第一次被調用時傳入的參數函數,不論這個函數的執行會以怎樣的方式結束。
隻要傳入某個Do方法的參數函數沒有結束執行,任何之後調用該方法的 goroutine 就都會被阻塞。隻有在這個參數函數執行結束以後,那些 goroutine 才會逐一被喚醒。
Once類型使用互斥鎖和原子操作實作了功能,而WaitGroup類型中隻用到了原子操作。 是以可以說,它們都是更高層次的同步工具。它們都基于基本的通用工具,實作了某一種特定的功能。sync包中的其他進階同步工具,其實也都是這樣的。
今天的思考題是:在使用WaitGroup值實作一對多的 goroutine 協作流程時,怎樣才能讓分發子任務的 goroutine 獲得各個子任務的具體執行結果?
https://github.com/MingsonZheng/go-core-demo
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協定進行許可。
歡迎轉載、使用、重新釋出,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用于商業目的,基于本文修改後的作品務必以相同的許可釋出。