Go 学习笔记(66)— Go 并发同步原语(sync.Mutex、sync.RWMutex、sync.Once)
1. 競態條件
一旦數據被多個線程共享,那么就很可能會產生爭用和沖突的情況。這種情況也被稱為競態條件(race condition),這往往會破壞共享數據的一致性。
舉個例子,同時有多個線程連續向同一個緩沖區寫入數據塊,如果沒有一個機制去協調這些線程的寫入操作的話,那么被寫入的數據塊就很可能會出現錯亂。
比如,在線程 A 還沒有寫完一個數據塊的時候,線程 B 就開始寫入另外一個數據塊了。
顯然,這兩個數據塊中的數據會被混在一起,并且已經很難分清了。因此,在這種情況下,我們就需要采取一些措施來協調它們對緩沖區的修改。這通常就會涉及同步。
2. 同步作用
概括來講,同步的用途有兩個,
- 一個是避免多個線程在同一時刻操作同一個數據塊,
 - 另一個是協調多個線程,以避免它們在同一時刻執行同一個代碼塊。
 
我們所說的同步其實就是在控制多個線程對共享資源的訪問。
3. 臨界區
一個線程在想要訪問某一個共享資源的時候,需要先申請對該資源的訪問權限,并且只有在申請成功之后,訪問才能真正開始。而當線程對共享資源的訪問結束時,它還必須歸還對該資源的訪問權限,若要再次訪問仍需申請。
如果針對某個共享資源的訪問令牌只有一塊,那么在同一時刻,就最多只能有一個線程進入到那個區域,并訪問到該資源。這時,我們可以說,多個并發運行的線程對這個共享資源的訪問是完全串行的。
只要一個代碼片段需要實現對共享資源的串行化訪問,就可以被視為一個臨界區(critical section),也就是我剛剛說的,由于要訪問到資源而必須進入的那個區域。
比如,在我前面舉的那個例子中,實現了數據塊寫入操作的代碼就共同組成了一個臨界區。如果針對同一個共享資源,這樣的代碼片段有多個,那么它們就可以被稱為相關臨界區。它們可以是一個內含了共享數據的結構體及其方法,也可以是操作同一塊共享數據的多個函數。
臨界區總是需要受到保護的,否則就會產生競態條件。施加保護的重要手段之一,就是使用實現了某種同步機制的工具,也稱為同步工具。
4. Go 中的同步工具
4.1 sync.Mutex
在 Go 語言中,可供我們選擇的同步工具并不少。其中,最重要且最常用的同步工具當屬互斥量(mutual exclusion,簡稱 mutex)。sync 包中的 Mutex 就是與其對應的類型,該類型的值可以被稱為互斥量或者互斥鎖。
一個互斥鎖可以被用來保護一個臨界區或者一組相關臨界區。我們可以通過它來保證,在同一時刻只有一個 goroutine 處于該臨界區之內。
為了兌現這個保證,每當有 goroutine 想進入臨界區時,都需要先對它進行鎖定,并且,每個 goroutine 離開臨界區時,都要及時地對它進行解鎖。
鎖定操作可以通過調用互斥鎖的 Lock 方法實現,而解鎖操作可以調用互斥鎖的 Unlock 方法
使用互斥鎖的注意事項如下:
- 不要重復鎖定互斥鎖;
 - 不要忘記解鎖互斥鎖,必要時使用defer語句;
 - 不要對尚未鎖定或者已解鎖的互斥鎖解鎖;
 - 不要在多個函數之間直接傳遞互斥鎖。
 
一旦,你把一個互斥鎖同時用在了多個地方,就必然會有更多的 goroutine 爭用這把鎖。這不但會讓你的程序變慢,還會大大增加死鎖(deadlock)的可能性。
所謂的死鎖,指的就是當前程序中的主 goroutine,以及我們啟用的那些 goroutine 都已經被阻塞。這些 goroutine 可以被統稱為用戶級的 goroutine。這就相當于整個程序都已經停滯不前了。
死鎖程序是所有并發進程彼此等待的程序。在這種情況下,如果沒有外界的干預,這個程序將永遠無陸恢復。
Go 語言運行時系統是不允許這種情況出現的,只要它發現所有的用戶級 goroutine 都處于等待狀態,就會自行拋出一個帶有如下信息的 panic:
fatal error: all goroutines are asleep - deadlock!
 
