【Go進階—併發程式設計】Mutex

與昊發表於2022-02-27

互斥鎖是併發程式中對臨界資源進行訪問控制的最基本手段,Mutex 即為 Go 語言原生的互斥鎖實現。

資料結構

原始碼包 src/sync/mutex.go 中定義了 Mutex 的資料結構:

type Mutex struct {
    state int32
    sema  uint32
}

state 欄位表示互斥鎖的狀態,是 32 位的整型,內部實現時把該變數分成四部分,用於記錄四種狀態:

image.png

  • Locked: 表示該互斥鎖是否已被鎖定;
  • Woken: 表示是否從正常模式被喚醒;
  • Starving:表示該Mutex是否處於飢餓狀態;
  • Waiter: 表示互斥鎖上阻塞等待的協程個數。

sema 欄位表示訊號量,加鎖失敗的協程阻塞等待該訊號量,解鎖的協程釋放訊號量從而喚醒等待訊號量的協程。

正常模式和飢餓模式

Mutex 有兩種模式——正常模式和飢餓模式,飢餓模式是 1.9 版本中引入的優化,目的是保證互斥鎖的公平性,防止協程餓死。預設情況下,Mutex 的模式為正常模式。

在正常模式下,協程如果加鎖不成功不會立即轉入等待佇列,而是判斷是否滿足自旋的條件,如果滿足則會自旋。

當持有鎖的協程釋放鎖的時候,會釋放一個訊號量來喚醒等待佇列中的一個協程,但如果有協程正處於自旋過程中,鎖往往會被該自旋協程獲取到。被喚醒的協程只好再次阻塞,不過阻塞前會判斷自上次阻塞到本次阻塞經過了多長時間,如果超過 1ms 的話,會將 Mutex 標記為飢餓模式。

在飢餓模式下,新加鎖的協程不會進入自旋狀態,它們只會在佇列的末尾等待,互斥鎖被釋放後會直接交給等待佇列最前面的協程。如果一個協程獲得了互斥鎖並且它在佇列的末尾或者它等待的時間少於 1ms,那麼互斥鎖就會切換回正常模式。

方法

互斥鎖 Mutex 就提供兩個方法 Lock 和 Unlock:進入臨界區之前呼叫 Lock 方法,退出臨界區的時候呼叫 Unlock 方法。

Lock

func (m *Mutex) Lock() {
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    m.lockSlow()
}

當鎖的狀態是 0 時,將 mutexLocked 置成 1,這是最簡單的情況。如果互斥鎖的狀態不是 0 時就會呼叫 lockSlow 方法,這裡將它分成幾個部分介紹獲取鎖的過程:

  1. 判斷當前 Goroutine 能否進入自旋;
  2. 通過自旋等待互斥鎖的釋放;
  3. 計算互斥鎖的最新狀態;
  4. 更新互斥鎖的狀態並獲取鎖。
判斷當前 Goroutine 能否進入自旋
func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false // 此 goroutine 的飢餓標記
    awoke := false // 喚醒標記
    iter := 0 // 自旋次數
    old := m.state
    for {
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            runtime_doSpin()
            iter++
            old = m.state
            continue
        }

Goroutine 進入自旋的條件非常苛刻:

  • 互斥鎖只有在普通模式才能進入自旋;
  • runtime.sync_runtime_canSpin 需要返回 true:

    • 執行在多 CPU 的機器上;
    • 當前 Goroutine 為了獲取該鎖進入自旋的次數小於 4 次;
    • 當前機器上至少存在一個正在執行的處理器 P 並且處理的執行佇列為空。
通過自旋等待互斥鎖的釋放

一旦當前 Goroutine 能夠進入自旋就會呼叫 runtime.sync_runtime_doSpin 和 runtime.procyield 執行 30 次的 PAUSE 指令,該指令只會佔用 CPU 並消耗 CPU 時間:

func sync_runtime_doSpin() {
    procyield(active_spin_cnt)
}

TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVL    cycles+0(FP), AX
again:
    PAUSE
    SUBL    $1, AX
    JNZ    again
    RET
計算互斥鎖的最新狀態

處理了自旋相關的邏輯後,會根據上下文計算當前互斥鎖最新的狀態。幾個不同的條件分別會更新 state 欄位中儲存的不同資訊 — mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift:

        new := old
        if old&mutexStarving == 0 {
            new |= mutexLocked // 非飢餓狀態,加鎖
        }
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift // 加鎖或飢餓狀態,waiter 數量加 1
        }
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving // 設定飢餓狀態
        }
        if awoke {
            if new&mutexWoken == 0 {
                throw("sync: inconsistent mutex state")
            }
            new &^= mutexWoken // 新狀態清除喚醒標記
        }
更新互斥鎖的狀態並獲取鎖

計算了新的互斥鎖狀態之後,會通過 CAS 函式更新狀態。如果沒有獲得鎖,會呼叫 runtime.sync_runtime_SemacquireMutex 通過訊號量保證資源不會被兩個 Goroutine 獲取。runtime.sync_runtime_SemacquireMutex 會在方法中不斷嘗試獲取鎖並陷入休眠等待訊號量的釋放,一旦當前 Goroutine 可以獲取訊號量,它就會立刻返回,繼續執行剩餘程式碼。

  • 在正常模式下,這段程式碼會設定喚醒和飢餓標記、重置迭代次數並重新執行獲取鎖的迴圈;
  • 在飢餓模式下,當前 Goroutine 會獲得互斥鎖,如果等待佇列中只存在當前 Goroutine,互斥鎖還會從飢餓模式中退出。
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 原來鎖的狀態已釋放,且不是飢餓狀態,獲取到鎖然後返回
            if old&(mutexLocked|mutexStarving) == 0 {
                break
            }
            
            // 處理飢餓狀態
            
            // 如果之前就在佇列裡面,加入到佇列頭
            queueLifo := waitStartTime != 0
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            // 阻塞等待
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            // 喚醒之後檢查鎖是否應該處於飢餓狀態
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            old = m.state
            // 如果鎖已經處於飢餓狀態,直接搶到鎖,返回
            if old&mutexStarving != 0 {
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync: inconsistent mutex state")
                }
                // 加鎖並且將 waiter 數減 1
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
                // 清除飢餓標記
                if !starving || old>>mutexWaiterShift == 1 {
                    delta -= mutexStarving
                }
                atomic.AddInt32(&m.state, delta)
                break
            }
            awoke = true
            iter = 0
        } else {
            old = m.state
        }
    }
}

Unlock

    func (m *Mutex) Unlock() {
        new := atomic.AddInt32(&m.state, -mutexLocked)
        if new != 0 {
            m.unlockSlow(new)
        }
    }
    
    func (m *Mutex) unlockSlow(new int32) {
        if (new+mutexLocked)&mutexLocked == 0 {
            throw("sync: unlock of unlocked mutex")
        }
        if new&mutexStarving == 0 {
            old := new
            for {
                if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                    return
                }
                new = (old - 1<<mutexWaiterShift) | mutexWoken
                if atomic.CompareAndSwapInt32(&m.state, old, new) {
                    runtime_Semrelease(&m.sema, false, 1)
                    return
                }
                old = m.state
            }
        } else {
            runtime_Semrelease(&m.sema, true, 1)
        }
    }

互斥鎖的解鎖過程比較簡單,該過程會先使用 sync.atomic.AddInt32 函式快速解鎖,失敗後執行慢速解鎖過程。unlockSlow 會先校驗鎖狀態的合法性 — 如果當前互斥鎖已經被解鎖過了會直接丟擲異常 。然後根據當前互斥鎖的狀態,在正常模式和飢餓模式下分別處理:

  • 在正常模式下,上述程式碼會使用如下所示的處理過程:

    • 如果互斥鎖不存在等待者或者互斥鎖的 mutexLocked、mutexStarving、mutexWoken 狀態不都為 0,那麼當前方法可以直接返回,不需要喚醒其他等待者;
    • 如果互斥鎖存在等待者,會通過 sync.runtime_Semrelease 喚醒等待者並移交鎖的所有權;
  • 在飢餓模式下,上述程式碼會直接呼叫 sync.runtime_Semrelease 將當前鎖交給下一個正在嘗試獲取鎖的等待者,等待者被喚醒後會得到鎖,在這時互斥鎖還不會退出飢餓狀態。

