Go 系列教程 —— 25. Mutex

hongmingover發表於2018-09-18

 

Go 系列教程 —— 25. Mutex 

這是一個建立於 2018-03-15 11:41:13 的文章,其中的資訊可能已經有所發展或是發生改變。

歡迎來到 Golang 系列教程的第 25 篇。

本教程我們學習 Mutex。我們還會學習怎樣通過 Mutex 和通道來處理競態條件(Race Condition)。

臨界區

在學習 Mutex 之前,我們需要理解併發程式設計中臨界區(Critical Section)的概念。當程式併發地執行時,多個 Go 協程不應該同時訪問那些修改共享資源的程式碼。這些修改共享資源的程式碼稱為臨界區。例如,假設我們有一段程式碼,將一個變數 x 自增 1。

x = x + 1

如果只有一個 Go 協程訪問上面的程式碼段,那都沒有任何問題。

但當有多個協程併發執行時,程式碼卻會出錯,讓我們看看究竟是為什麼吧。簡單起見,假設在一行程式碼的前面,我們已經執行了兩個 Go 協程。

在上一行程式碼的內部,系統執行程式時分為如下幾個步驟(這裡其實還有很多包括暫存器的技術細節,以及加法的工作原理等,但對於我們的系列教程,只需認為只有三個步驟就好了):

  1. 獲得 x 的當前值
  2. 計算 x + 1
  3. 將步驟 2 計算得到的值賦值給 x

如果只有一個協程執行上面的三個步驟,不會有問題。

我們討論一下當有兩個併發的協程執行該程式碼時,會發生什麼。下圖描述了當兩個協程併發地訪問程式碼行 x = x + 1 時,可能出現的一種情況。

one-scenario

我們假設 x 的初始值為 0。而協程 1 獲取 x 的初始值,並計算 x + 1。而在協程 1 將計算值賦值給 x 之前,系統上下文切換到了協程 2。於是,協程 2 獲取了 x 的初始值(依然為 0),並計算 x + 1。接著系統上下文又切換回了協程 1。現在,協程 1 將計算值 1 賦值給 x,因此 x 等於 1。然後,協程 2 繼續開始執行,把計算值(依然是 1)複製給了 x,因此在所有協程執行完畢之後,x 都等於 1。

現在我們考慮另外一種可能發生的情況。

another-scenario

在上面的情形裡,協程 1 開始執行,完成了三個步驟後結束,因此 x 的值等於 1。接著,開始執行協程 2。目前 x 的值等於 1。而當協程 2 執行完畢時,x 的值等於 2。

所以,從這兩個例子你可以發現,根據上下文切換的不同情形,x 的最終值是 1 或者 2。這種不太理想的情況稱為競態條件(Race Condition),其程式的輸出是由協程的執行順序決定的。

在上例中,如果在任意時刻只允許一個 Go 協程訪問臨界區,那麼就可以避免競態條件。而使用 Mutex 可以達到這個目的

Mutex

Mutex 用於提供一種加鎖機制(Locking Mechanism),可確保在某時刻只有一個協程在臨界區執行,以防止出現競態條件。

Mutex 可以在 sync 包內找到。Mutex 定義了兩個方法:Lock 和 Unlock。所有在 Lock 和 Unlock 之間的程式碼,都只能由一個 Go 協程執行,於是就可以避免競態條件。

mutex.Lock()  
x = x + 1  
mutex.Unlock()

在上面的程式碼中,x = x + 1 只能由一個 Go 協程執行,因此避免了競態條件。

如果有一個 Go 協程已經持有了鎖(Lock),當其他協程試圖獲得該鎖時,這些協程會被阻塞,直到 Mutex 解除鎖定為止。

含有競態條件的程式

在本節裡,我們會編寫一個含有競態條件的程式,而在接下來一節,我們再修復競態條件的問題。

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 協程。每個 Go 協程併發地執行,由於第 8 行試圖增加 x 的值,因此多個併發的協程試圖訪問 x 的值,這時就會發生競態條件。

由於 playground 具有確定性,競態條件不會在 playground 發生,請在你的本地執行該程式。請在你的本地機器上多執行幾次,可以發現由於競態條件,每一次輸出都不同。我其中遇到的幾次輸出有 final value of x 941final value of x 928final value of x 922 等。

使用 Mutex

在前面的程式裡,我們建立了 1000 個 Go 協程。如果每個協程對 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)
}

在 playground 中執行

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)
}

在 playground 中 執行

在上述程式中,我們建立了容量為 1 的緩衝通道,並在第 18 行將它傳入 increment 協程。該緩衝通道用於保證只有一個協程訪問增加 x 的臨界區。具體的實現方法是在 x 增加之前(第 8 行),傳入 true 給緩衝通道。由於緩衝通道的容量為 1,所以任何其他協程試圖寫入該通道時,都會發生阻塞,直到 x 增加後,通道的值才會被讀取(第 10 行)。實際上這就保證了只允許一個協程訪問臨界區。

該程式也輸出:

final value of x 1000

Mutex vs 通道

通過使用 Mutex 和通道,我們已經解決了競態條件的問題。那麼我們該選擇使用哪一個?答案取決於你想要解決的問題。如果你想要解決的問題更適用於 Mutex,那麼就用 Mutex。如果需要使用 Mutex,無須猶豫。而如果該問題更適用於通道,那就使用通道。:)

由於通道是 Go 語言很酷的特性,大多數 Go 新手處理每個併發問題時,使用的都是通道。這是不對的。Go 給了你選擇 Mutex 和通道的餘地,選擇其中之一都可以是正確的。

總體說來,當 Go 協程需要與其他協程通訊時,可以使用通道。而當只允許一個協程訪問臨界區時,可以使用 Mutex。

就我們上面解決的問題而言,我更傾向於使用 Mutex,因為該問題並不需要協程間的通訊。所以 Mutex 是很自然的選擇。

我的建議是去選擇針對問題的工具,而別讓問題去將就工具。:)

本教程到此結束。祝你愉快

相關文章