博客 / 詳情

返回

腦抽研究生Go併發-1-基本併發原語-上-Mutex、RWMutex、WaitGroup

基本併發原語

臨界區:避免程序中併發訪問或修改造成嚴重後果。

  • 數據庫、共享數據結構、I/O 設備、連接池中的連接

同步原語

包含:互斥鎖 Mutex、讀寫鎖 RWMutex、併發編排 WaitGroup、條件變量 Cond、Channel 等

適用場景:

  • 共享資源
  • 任務編排:goroutine + WaitGroup/Channel
  • 消息傳遞:goroutine +Channel。

Mutex實現了Locker接口

image.png

race detector:

檢測併發訪問共享資源是否有問題的工具,檢測data race

缺點:不能在編譯時檢測,而且只有出現了問題才能顯示data race

執行方式:go run -race counter.go

mutex:

圖片

用法一:直接使用

用法二:結構體中嵌套使用

用法三:結構體嵌套 + 方法嵌套

CAS(compare-and-swap)(原子操作)

CAS 指令將給定的值和一個內存地址中的值進行比較,如果它們是同一個值,就使用新值替換內存地址中的值。

Mutex的危險性:

Go語言的互斥鎖不記錄是哪個 goroutine(線程)給它上的鎖。

  • 導致:任何 goroutine 都能開鎖,因為 Mutex 不知道是誰鎖了它,所以任何一個 goroutine 都可以調用 Unlock 將其釋放。

所以記得 Unlock

Mutex全貌

  • 基石: CAS 比較並交換
  • Lock :幸運則持有,擁堵則自旋一段時間,搶到則佔有鎖,沒搶到則加入等待隊列。

    • 正常模式:線程可以插隊,搶奪本應是隊頭的鎖。
    • 飢餓模式:隊頭等太久(超1ms),不讓搶了,隊列變空 / 等時間變短 會恢復正常模式。
  • Unlock :如果線程執行完臨界區代碼,有別人直接拎包入住,它就不用管,否則要主動喚醒別人。

常見的 4 種錯誤場景:

  • 錯誤場景一:Lock/Unlock 不是成對出現
  • 錯誤場景二:Copy 已使用的 Mutex (vet 工具可以檢查)
  • 錯誤場景三:重入:mutex是不可重入的

mutex的不可重入

可重入鎖的意義:防止在函數遞歸或嵌套調用中,同一個 goroutine 對同一個鎖的重複加鎖請求導致自我死鎖

在Go極少被使用,通常被視為一個設計缺陷的標誌

可重入mutex的實現⬇️

1️⃣如果是鎖的持有者,就增加計數,直接放行(“可重入”)。如果不是,就自己搞個自己的鎖。

2️⃣釋放別人的鎖,直接報錯。

3️⃣當計數器為零時,才是真正釋放鎖。

TokenRecursiveMutex vs. RecursiveMutex

TokenRecursiveMutex是巨大升級和範式轉變。

  • Goid 鎖是 “認人(goroutine)不認理(任務)”
  • Token 鎖是 “認理(token)不認人(goroutine)”

<!---->

// Token方式的遞歸鎖
type TokenRecursiveMutex struct {
    sync.Mutex
    token     int64
    recursion int32
}
​
// 請求鎖,需要傳入token
func (m *TokenRecursiveMutex) Lock(token int64) {
    if atomic.LoadInt64(&m.token) == token { //如果傳入的token和持有鎖的token一致,説明是遞歸調用
        m.recursion++
        return
    }
    m.Mutex.Lock() // 傳入的token不一致,説明不是遞歸調用
    // 搶到鎖之後記錄這個token
    atomic.StoreInt64(&m.token, token)
    m.recursion = 1
}
​
// 釋放鎖
func (m *TokenRecursiveMutex) Unlock(token int64) {
    if atomic.LoadInt64(&m.token) != token { // 釋放其它token持有的鎖
        panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
    }
    m.recursion-- // 當前持有這個鎖的token釋放鎖
    if m.recursion != 0 { // 還沒有回退到最初的遞歸調用
        return
    }
    atomic.StoreInt64(&m.token, 0) // 沒有遞歸調用了,釋放鎖
    m.Mutex.Unlock()
}
  • 錯誤場景四:死鎖(爭奪資源而相互等待)