易錯場景

使用 Mutex 常見的錯誤場景有 4 類,分別是 Lock/Unlock 不是成對出現、Copy 已使用的 Mutex、重入和死鎖。其他三種比較簡單,這裡重點介紹一下有關重入的問題。

重入

標準庫 Mutex 不是可重入鎖,也就是指在一個 goroutine 中不可以多次獲取同一把鎖。如果想在 Mutex 的基礎上要實現一個可重入鎖的話,可以有下面兩個方案:

  • 通過 hacker 的方式獲取到 goroutine id,記錄下獲取鎖的 goroutine id,它可以實現 Locker 介面。
  • 呼叫 Lock/Unlock 方法時,由 goroutine 提供一個 token,用來標識它自己,而不是我們通過 hacker 的方式獲取到 goroutine id,但是,這樣一來就不滿足 Locker 介面。

可重入鎖解決了程式碼重入或者遞迴呼叫帶來的死鎖問題,同時它也帶來了另一個好處,就是我們可以要求,只有持有鎖的 goroutine 才能 unlock 這個鎖。這也很容易實現,因為在上面這兩個方案中,都已經記錄了是哪一個 goroutine 持有這個鎖。

方案一:goroutine id

這個方案的關鍵第一步是獲取 goroutine id,方式有兩種,分別是簡單方式和 hacker 方式。

簡單方式,就是通過 runtime.Stack 方法獲取棧幀資訊,棧幀資訊裡包含 goroutine id。runtime.Stack 方法可以獲取當前的 goroutine 資訊。

接下來我們來看 hacker 的方式,我們獲取執行時的 g 指標,反解出對應的 g 的結構。每個執行的 goroutine 結構的 g 指標儲存在當前 goroutine 的一個叫做 TLS 物件中。

  1. 我們先獲取到 TLS 物件;
  2. 再從 TLS 中獲取 goroutine 結構的 g 指標;
  3. 再從 g 指標中取出 goroutine id。

我們沒有必要重複發明輪子,直接使用第三方的庫來獲取 goroutine id 就可以了。現在已經有很多成熟的庫了,比如 petermattis/goid。接下來我們實現一個可以使用的可重入鎖:

// RecursiveMutex 包裝一個Mutex,實現可重入
type RecursiveMutex struct {
    sync.Mutex
    owner     int64 // 當前持有鎖的goroutine id
    recursion int32 // 這個goroutine 重入的次數
}

func (m *RecursiveMutex) Lock() {
    gid := goid.Get()
    // 如果當前持有鎖的goroutine就是這次呼叫的goroutine,說明是重入
    if atomic.LoadInt64(&m.owner) == gid {
        m.recursion++
        return
    }
    m.Mutex.Lock()
    // 獲得鎖的goroutine第一次呼叫,記錄下它的goroutine id,呼叫次數加1
    atomic.StoreInt64(&m.owner, gid)
    m.recursion = 1
}

func (m *RecursiveMutex) Unlock() {
    gid := goid.Get()
    // 非持有鎖的goroutine嘗試釋放鎖,錯誤的使用
    if atomic.LoadInt64(&m.owner) != gid {
        panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
    }
    // 呼叫次數減1
    m.recursion--
    if m.recursion != 0 { // 如果這個goroutine還沒有完全釋放,則直接返回
        return
    }
    // 此goroutine最後一次呼叫,需要釋放鎖
    atomic.StoreInt64(&m.owner, -1)
    m.Mutex.Unlock()
}
方案二:token

方案一是用 goroutine id 做 goroutine 的標識,我們也可以讓 goroutine 自己來提供標識。不管怎麼說,Go 開發者不期望使用者利用 goroutine id 做一些不確定的東西,所以,他們沒有暴露獲取 goroutine id 的方法。

我們可以這麼設計,呼叫者自己提供一個 token,獲取鎖的時候把這個 token 傳入,釋放鎖的時候也需要把這個 token 傳入。通過使用者傳入的 token 替換方案一中 goroutine id,其它邏輯和方案一一致。

擴充

TryLock

我們可以為 Mutex 新增一個 TryLock 的方法,也就是嘗試獲取鎖。當一個 goroutine 呼叫這個 TryLock 方法請求鎖的時候,如果這把鎖沒有被其他 goroutine 所持有,那麼,這個 goroutine 就持有了這把鎖,並返回 true。如果這把鎖已經被其他 goroutine 所持有,或者是正在準備交給某個被喚醒的 goroutine,那麼就直接返回 false,不會阻塞在方法呼叫上。

// 複製Mutex定義的常量
const (
    mutexLocked = 1 << iota // 加鎖標識位置
    mutexWoken              // 喚醒標識位置
    mutexStarving           // 鎖飢餓標識位置
    mutexWaiterShift = iota // 標識waiter的起始bit位置
)

// 擴充套件一個Mutex結構
type Mutex struct {
    sync.Mutex
}

// 嘗試獲取鎖
func (m *Mutex) TryLock() bool {
    // 如果能成功搶到鎖
    if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), 0, mutexLocked) {
        return true
    }
    // 如果處於喚醒、加鎖或者飢餓狀態,這次請求就不參與競爭了,返回false
    old := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
    if old&(mutexLocked|mutexStarving|mutexWoken) != 0 {
        return false
    }
    // 嘗試在競爭的狀態下請求鎖
    new := old | mutexLocked
    return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), old, new)
}

第 17 行是一個 fast path,如果幸運,沒有其他 goroutine 爭這把鎖,那麼,這把鎖就會被這個請求的 goroutine 獲取,直接返回。

如果鎖已經被其他 goroutine 所持有,或者被其他喚醒的 goroutine 準備持有,那麼,就直接返回 false,不再請求,程式碼邏輯在第 23 行。

如果沒有被持有,也沒有其它喚醒的 goroutine 來競爭鎖,鎖也不處於飢餓狀態,就嘗試獲取這把鎖(第 29 行),不論是否成功都將結果返回。因為,這個時候,可能還有其他的 goroutine 也在競爭這把鎖,所以,不能保證成功獲取這把鎖。

獲取等待者的數量等指標

Mutex 的資料結構包含兩個欄位:state 和 sema。前四個位元組(int32)就是 state 欄位。Mutex 結構中的 state 欄位有很多個含義,通過 state 欄位,可以知道鎖是否已經被某個 goroutine 持有、當前是否處於飢餓狀態、是否有等待的 goroutine 被喚醒、等待者的數量等資訊。但是,state 這個欄位並沒有暴露出來,怎麼獲取未暴露的欄位呢?很簡單,我們可以通過 unsafe 的方式實現。

const (
    mutexLocked = 1 << iota // mutex is locked
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
)

type Mutex struct {
    sync.Mutex
}

func (m *Mutex) Count() int {
    // 獲取state欄位的值
    v := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
    v = v >> mutexWaiterShift //得到等待者的數值
    v = v + (v & mutexLocked) //再加上鎖持有者的數量,0或者1
    return int(v)
}

這個例子的第 14 行通過 unsafe 操作,我們可以得到 state 欄位的值。在第 15 行通過右移三位(這裡的常量 mutexWaiterShift 的值為 3),就得到了當前等待者的數量。如果當前的鎖已經被其他 goroutine 持有,那麼,我們就稍微調整一下這個值,加上一個 1(第 16 行),基本上可以把它看作是當前持有和等待這把鎖的 goroutine 的總數。

相關文章