Go中Mutex和Channel使用哪個?

馴鹿的古牧 發佈 2024-03-15T06:30:37.867949+00:00

Goroutine 1 獲取 x 的初始值,計算 x + 1,在將計算值分配給 x 之前,系統上下文切換到 Goroutine 2。

臨界區

在說到鎖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之間的任何通信。因此,互斥鎖將是自然而然的選擇。

關鍵字: