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