注意,這種由 Go 語言運行時系統自行拋出的 panic 都屬于致命錯誤,都是無法被恢復的,調用 recover 函數對它們起不到任何作用。也就是說,一旦產生死鎖,程序必然崩潰。
忘記解鎖導致的問題有時候是比較隱秘的,并不會那么快就暴露出來。這也是我們需要特別關注它的原因。相比之下,解鎖未鎖定的互斥鎖會立即引發 panic。并且,與死鎖導致的 panic 一樣,它們是無法被恢復的。
因此,我們總是應該保證,對于每一個鎖定操作,都要有且只有一個對應的解鎖操作。
Go 語言中的互斥鎖是開箱即用的。換句話說,一旦我們聲明了一個 sync.Mutex 類型的變量,就可以直接使用它了。不過要注意,該類型是一個結構體類型,屬于值類型中的一種。把它傳給一個函數、將它從函數中返回、把它賦給其他變量、讓它進入某個通道都會導致它的副本的產生。并且,原值和它的副本,以及多個副本之間都是完全獨立的,它們都是不同的互斥鎖。
如果你把一個互斥鎖作為參數值傳給了一個函數,那么在這個函數中對傳入的鎖的所有操作,都不會對存在于該函數之外的那個原鎖產生任何的影響。
package mainimport ("fmt""time"
)var sum int = 0func main() {for i := 0; i < 1000; i++ {go add(10)}time.Sleep(2 * time.Second)fmt.Println("sum is ", sum)}func add(i int) {sum += i
}
 
這段代碼循環 1000 次,每次給 sum 加 10,理論上應該是 10000,但是執行結果為 8380、或者 9010 或者 9130 等。
原因是多個 go 語句并發地對 sum 進行加 10 操作,不能保證每次取的值就是上一次執行的結果。
使用互斥鎖的代碼示例:
package mainimport ("fmt""sync""time"
)var (sum   int = 0mutex sync.Mutex
)func main() {for i := 0; i < 1000; i++ {go add(10)}time.Sleep(2 * time.Second)fmt.Println("sum is ", sum)}func add(i int) {mutex.Lock()sum += imutex.Unlock()
} 
Mutex 的 Lock 和 Unlock 方法總是成對出現,而且要確保 Lock 獲得鎖后,一定執行 UnLock 釋放鎖,所以在函數或者方法中會采用 defer 語句釋放鎖,如下面的代碼所示:
func add(i int) {mutex.Lock()defer mutex.Unlock()sum += i
}
 
4.2 sync.RWMutex
讀寫鎖是讀 / 寫互斥鎖的簡稱。在 Go 語言中,讀寫鎖由 sync.RWMutex 類型的值代表。與 sync.Mutex 類型一樣,這個類型也是開箱即用的。
顧名思義,讀寫鎖是把對共享資源的“讀操作”和“寫操作”區別對待了。它可以對這兩種操作施加不同程度的保護。換句話說,相比于互斥鎖,讀寫鎖可以實現更加細膩的訪問控制。
一個讀寫鎖中實際上包含了兩個鎖,即:讀鎖和寫鎖。sync.RWMutex 類型中的 Lock 方法和 Unlock 方法分別用于對寫鎖進行鎖定和解鎖,而它的RLock 方法和 RUnlock 方法則分別用于對讀鎖進行鎖定和解鎖。
另外,對于同一個讀寫鎖來說有如下規則。
- 在寫鎖已被鎖定的情況下再試圖鎖定寫鎖,會阻塞當前的 
goroutine。 - 在寫鎖已被鎖定的情況下試圖鎖定讀鎖,會阻塞當前的 
goroutine。 - 在讀鎖已被鎖定的情況下試圖鎖定寫鎖,會阻塞當前的 
goroutine。 - 在讀鎖已被鎖定的情況下再試圖鎖定讀鎖,并不會阻塞當前的 
goroutine。 
換一個角度來說,對于某個受到讀寫鎖保護的共享資源,
- 多個寫操作不能同時進行
 - 寫操作和讀操作也不能同時進行
 - 多個讀操作卻可以同時進行
 
