臨界區
在說到鎖mutex之前,先要理解並發編程中臨界區的概念。當程序並發運行時,修改共享資源的那部分代碼不應該被多個Goroutines訪問,這部分修改共享資源的代碼稱為臨界區。假定我們有如下一行代碼:
x = x + 1
如果上面的代碼段被單個Goroutine訪問,不會出現任何問題。讓我們看看為什麼當有多個Goroutines同時運行時,這段代碼會失敗。為了簡單起見,我們假設我們有2個Goroutines同時運行上述代碼。系統將在以下步驟中執行上述代碼行(還有更多涉及寄存器、加法如何工作等的技術細節,讓我們假設這是三個步驟):
1、獲取 x 的當前值;
2、計算 x + 1;
3、將步驟2中的計算值賦給 x;
當這三個步驟僅由一個Goroutine執行時,一切都很好。
讓我們討論一下當 2 個Goroutines同時運行此代碼時會發生什麼。下圖描述了當兩個 Goroutines 同時訪問代碼行 x = x + 1 時可能發生的情況。
我們假設 x 的初始值為 0。Goroutine 1 獲取 x 的初始值,計算 x + 1,在將計算值分配給 x 之前,系統上下文切換到 Goroutine 2。現在 Goroutine 2 得到 x 的初始值,它仍然是 0,計算 x + 1。在此之後,系統上下文再次切換到 Goroutine1。現在 Goroutine 1 將其計算值 1 分配給 x,因此 x 變為 1。然後 Goroutine2 再次開始執行,然後分配其計算值,該值再次為 1 到 x,因此在兩個 Goroutines 執行後 x 為 1。
現在讓我們看看可能發生的另外不同情況:
在上述場景中,Goroutine 1 開始執行並完成其所有三個步驟,因此 x 的值變為 1。然後 Goroutine 2 開始執行。現在 x 的值是 1,當 Goroutine 2 完成執行時,x 的值是 2。
因此,從這兩種情況下,您可以看到 x 的最終值是 1 或 2,具體取決於上下文切換的發生方式。這種程序的輸出取決於 Goroutines 執行順序的不良情況稱為競爭條件。
在上述場景中,如果只允許一個 Goroutine 在任何時間點訪問代碼的關鍵部分,則可以避免競爭條件。這是通過使用互斥鎖實現的。
Mutex
互斥鎖Mutex用於提供鎖定機制,以確保在任何時間點只有一個 Goroutine 運行代碼的臨界區,以防止競爭條件發生。
互斥鎖Mutex在sync包中,在互斥鎖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)
}
在上面的程序中,increment函數用於變量x遞增計算,之後調用WaitGroup的Done()函數表明運行完成。
我們生成 1000 個increment的Goroutine。這些 Goroutines 中的每一個都同時運行,當嘗試遞增 x時,會發生競爭條件,因為多個 Goroutines 嘗試同時訪問 x 的值。
可以在本地運行此程序,多次運行此程序,您可以看到由於競爭條件,每次的輸出都會有所不同。我遇到的一些輸出是 x 941 的最終值、x 928 的最終值、x 922 的最終值等等。
使用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是一種struct類型,在上面的程序中,我們創建一個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
wg.Done()
}
func main() {
var w sync.WaitGroup
ch := make(chan bool, 1)
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, ch)
}
w.Wait()
fmt.Println("final value of x", x)
}
在上面的程序中,我們創建了一個容量為1的緩衝通道,並將其傳遞給第18行中的增量 Goroutine。此緩衝通道用於確保只有一個Goroutine 訪問遞增 x 的代碼的臨界區。這是通過在 x 遞增之前將 true傳遞給第8行中的緩衝通道來完成的。由於緩衝通道的容量為 1,因此所有其他嘗試寫入此通道的 Goroutines 都將被阻塞,直到在第10行中遞增 x 後從該通道讀取該值。實際上,這只允許一個 Goroutine 訪問臨界區。
如果程序運行,會得到如下輸出:
final value of x 1000
Mutex vs Channels
我們已經使用Mutex和Channel解決了競爭條件問題。那麼我們如何決定何時使用什麼呢?答案在於您要解決的問題。如果您嘗試解決的問題更適合互斥鎖,請繼續使用互斥鎖。如果需要,不要猶豫,使用互斥鎖。如果問題似乎更適合通道,那麼:)使用它。
大多數 Go 新手嘗試使用通道解決每個並發問題,因為它是該語言的一個很酷的功能。這是錯誤的。Go為我們提供了使用Mutex或Channel的選項,選擇兩者都沒有錯。
通常,當 Goroutines 需要相互通信時使用通道,當只有一個 Goroutine 應該訪問代碼的臨界區時,使用互斥鎖。
對於我們上面解決的問題,更喜歡使用互斥鎖,因為這個問題不需要goroutine之間的任何通信。因此,互斥鎖將是自然而然的選擇。