- 原文地址:Part 25: Mutex
- 原文作者:Naveen R
- 譯者:咔嘰咔嘰 轉載請註明出處。
在本教程中,我們將瞭解互斥鎖Mutex
。我們還將學習如何使用Mutex
和channel
解決競態條件。
臨界區
在瞭解互斥鎖之前,先了解併發程式設計中臨界區的概念非常重要。當程式併發執行時,多個Goroutines
不應該同時擁有修改共享記憶體的許可權。修改共享記憶體的這部分程式碼則稱為臨界區。例如,假設我們有一段程式碼將變數 x 遞增 1。
x = x + 1
複製程式碼
如果上面一段程式碼被一個Goroutine
訪問,就不會有任何問題。
讓我們看看為什麼當有多個Goroutines
併發執行時,這段程式碼會失敗。為簡單起見,我們假設我們有 2 個Goroutines
併發執行上面的程式碼行。
上面的程式碼將按以下步驟執行(有更多技術細節涉及暫存器,如何新增任務等等,但為了本教程的簡便,我們假設都是第三步),
- 獲取當前
x
的值- 計算
x + 1
- 把第二步計算的值賦給
x
當這三個步驟僅由一個Goroutine
進行時,結果沒什麼問題。
讓我們看看當兩個Goroutines
併發執行此程式碼時會發生什麼。下圖描繪了當兩個Goroutines
併發訪問程式碼行x = x + 1
時可能發生的情況。
我們假設x
的初始值為 0。Goroutine 1
獲取x
的初始值,計算x + 1
,在它將計算值賦值給x
之前,系統切換到Goroutine 2
。現在Goroutine 2
獲取的x
的值仍為 0,然後計算x + 1
。此時系統再次切回到Goroutine 1
。現在Goroutine 1
將其計算值 1 賦值給x
,因此x
變為 1。然後Goroutine 2
再次開始執行然後賦值計算值,然後把 1 賦值給x
,因此在兩個Goroutines
執行後x
為 1。
現在讓我們看看可能發生的不同情況。
在上面的場景中,Goroutine 1
開始執行並完成所有的三個步驟,因此x
的值變為 1。然後Goroutine 2
開始執行。現在x
的值為 1,當Goroutine 2
完成執行時,x
的值為 2。
因此,在這兩種情況下,可以看到x
的最終值為 1 或者 2,具體取決於協程如何切換。這種程式的輸出取決於Goroutines
的執行順序的情況,稱為競態條件。
在上面的場景中,如果在任何時間點只允許一個Goroutine
訪問臨界區,則可以避免競態條件。這可以通過使用 Mutex 實現。
Mutex
互斥鎖
Mutex
用於提供鎖定機制,以確保在任何時間點只有一個Goroutine
執行程式碼的臨界區,以防止發生競態條件。
sync
包中提供了Mutex
。Mutex
上定義了兩種方法,即鎖定Lock
和UnLock
。在Lock
和UnLock
的呼叫之間的任何程式碼將只能由一個Goroutine
執行,從而避免競態條件。
mutex.Lock()
x = x + 1
mutex.Unlock()
複製程式碼
在上面的程式碼中,x = x + 1
將僅由一個Goroutine
執行。
如果一個Goroutine
已經持有鎖,當一個新的Goroutine
試圖獲取鎖的時候,新的Goroutine
將被阻塞直到互斥鎖被解鎖。
擁有競態條件的程式
在本節中,我們將編寫一個有競態條件的程式,在接下來的部分中我們將修復競態條件。
package main
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup) {
x = x + 1
wg.Done()
}
func main() {
var w sync.WaitGroup
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w)
}
w.Wait()
fmt.Println("final value of x", x)
}
複製程式碼
在上面的程式中,第 7 行的increment
函式將x
的值遞增 1,然後呼叫WaitGroup
上的Done
以通知main Goroutine
任務完成。
我們在第 15 行生成 1000 個increment Goroutines
。這些Goroutines
中的每一個都併發執行,當多個Goroutines
嘗試同時訪問x
的值,並且計算x + 1
時會出現競態條件。
最好在本地執行此程式,因為playgroud
是確定性的不會出現競態條件。在本地計算機上多次執行此程式,您可以看到由於競態條件,每次輸出都會不同。我遇到的一些輸出是final value of x 941, final value of x 928, final value of x 922
等等。
使用互斥鎖 Mutex
解決競態條件
在上面的程式中,我們生成了 1000 個Goroutines
。如果每個都將x
的值加 1,則x
的最終期望值應該為 1000。在本節中,我們將使用互斥鎖 Mutex
修復上述程式中的競態條件。
package main
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup, m *sync.Mutex) {
m.Lock()
x = x + 1
m.Unlock()
wg.Done()
}
func main() {
var w sync.WaitGroup
var m sync.Mutex
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, &m)
}
w.Wait()
fmt.Println("final value of x", x)
}
複製程式碼
Mutex
是一種結構型別,我們在第一行中初始化了一個Mutex
型別的變數m
。 在上面的程式中,我們修改了increment
函式,使x = x + 1
的程式碼在m.Lock()
和m.Unlock()
之間。現在這段程式碼沒有任何競態條件,因為在任何時候只允許一個Goroutine
執行臨界區。
現在如果執行該程式,它將輸出,
final value of x 1000
複製程式碼
在第 18 行傳遞互斥鎖的地址非常重要。如果通過值傳遞互斥鎖而不是地址傳遞,則每個Goroutine
都將擁有自己的互斥鎖副本,那麼肯定還會發生競態條件。
使用通道channel
解決競態條件
我們也可以使用channel
解決競態條件。讓我們看看這是如何完成的。
package main
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup, ch chan bool) {
ch <- true
x = x + 1
<- ch
}
func main() {
var w sync.WaitGroup
ch := make(chan bool, 1)
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, ch)
wg.Done()
}
w.Wait()
fmt.Println("final value of x", x)
}
複製程式碼
在上面的程式中,我們建立了一個容量為 1 的緩衝channel
,並將其傳遞給第 18 行的increment Goroutine
。 此緩衝channel
通過將true
傳遞給ch
來實現確保只有一個Goroutine
訪問臨界區的。在x
遞增之前,由於緩衝channel
的容量為 1,因此嘗試寫入此channel
的所有其他Goroutines
都會被阻塞,直到第 10 行將ch
的值取出來。 使用這種方式,實現了只允許一個 Goroutine 訪問臨界區。
程式輸出,
final value of x 1000
複製程式碼
互斥鎖Mutex
VS 通道channel
我們使用互斥鎖Mutex
和通道channel
解決了競態條件問題。那麼我們怎麼決定何時使用哪個?答案在於您要解決的問題。如果您嘗試解決的問題更適合互斥鎖Mutex
,那麼請繼續使用Mutex
。如果需要,請不要猶豫地使用Mutex
。如果問題似乎更適合通道channel
,那麼使用它:)。
大多數 Go 新手嘗試使用channel
解決遇到的併發問題,因為它是該語言的一個很酷的功能。這是錯的。該語言為我們提供了Mutex
或Channel
的選項,並且選擇任何一種都沒有錯。
一般情況下,當Goroutine
需要相互通訊時使用channel
,當Goroutine
只訪問程式碼的臨界區時,使用Mutex
。
在我們上面那些問題的情況下,我寧願使用Mutex
,因為這個問題不需要goroutines
之間進行任何通訊。因此Mutex
是一種自然的選擇。
我的建議是選擇工具去解決問題,而不要為了工具去適應問題:)