再來看另一個方面。對寫鎖進行解鎖,會喚醒“所有因試圖鎖定讀鎖,而被阻塞的 goroutine”,并且,這通常會使它們都成功完成對讀鎖的鎖定。
然而,對讀鎖進行解鎖,只會在沒有其他讀鎖鎖定的前提下,喚醒“因試圖鎖定寫鎖,而被阻塞的 goroutine”;并且,最終只會有一個被喚醒的 goroutine 能夠成功完成對寫鎖的鎖定,其他的 goroutine 還要在原處繼續等待。至于是哪一個 goroutine,那就要看誰的等待時間最長了。
除此之外,讀寫鎖對寫操作之間的互斥,其實是通過它內含的一個互斥鎖實現的。因此,也可以說,Go 語言的讀寫鎖是互斥鎖的一種擴展。
最后,需要強調的是,與互斥鎖類似,解鎖“讀寫鎖中未被鎖定的寫鎖”,會立即引發 panic,對于其中的讀鎖也是如此,并且同樣是不可恢復的。
不過,正因為如此,我們可以使用它對共享資源的操作,實行更加細膩的控制。另外,由于這里的讀寫鎖是互斥鎖的一種擴展,所以在有些方面它還是沿用了互斥鎖的行為模式。比如,在解鎖未鎖定的寫鎖或讀鎖時的表現,又比如,對寫操作之間互斥的實現方式。
最后,需要特別注意的是,無論是互斥鎖還是讀寫鎖,我們都不要試圖去解鎖未鎖定的鎖,因為這樣會引發不可恢復的 panic。
package mainimport ("fmt""sync""time"
)var (sum   int = 0mutex sync.Mutex
)func main() {for i := 0; i < 1000; i++ {go add(10)}for i := 0; i < 10; i++ {go readSum()}time.Sleep(2 * time.Second)}func readSum() {ret := sumfmt.Println("ret is ", ret)
}func add(i int) {mutex.Lock()sum += imutex.Unlock()
}
 
這個示例開啟了 10 個協程,它們同時讀取 sum 的值。因為 readSum 函數并沒有任何加鎖控制,所以它不是并發安全的,即一個 goroutine 正在執行 sum += i 操作的時候,另一個 goroutine 可能正在執行 ret := sum 操作,這就會導致讀取的 num 值是一個過期的值,結果不可預期。
如果要解決以上資源競爭的問題,可以使用互斥鎖 sync.Mutex,如下面的代碼所示:
func readSum() {mutex.Lock()ret := summutex.Unlock()fmt.Println("ret is ", ret)
}
 
因為 add 和 readSum 函數使用的是同一個 sync.Mutex,所以它們的操作是互斥的,也就是一個 goroutine 進行修改操作 sum+=i 的時候,另一個 gouroutine 讀取 sum 的操作 ret := sum 會等待,直到修改操作執行完畢。
現在我們解決了多個 goroutine 同時讀寫的資源競爭問題,但是又遇到另外一個問題——性能。因為每次讀寫共享資源都要加鎖,所以性能低下,這該怎么解決呢?
現在我們分析讀寫這個特殊場景,有以下幾種情況:
-  
寫的時候不能同時讀,因為這個時候讀取的話可能讀到臟數據(不正確的數據);
 -  
讀的時候不能同時寫,因為也可能產生不可預料的結果;
 -  
讀的時候可以同時讀,因為數據不會改變,所以不管多少個
goroutine讀都是并發安全的。 
所以就可以通過讀寫鎖 sync.RWMutex 來優化這段代碼,提升性能?,F在我將以上示例改為讀寫鎖,來實現我們想要的結果,如下所示:
var rwmutex sync.RWMutexfunc readSum() {rwmutex.RLock()ret := sumrwmutex.RUnlock()fmt.Println("ret is ", ret)
}
 
