Go語言如何利用Mutex保障數據讀寫正確

Go 並發場景下如何保障數據讀寫正確?本文聊聊 Mutex 的用法。

Go 語言作為一個原生支持用戶態進程(Goroutine)的語言,當提到並發編程、多線程編程時,往往都離不開鎖這一概念。鎖是一種並發編程中的同步原語(Synchronization Primitives),它能保證多個 Goroutine 在訪問同一片內存時不會出現競爭條件(Race condition)等問題。

本文,我會帶你詳細瞭解互斥鎖的實現機制,以及 Go 標準庫的互斥鎖 Mutex 的基本使用方法。後面會講解 Mutex 的具體實現原理、易錯場景和一些拓展用法。 歡迎關註一下不迷路。

好瞭,我們先來看看互斥鎖的實現機制。

1、實現機制

互斥鎖 Mutex 是並發控制的一個基本手段,是為瞭避免並發競爭建立的並發控制機制,其中有個“臨界區”的概念。

在並發編程過程中,如果程序中一部分資源或者變量會被並發訪問或者修改,為瞭避免並發訪問導致數據的不準確,這部分程序需要率先被保護起來,之後操作,操作結束後去除保護,這部分被保護的程序就叫做 臨界區。

限定臨界區隻能同時由一個線程持有。 當臨界區由一個線程持有的時候,其它線程如果想進入這個臨界區,就會返回失敗,或者是等待。直到持有的線程退出臨界區,其他線程才有機會獲得這個臨界區。如下圖:

Go mutex 臨界區示意圖

上圖互斥鎖就很好地解決瞭資源競爭問題,有人也把互斥鎖叫做排它鎖。那在 Go 標準庫中,它提供瞭 Mutex 來實現互斥鎖這個功能。

Go 語言在 sync 包中提供瞭用於同步的一些基本原語,包括常見的 sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 和 sync.Cond。這次主要講 Mutex。

接下來我們看看到底可以怎麼使用 Mutex。

2、基本用法

在 Go 的標準庫中,package sync 提供瞭鎖相關的一系列同步原語,這個 package 還定義瞭一個 Locker 的接口,Mutex 就實現瞭這個接口。

互斥鎖 Mutex 提供瞭兩個方法 Lock 和 Unlock:進入到臨界區使用 Lock 方法加鎖,退出臨界區使用 Unlock 方法釋放鎖。

type Locker interface {
    Lock()
    Unlock()
}

上面可以看出,Go 定義的鎖接口的方法集很簡單,就是請求鎖(Lock)和釋放鎖(Unlock)這兩個方法,繼承瞭 Go 語言一貫的簡潔風格。

我們本文會介紹的 Mutex 以及後面會介紹的讀寫鎖 RWMutex 都實現瞭 Locker 接口,所以首先我把這個接口介紹瞭,提前瞭解一下。

func(m *Mutex)Lock()
func(m *Mutex)Unlock()

並發場景下,一個 goroutine 調用 Lock 方法拿到鎖後,此時其他的 goroutine 會阻塞在 Lock 的調用上,一直等到當前獲取到鎖的 goroutine 釋放鎖。

看到這兒,你可能會問,為啥一定要加鎖呢?那我們就說一下在並發場景下不使用鎖的例子,看下會出現什麼問題。

舉一個計數器的例子,是由 10 個 goroutine 對計數器進行累加操作,每個 goroutine 負責執行 10 萬次的加 1 操作,期望的結果是 1000000 (10 * 100000)。

package main
import (
    "fmt"
    "sync"
)
    
func main() {
    var count = 0
    // 使用 WaitGroup 等待,創建 10 個goroutine
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0; i< 10;i++ {
        go func() {
            defer wg.Done()
            // 對變量count執行10次加1
            for j := 0; j< 100000; j++ {
                count++
            }
        }()
    }
    // 等待 10個 goroutine完成
    wg.Wait()
    fmt.Printin("count:", count)
}

每次運行,都得到瞭不同的結果,所以是不會得到期望的 1000000。

那麼這是為什麼?

其實,因為 count++ 不是一個原子操作,就可能有並發的問題。

上述是並發訪問共享數據的常見錯誤,10 個 goroutine 同時讀取到 count 的值為 9867,對值加 1,值變成 啦9868,然後把這個值覆蓋到 count,但是實際上此時我們增加的總數應該是 10 才對,這裡卻隻增加瞭 1,好多計數都被“吞”掉瞭。

3、race detector

很多時候,並發問題隱藏得非常深,即使是有經驗的人,也不太容易發現或者 Debug 出來。

Go race detector , 一個檢測並發訪問共享資源是否有問題的工具,它可以幫助我們自動發現程序有沒有 data race 的問題。是基於 Google 的 C/C++ sanitizers 技術實現的,能夠監測出內存地址的訪問,當代碼運行時,race detector 可以很好的監控到共享變量的非同步訪問,出現 race 的時候,能夠輸出警告的信息。

怎麼用的呢?

在編譯、測試、運行 Go 代碼的時候,加上 race 參數,就有可能發現並發問題。比如在上面的例子中,我們可以加上 race 參數運行,檢測一下是不是有並發問題。

go run -race main.go 就會輸出警告信息。

圖中會提示有並發問題,會提示哪一個 goroutine 在某一行對變量有寫操作,同時也會提示哪個 goroutine 在某一行對變量有讀操作,這就是並發操作時引起瞭 data race。

既然存在 data race 問題,我們怎麼去解決呢?接下來就講下 Mutex,它可以輕松地消除掉 data race。

package main
import (
    "fmt"
    "sync"
)
    
func main() {
    var count = 0
    
    // 互斥鎖保護計數器
    var mu sync.Mutex
    // 輔助變量,用來確認所有的goroutine都完成
    var wg sync.WaitGroup
    wg.Add(10)
    // 啟動10個gourontine
    for i := 0;i< 10;i+++ {
        go func() {
            defer wg.Done()
            for j := 0; j< 100000; j++ {
                mu.Lock()
                count++
                mu.Unlock()
            }
        }()
    }
    // 等待 10個 goroutine完成
    wg.Wait()
    fmt.Printin("count:", count)
}

運行一下 go run -race main.go

你會發現輸出瞭期望值 1000000,data race 告警也沒有啦。

怎麼樣,是不是很驚喜,使用 Mutex 是不是非常高效?

我們在日常使用中,Mutex 會嵌入到其它 struct 中使用。

type Counter struct{
    sync.Mutex
    Count uint64
}


func main() {
    var counter Counter
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0;i< 10;i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < 100000; j++ {
                counter.Lock()
                counter.Count++
                counter.Unlock()
            }
        }()
    }
    wg.Wait()
    fmt.Println("count:", counter.Count)
}

當嵌入的 struct 有多個字段,我們會把 Mutex 放在要控制的字段上面,然後使用空格把字段分隔開來。這樣寫的話,邏輯會更清晰,也更易於維護。

有時候,你還可以把獲取鎖、釋放鎖、計數加一的邏輯封裝成一個方法,對外不需要暴露鎖等邏輯。

//線程安全的計數器類型
type Counter struct{
    CounterType int
    Name        string
    mu    sync.Mutex
    count uint64
}

func main() {
    // 封裝一個計數器
    var counter Counter
    var wg sync.WaitGroup
    wg.Add(10)
    // 啟動 10 個 goroutine
    for i := 0;i< 10;i++ {
        go func() {
            defer wg.Done( )
            // 執行 10 萬次累加
            for j := 0; j< 100000; j++ {
                // 受到鎖保護的方法
                counter.Incr()
            }
        }()
    }
    wg.Wait()
    fmt.PrintIn(counter.Count())
}

// 加1的方法,內部使用互斥鎖保護
func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}
// 得到計數器的值,也需要鎖保護
func (c *Counter) Count() uint64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

4、總結

本文介紹瞭並發問題的背景知識、標準庫中 Mutex 的使用,通過 Go race detector 工具發下並發場景下的問題及解決方法。你肯定已經瞭解瞭 Mutex 這個同步原語。

日常開發中,在設計階段,我們就應該需要考慮共享資源的並發問題,當然在初始階段有時候並不是很確定某個資源時否會唄共享,會隨著後續的迭代會顯現。雖遲但會到。當你意識到這個問題時,就需要通過互斥鎖來解決啦。

其實 Docker issue 37583、35517、32826、30696等、kubernetes issue 72361、71617等,都是後來發現的 data race 而采用互斥鎖 Mutex 進行修復的。

5、思考問題

Q: 當 Mutex 已經被一個 goroutine 獲取瞭鎖,其它的 goroutine 們隻能一直等待。當這個鎖釋放後,等待中的 goroutine 中哪一個會優先獲取 Mutex 呢?

到此這篇關於Go語言如何利用Mutex保障數據讀寫正確的文章就介紹到這瞭,更多相關Go語言Mutex保障數據讀寫正確內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: