Golang 系列教程 第 25 部分 - Mutex
在該教程,我們學習互斥鎖,也學習如何使用 channels 和 互斥鎖解決競態條件。
臨界區
在學習互斥鎖之前,理解併發程序的臨界區的概念是很重要的。當程序併發地運行,修改共享資源的部分代碼不應該被多個 協程(Goroutines)同時訪問。修改共享資源的這部分代碼就被叫做臨界區。例如,我們假設有一段將變量 x
增加 1 的代碼。
x = x +1
只要上面的一段代碼被單個協程訪問,不應該有任何問題。
我們來看看爲什麼這段代碼在多個協程併發地運行時會失敗。爲了簡單,我們假設有 2 個協程併發地運行上面的一行代碼。
上面的一行代碼內部將被系統以下面的三步執行(有更多的技術細節如寄存器,如如何添加工作等等,本教程爲了簡單,假設有三步):
- 獲取 x 的當前值。
- 計算 x+1
- 將第2步計算的值賦值給 2
當這三個步驟僅被一個協程執行,一切正常。
我們討論當2個協程併發地運行這段代碼將會發生什麼。下面的這幅圖片描繪了一個當兩個協程併發地訪問 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
。因此 x
在兩個協程執行後是 1
。
現在我們來看一個可能發生不同情況
在上面的情況,Goroutine 1
開始執行,完成它的所有三個步驟,因此 x 的值爲 1
。然後 Goroutine 2
開始執行,現在 x
的值是 1
,當 Goroutine 2
完成執行,x
的值是 2
。
所以從兩個例子中你可以看出,x 的最終值是 1
或 2
取決於上下文切換的發生。這個程序的輸出取決於協程執行的順序的不良情況被稱爲競態條件
在上面的場景中,如果在任何時間點,只允許一個協程訪問臨界區代碼,可以避免競態條件。這可以通過使用互斥鎖實現。
互斥鎖(Mutex)
Mutex用於提供鎖定機制,以確保在任何時間點只有一個Goroutine運行代碼的臨界區,以防止發生競爭條件。
Mutex 可以從 sync包中獲得。Mutex定義了兩個名爲 Lock和Unlock的方法。任何出現在調用 Lock
和 Unlock
之間的代碼僅被一個協程執行,這樣避免了競態條件。
mutex.Lock()
x = x + 1
mutex.Unlock()
在上面代碼中,x = x + 1
在任何時間點僅被一個協程執行,這樣避免了競態條件。
如果一個協程已經擁有了鎖,如果一個新的務程試圖請求一個鎖,則新的協程將被阻塞直到互斥鎖被釋放。
含有競態條件的程序
在這個部分,我們將寫一個擁有競態條件的程序,在接下來的程序中我們將修復這個競態條件。
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()
通知它的完成。
在第 15 行我們生成了 1000 個 increment
協程,這個協程中的每個併發地運行,在第 8 行試圖增加 x
時,多個協程併發地嘗試訪問 x 發生競態條件。
請在你本地運行這個程序,由於playground 是確定的,在 playground 不會發生競態條件。在你本地機器上多次運行這個程序,你會看到,由於競態條件,每次運行的輸出是不同的。
使用互斥鎖解決競態條件
在上面程序中,我們發出了 1000 個協程,如果每個將 x 值增加 1,最後 x 的期望值應該是 1000。在這個部分,我們在程序中使用互斥鎖解決競態條件。
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是一個結構體類型,我們在第 15 行創建一個空的類型爲 Mutex
的變量 m
。在上面的程序中我們修改了 increment
函數,這樣增加 x
的代碼 x = x + 1
在 m.Lock()
和 m.Unlock()
之間,現在在任何時間,史允許一個協程執行這段代碼 ,避免了競態條件。
如果該程序運行,將輸出
final value of x 1000
在第 18 行傳遞 mutex 的地址是非常重要的。如果 mutex 通過值傳遞代替地址傳遞,第個協程將擁有 mutex 的一個副本,競態條件依然會出現。
使用通道解決競態條件
我們也可以使用通道來解決競態條件
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 行將它傳遞給 increment
協程。這個帶緩衝的通道被用來確保僅有一個協程訪問臨界區代碼 - 增加 x 。這在第 8 行在增加 x
之前通過向帶緩衝的通道傳遞 true
完成。由於帶緩衝的通道容量爲 1
,其他所有試圖寫該通道的協程被阻塞,直到第 10 行增加 x 之後該值被從該通道上讀走。這有效地允許一個通道訪問臨界區。
這個程序也打印
final value of x 1000
Mutex vs Channels
我們使用 Mutex 和 通道都解決了競態條件。那麼我們如何決定何時用哪個。答案在於你所要解決的問題。如果你嘗試解決的問題對於互斥更容易解決,那就使用互斥鎖吧。如果需要,毫不猶豫地使用互斥鎖。如果問題看上去使用通道更好地解決,那就使用它。
很多Go菜鳥嘗試使用通道解決所有的併發問題,通道確實是語言的一個非常酷的特性。這是錯的,語言給我們提供了使用互斥鎖或通道的選項供選擇,這並沒有錯。
一般地當協程需要互相通道使用通道,互斥僅當一個協程應該臨界區代碼。
我們解決問題的示例中,我更期望使用互斥鎖,因爲這個問題並不需要與任何協程通信。因此互斥是一個更自然的選擇。
我的建議是爲問題選擇工具,而不是爲工具嘗試解決問題。
**下一教程 - 結構體代替類 **