死鎖的四個必要條件:互斥、持有和等待、不可剝奪、環路等待

異常檢測手段

通過搜索日誌、查看日誌,我們能夠知道程序有異常了,比如某個流程一直沒有結束。

  • 通過 Go pprof 工具分析,block profiler 可以監控阻塞的 goroutine。
  • 查看全部的 goroutine 的堆棧信息,查看阻塞的 groutine 究竟阻塞在哪一行哪一個對象。

額外功能

鎖是性能下降的“罪魁禍首”之一,所以,有效地降低鎖的競爭,就能夠很好地提高性能。因此,監控關鍵互斥鎖上等待的 goroutine 的數量,是我們分析鎖競爭的激烈程度的一個重要指標。

TryLock

原理:有則持有,沒有也不會阻塞等待。

  • 場景一:執行降級或替代方案

    • 成功去更新緩存。
    • 失敗不會等待,跳過更新緩存,返回舊一點的緩存數據。
  • 場景二:提高系統吞吐量

    • Worker 嘗試 TryLock(A)。
    • 成功則處理高優先級任務A。
    • 失敗則立即去嘗試 TryLock(B),處理低優先級的任務。
  • 場景三:避免死鎖

    • 協程1:Lock(A) -> 然後 TryLock(B)。
    • 如果 TryLock(B) 失敗了,説明可能要發生死鎖。協程1會主動釋放已經持有的鎖A (Unlock(A)),然後等待一小段時間,從頭再來。
    • 通過這種“獲取失敗就主動放棄”的策略,打破了死鎖的循環等待條件。

獲取等待者的數量等指標

危險⚠️略過

Mutex 實現一個線程安全的隊列

Mutex + 結構體 + 方法

RWMutex

圖片

使用場景:可以明確區分 reader 和 writer,且有大量的併發讀、少量的併發寫,並且有強烈的性能需求。

  • Lock/Unlock:寫操作時調用的方法。
  • RLock/RUnlock:讀操作時調用的方法。
  • RLocker:為讀操作返回一個 Locker 接口的對象。它的 Lock / Unlock方法會調用 RWMutex 的 RLock / RUnlock 方法。

Read-preferring 和 Write-preferring

Go 標準庫中的 RWMutex 設計是 寫優先(Write-preferring) 方案

RWMutex 的 3 個踩坑點

  • 坑點 1:不可複製
  • 坑點 2:重入導致死鎖

    • 1️⃣writer 重入調用 Lock
    • 2️⃣鎖升級:Goroutine A(讀者身份)等待 Goroutine A(作家身份)完成,而 Goroutine A(作家身份)在等待 Goroutine A(讀者身份)釋放。A -> A 的內部循環。
    • 3️⃣環形依賴:多個Goroutine形成一個等待環。作家等老讀者,老讀者等新讀者,新讀者又等作家。
  • 坑點 3:釋放未加鎖的 RWMutex

避免重入!!!

WaitGroup

圖片

基本方法:

func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
  • Add,用來設置 WaitGroup 的計數值;
  • Done,用來將 WaitGroup 的計數值減 1,其實就是調用了 Add(-1);
  • Wait,調用這個方法的 goroutine 會一直阻塞,直到 WaitGroup 的計數值變為 0。

WaitGroup 編排任務:需要啓動多個 goroutine 執行任務,主 goroutine 需要等待子 goroutine 都完成後才繼續執行。

使用 WaitGroup 時的常見錯誤

  • 常見問題一:計數器設置為負值
  • 常見問題二:沒有等所有的 Add 方法調用之後再調用 Wait
  • 常見問題三:前一個 Wait 還沒結束就重用 WaitGroup

WaitGroup的noCopy可以輔助 vet 檢查

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.