Part25 Mutex

Golang 系列教程 第 25 部分 - Mutex


在該教程,我們學習互斥鎖,也學習如何使用 channels 和 互斥鎖解決競態條件。

臨界區

在學習互斥鎖之前,理解併發程序的臨界區的概念是很重要的。當程序併發地運行,修改共享資源的部分代碼不應該被多個 協程(Goroutines)同時訪問。修改共享資源的這部分代碼就被叫做臨界區。例如,我們假設有一段將變量 x 增加 1 的代碼。

x = x +1

只要上面的一段代碼被單個協程訪問,不應該有任何問題。

我們來看看爲什麼這段代碼在多個協程併發地運行時會失敗。爲了簡單,我們假設有 2 個協程併發地運行上面的一行代碼。

上面的一行代碼內部將被系統以下面的三步執行(有更多的技術細節如寄存器,如如何添加工作等等,本教程爲了簡單,假設有三步):

  1. 獲取 x 的當前值。
  2. 計算 x+1
  3. 將第2步計算的值賦值給 2

當這三個步驟僅被一個協程執行,一切正常。

我們討論當2個協程併發地運行這段代碼將會發生什麼。下面的這幅圖片描繪了一個當兩個協程併發地訪問 x=x+1 將會發行什麼的情況。
picture1
我們已經假設 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

現在我們來看一個可能發生不同情況
picture2
在上面的情況,Goroutine 1開始執行,完成它的所有三個步驟,因此 x 的值爲 1。然後 Goroutine 2開始執行,現在 x 的值是 1,當 Goroutine 2完成執行,x 的值是 2

所以從兩個例子中你可以看出,x 的最終值是 12 取決於上下文切換的發生。這個程序的輸出取決於協程執行的順序的不良情況被稱爲競態條件

在上面的場景中,如果在任何時間點,只允許一個協程訪問臨界區代碼,可以避免競態條件。這可以通過使用互斥鎖實現。

互斥鎖(Mutex)

Mutex用於提供鎖定機制,以確保在任何時間點只有一個Goroutine運行代碼的臨界區,以防止發生競爭條件。

Mutex 可以從 sync包中獲得。Mutex定義了兩個名爲 LockUnlock的方法。任何出現在調用 LockUnlock 之間的代碼僅被一個協程執行,這樣避免了競態條件。

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 + 1m.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菜鳥嘗試使用通道解決所有的併發問題,通道確實是語言的一個非常酷的特性。這是錯的,語言給我們提供了使用互斥鎖或通道的選項供選擇,這並沒有錯。

一般地當協程需要互相通道使用通道,互斥僅當一個協程應該臨界區代碼。

我們解決問題的示例中,我更期望使用互斥鎖,因爲這個問題並不需要與任何協程通信。因此互斥是一個更自然的選擇。

我的建議是爲問題選擇工具,而不是爲工具嘗試解決問題。

**下一教程 - 結構體代替類 **

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章