對比互斥鎖的示例,讀寫鎖的改動有兩處:
-  
把鎖的聲明換成讀寫鎖
sync.RWMutex。 -  
把函數 readSum 讀取數據的代碼換成讀鎖,也就是
RLock和RUnlock。 
這樣性能就會有很大的提升,因為多個 goroutine 可以同時讀數據,不再相互等待。
4.3 sync.Once
在實際的工作中,你可能會有這樣的需求:讓代碼只執行一次,哪怕是在高并發的情況下,比如創建一個單例。
針對這種情形,Go 語言為我們提供了 sync.Once 來保證代碼只執行一次,如下所示:
package mainimport ("fmt""sync"
)func main() {var once sync.Oncedone := make(chan bool)for i := 0; i < 10; i++ {go func() {//把要執行的函數(方法)作為參數傳給once.Do方法即可once.Do(greet)done <- true}()}for i := 0; i < 10; i++ {<-done}
}func greet() {fmt.Println("hello world")
} 
輸出結果:
hello world
 
這是 Go 語言自帶的一個示例,雖然啟動了 10 個協程來執行 greet 函數,但是因為用了 once.Do 方法,所以函數 greet 只會被執行一次。也就是說在高并發的情況下,sync.Once 也會保證 greet 函數只執行一次。
sync.Once 適用于創建某個對象的單例、只加載一次的資源等只執行一次的場景。
如果沒有調用 once.Do 的方法,如下則會執行 10 次 greet 函數。
package mainimport ("fmt"
)func main() {// var once sync.Oncedone := make(chan bool)for i := 0; i < 10; i++ {go func() {//把要執行的函數(方法)作為參數傳給once.Do方法即可// once.Do(greet)greet()done <- true}()}for i := 0; i < 10; i++ {<-done}
}func greet() {fmt.Println("hello world")
} 
問題:sync.Once類型值的Do方法是怎么保證只執行參數函數一次的?
與 sync.WaitGroup 類型一樣,sync.Once 類型(以下簡稱 Once 類型)也屬于結構體類型,同樣也是開箱即用和并發安全的。
由于這個類型中包含了一個 sync.Mutex 類型的字段,所以,復制該類型的值也會導致功能的失效。
Once 類型的 Do 方法只接受一個參數,這個參數的類型必須是 func() ,即:無參數聲明和結果聲明的函數。該方法的功能并不是對每一種參數函數都只執行一次,而是只執行“首次被調用時傳入的”那個函數,并且之后不會再執行任何參數函數。
如下代碼:
func main() {var count intincrement := func() { count++ }decrement := func() {fmt.Println("enter decrement")count--}var once sync.Onceonce.Do(increment)once.Do(decrement)fmt.Printf("count is %v", count)
}
 
打印結果為:
count is 1
 
可以看到 count 并不是 0,同時呢我們發現 decrement 函數中并沒有進去,所以就驗證了上面的說法。once 只執行一次 Do 方法。
所以,如果你有多個只需要執行一次的函數,那么就應該為它們中的每一個都分配一個 sync.Once 類型的值(以下簡稱 Once 值)。
func main() {var count intincrement := func() { count++ }decrement := func() {fmt.Println("enter decrement")count--}var onceIn sync.Once // 給 increment 函數聲明一個 Once 類型值var onceDe sync.Once // 給 decrement 函數聲明一個 Once 類型值onceIn.Do(increment)onceDe.Do(decrement)fmt.Printf("count is %v", count)
}
 
輸出結果為:
enter decrement
count is 0
 
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。
下面我再來說說這個 Do方法在功能方面的兩個特點。
第一個特點,由于 Do 方法只會在參數函數執行結束之后把 done 字段的值變為 1,因此,如果參數函數的執行需要很長時間或者根本就不會結束(比如執行一些守護任務),那么就有可能會導致相關 goroutine 的同時阻塞。例如,有多個 goroutine 并發地調用了同一個Once 值的 Do 方法,并且傳入的函數都會一直執行而不結束。那么,這些 goroutine 就都會因調用了這個 Do 方法而阻塞。因為,除了那個搶先執行了參數函數的 goroutine 之外,其他的 goroutine 都會被阻塞在鎖定該 Once 值的互斥鎖 m 的那行代碼上。
第二個特點,Do 方法在參數函數執行結束后,對 done 字段的賦值用的是原子操作,并且,這一操作是被掛在 defer 語句中的。因此,不論參數函數的執行會以怎樣的方式結束,done 字段的值都會變為 1。也就是說,即使這個參數函數沒有執行成功(比如引發了一個 panic),我們也無法使用同一個 Once 值重新執行它了。所以,如果你需要為參數函數的執行設定重試機制,那么就要考慮 Once 值的適時替換問題。在很多時候,我們需要依據 Do 方法的這兩個特點來設計與之相關的流程,以避免不必要的程序阻塞和功能缺失。
Once 值的使用方式比 WaitGroup 值更加簡單,它只有一個 Do 方法。同一個 Once 值的 Do 方法,永遠只會執行第一次被調用時傳入的參數函數,不論這個函數的執行會以怎樣的方式結束。只要傳入某個 Do 方法的參數函數沒有結束執行,任何之后調用該方法的 goroutine 就都會被阻塞。只有在這個參數函數執行結束以后,那些 goroutine 才會逐一被喚醒。Once 類型使用互斥鎖和原子操作實現了功能,而 WaitGroup 類型中只用到了原子操作。
總結
以上是生活随笔為你收集整理的Go 学习笔记(66)— Go 并发同步原语(sync.Mutex、sync.RWMutex、sync.Once)的全部內容,希望文章能夠幫你解決所遇到的問題。
                            
                        - 上一篇: 2022-2028年中国氟硅橡胶产业发展
 - 下一篇: 2022-2028年中国半导体用环氧塑封