原始碼剖析 golang 中 sync.Mutex

PureWhiteWu發表於2020-02-17

go 語言以併發作為其特性之一,併發必然會帶來對於資源的競爭,這時候我們就需要使用 go 提供的sync.Mutex這把互斥鎖來保證臨界資源的訪問互斥。

既然經常會用這把鎖,那麼瞭解一下其內部實現,就能瞭解這把鎖適用什麼場景,特性如何了。

引子

在看sync.Mutex的程式碼的時候,一定要記住,同時會有多個 goroutine 會來要這把鎖,所以鎖的狀態state是可能會一直更改的。

鎖的性質

先說結論:sync.Mutex是把公平鎖。

在原始碼中,有一段註釋:

// Mutex fairness.
//
// Mutex can be in 2 modes of operations: normal and starvation.
// In normal mode waiters are queued in FIFO order, but a woken up waiter
// does not own the mutex and competes with new arriving goroutines over
// the ownership. New arriving goroutines have an advantage -- they are
// already running on CPU and there can be lots of them, so a woken up
// waiter has good chances of losing. In such case it is queued at front
// of the wait queue. If a waiter fails to acquire the mutex for more than 1ms,
// it switches mutex to the starvation mode.
//
// In starvation mode ownership of the mutex is directly handed off from
// the unlocking goroutine to the waiter at the front of the queue.
// New arriving goroutines don't try to acquire the mutex even if it appears
// to be unlocked, and don't try to spin. Instead they queue themselves at
// the tail of the wait queue.
//
// If a waiter receives ownership of the mutex and sees that either
// (1) it is the last waiter in the queue, or (2) it waited for less than 1 ms,
// it switches mutex back to normal operation mode.
//
// Normal mode has considerably better performance as a goroutine can acquire
// a mutex several times in a row even if there are blocked waiters.
// Starvation mode is important to prevent pathological cases of tail latency.

看懂這段註釋對於我們理解 mutex 這把鎖有很大的幫助,這裡面講了這把鎖的設計理念。大致意思如下:

// 公平鎖
//
// 鎖有兩種模式:正常模式和飢餓模式。
// 在正常模式下,所有的等待鎖的goroutine都會存在一個先進先出的佇列中(輪流被喚醒)
// 但是一個被喚醒的goroutine並不是直接獲得鎖,而是仍然需要和那些新請求鎖的(new arrivial)
// 的goroutine競爭,而這其實是不公平的,因為新請求鎖的goroutine有一個優勢——它們正在CPU上
// 執行,並且數量可能會很多。所以一個被喚醒的goroutine拿到鎖的概率是很小的。在這種情況下,
// 這個被喚醒的goroutine會加入到佇列的頭部。如果一個等待的goroutine有超過1ms(寫死在程式碼中)
// 都沒獲取到鎖,那麼就會把鎖轉變為飢餓模式。
//
// 在飢餓模式中,鎖的所有權會直接從釋放鎖(unlock)的goroutine轉交給佇列頭的goroutine,
// 新請求鎖的goroutine就算鎖是空閒狀態也不會去獲取鎖,並且也不會嘗試自旋。它們只是排到佇列的尾部。
//
// 如果一個goroutine獲取到了鎖之後,它會判斷以下兩種情況:
// 1. 它是佇列中最後一個goroutine;
// 2. 它拿到鎖所花的時間小於1ms;
// 以上只要有一個成立,它就會把鎖轉變回正常模式。

// 正常模式會有比較好的效能,因為即使有很多阻塞的等待鎖的goroutine,
// 一個goroutine也可以嘗試請求多次鎖。
// 飢餓模式對於防止尾部延遲來說非常的重要。

在下一步真正看原始碼之前,我們必須要理解一點:當一個 goroutine 獲取到鎖的時候,有可能沒有競爭者,也有可能會有很多競爭者,那麼我們就需要站在不同的 goroutine 的角度上去考慮 goroutine 看到的鎖的狀態和實際狀態、期望狀態之間的轉化。

欄位定義

sync.Mutex只包含兩個欄位:

// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
type Mutex struct {
    state int32
    sema    uint32
}

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

    starvationThresholdNs = 1e6
)

其中state是一個表示鎖的狀態的欄位,這個欄位會同時被多個 goroutine 所共用(使用 atomic.CAS 來保證原子性),第 0 個 bit(1)表示鎖已被獲取,也就是已加鎖,被某個 goroutine 擁有;第 1 個 bit(2)表示有 goroutine 被喚醒,嘗試獲取鎖;第 2 個 bit(4)標記這把鎖是否為飢餓狀態。

sema欄位就是用來喚醒 goroutine 所用的訊號量。

Lock

在看程式碼之前,我們需要有一個概念:每個 goroutine 也有自己的狀態,存在區域性變數裡面(也就是函式棧裡面),goroutine 有可能是新到的、被喚醒的、正常的、飢餓的。

atomic.CAS

先看一下最基礎的一行程式碼加鎖的 CAS 操作:

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            ...
        }
        return
    }
    ...
}

這是第一段程式碼,這段程式碼呼叫了atomic包中的CompareAndSwapInt32這個方法來嘗試快速獲取鎖,這個方法的簽名如下:

// CompareAndSwapInt32 executes the compare-and-swap operation for an int32 value.
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

意思是,如果 addr 指向的地址中存的值和 old 一樣,那麼就把 addr 中的值改為 new 並返回 true;否則什麼都不做,返回 false。由於是atomic中的函式,所以是保證了原子性的。

我們來具體看看 CAS 的實現(src/runtime/internal/atomic/asm_amd64.s):

// bool Cas(int32 *val, int32 old, int32 new)
// Atomically:
//  if(*val == old){
//      *val = new;
//      return 1;
//  } else
//      return 0;
// 這裡引數及返回值大小加起來是17,是因為一個指標在amd64下是8位元組,
// 然後int32分別是佔用4位元組,最後的返回值是bool佔用1位元組,所以加起來是17
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17 
    // 為什麼不把*val指標放到AX中呢?因為AX有特殊用處,
    // 在下面的CMPXCHGL裡面,會從AX中讀取要比較的其中一個數
    MOVQ    ptr+0(FP), BX
    // 所以AX要用來存引數old
    MOVL    old+8(FP), AX
    // 把new中的數存到暫存器CX中
    MOVL    new+12(FP), CX
    // 注意這裡了,這裡使用了LOCK字首,所以保證操作是原子的
    LOCK
    // 0(BX) 可以理解為 *val
    // 把 AX中的數 和 第二個運算元 0(BX)——也就是BX暫存器所指向的地址中存的值 進行比較
    // 如果相等,就把 第一個運算元 CX暫存器中存的值 賦給 第二個運算元 BX暫存器所指向的地址
    // 並將標誌暫存器ZF設為1
    // 否則將標誌暫存器ZF清零
    CMPXCHGL    CX, 0(BX)
    // SETE的作用是:
    // 如果Zero Flag標誌暫存器為1,那麼就把運算元設為1
    // 否則把運算元設為0
    // 也就是說,如果上面的比較相等了,就返回true,否則為false
    // ret+16(FP)代表了返回值的地址
    SETEQ   ret+16(FP)
    RET

如果看不懂也沒太大關係,只要知道這個函式的作用,以及這個函式是原子性的即可。

那麼這段程式碼的意思就是:先看看這把鎖是不是空閒狀態,如果是的話,直接原子性地修改一下state為已被獲取就行了。多麼簡潔(雖然後面的程式碼並不是……)!

主流程

接下來具體看主流程的程式碼,程式碼中有一些位運算看起來比較暈,我會試著用虛擬碼在邊上註釋。

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }

    // 用來存當前goroutine等待的時間
    var waitStartTime int64
    // 用來存當前goroutine是否飢餓
    starving := false
    // 用來存當前goroutine是否已喚醒
    awoke := false
    // 用來存當前goroutine的迴圈次數(想一想一個goroutine如果迴圈了2147483648次咋辦……)
    iter := 0
    // 複製一下當前鎖的狀態
    old := m.state
    // 自旋
    for {
        // 如果是飢餓情況之下,就不要自旋了,因為鎖會直接交給佇列頭部的goroutine
        // 如果鎖是被獲取狀態,並且滿足自旋條件(canSpin見後文分析),那麼就自旋等鎖
        // 虛擬碼:if isLocked() and isNotStarving() and canSpin()
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            // 將自己的狀態以及鎖的狀態設定為喚醒,這樣當Unlock的時候就不會去喚醒其它被阻塞的goroutine了
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            // 進行自旋(分析見後文)
            runtime_doSpin()
            iter++
            // 更新鎖的狀態(有可能在自旋的這段時間之內鎖的狀態已經被其它goroutine改變)
            old = m.state
            continue
        }

        // 當走到這一步的時候,可能會有以下的情況:
        // 1. 鎖被獲取+飢餓
        // 2. 鎖被獲取+正常
        // 3. 鎖空閒+飢餓
        // 4. 鎖空閒+正常

        // goroutine的狀態可能是喚醒以及非喚醒

        // 複製一份當前的狀態,目的是根據當前狀態設定出期望的狀態,存在new裡面,
        // 並且通過CAS來比較以及更新鎖的狀態
        // old用來存鎖的當前狀態
        new := old

        // 如果說鎖不是飢餓狀態,就把期望狀態設定為被獲取(獲取鎖)
        // 也就是說,如果是飢餓狀態,就不要把期望狀態設定為被獲取
        // 新到的goroutine乖乖排隊去
        // 虛擬碼:if isNotStarving()
        if old&mutexStarving == 0 {
            // 虛擬碼:newState = locked
            new |= mutexLocked
        }
        // 如果鎖是被獲取狀態,或者飢餓狀態
        // 就把期望狀態中的等待佇列的等待者數量+1(實際上是new + 8)
        // (會不會可能有三億個goroutine等待拿鎖……)
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift
        }
        // 如果說當前的goroutine是飢餓狀態,並且鎖被其它goroutine獲取
        // 那麼將期望的鎖的狀態設定為飢餓狀態
        // 如果鎖是釋放狀態,那麼就不用切換了
        // Unlock期望一個飢餓的鎖會有一些等待拿鎖的goroutine,而不只是一個
        // 這種情況下不會成立
        if starving && old&mutexLocked != 0 {
            // 期望狀態設定為飢餓狀態
            new |= mutexStarving
        }
        // 如果說當前goroutine是被喚醒狀態,我們需要reset這個狀態
        // 因為goroutine要麼是拿到鎖了,要麼是進入sleep了
        if awoke {
            // 如果說期望狀態不是woken狀態,那麼肯定出問題了
            // 這裡看不懂沒關係,wake的邏輯在下面
            if new&mutexWoken == 0 {
                throw("sync: inconsistent mutex state")
            }
            // 這句就是把new設定為非喚醒狀態
            // &^的意思是and not
            new &^= mutexWoken
        }
        // 通過CAS來嘗試設定鎖的狀態
        // 這裡可能是設定鎖,也有可能是隻設定為飢餓狀態和等待數量
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 如果說old狀態不是飢餓狀態也不是被獲取狀態
            // 那麼代表當前goroutine已經通過CAS成功獲取了鎖
            // (能進入這個程式碼塊表示狀態已改變,也就是說狀態是從空閒到被獲取)
            if old&(mutexLocked|mutexStarving) == 0 {
                break // locked the mutex with CAS
            }
            // 如果之前已經等待過了,那麼就要放到佇列頭
            queueLifo := waitStartTime != 0
            // 如果說之前沒有等待過,就初始化設定現在的等待時間
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            // 既然獲取鎖失敗了,就使用sleep原語來阻塞當前goroutine
            // 通過訊號量來排隊獲取鎖
            // 如果是新來的goroutine,就放到佇列尾部
            // 如果是被喚醒的等待鎖的goroutine,就放到佇列頭部
            runtime_SemacquireMutex(&m.sema, queueLifo)

            // 這裡sleep完了,被喚醒

            // 如果當前goroutine已經是飢餓狀態了
            // 或者當前goroutine已經等待了1ms(在上面定義常量)以上
            // 就把當前goroutine的狀態設定為飢餓
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            // 再次獲取一下鎖現在的狀態
            old = m.state
            // 如果說鎖現在是飢餓狀態,就代表現在鎖是被釋放的狀態,當前goroutine是被訊號量所喚醒的
            // 也就是說,鎖被直接交給了當前goroutine
            if old&mutexStarving != 0 {
                // 如果說當前鎖的狀態是被喚醒狀態或者被獲取狀態,或者說等待的佇列為空
                // 那麼是不可能的,肯定是出問題了,因為當前狀態肯定應該有等待的佇列,鎖也一定是被釋放狀態且未喚醒
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync: inconsistent mutex state")
                }
                // 當前的goroutine獲得了鎖,那麼就把等待佇列-1
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
                // 如果當前goroutine非飢餓狀態,或者說當前goroutine是佇列中最後一個goroutine
                // 那麼就退出飢餓模式,把狀態設定為正常
                if !starving || old>>mutexWaiterShift == 1 {
                    // Exit starvation mode.
                    // Critical to do it here and consider wait time.
                    // Starvation mode is so inefficient, that two goroutines
                    // can go lock-step infinitely once they switch mutex
                    // to starvation mode.
                    delta -= mutexStarving
                }
                // 原子性地加上改動的狀態
                atomic.AddInt32(&m.state, delta)
                break
            }
            // 如果鎖不是飢餓模式,就把當前的goroutine設為被喚醒
            // 並且重置iter(重置spin)
            awoke = true
            iter = 0
        } else {
            // 如果CAS不成功,也就是說沒能成功獲得鎖,鎖被別的goroutine獲得了或者鎖一直沒被釋放
            // 那麼就更新狀態,重新開始迴圈嘗試拿鎖
            old = m.state
        }
    }

    if race.Enabled {
        race.Acquire(unsafe.Pointer(m))
    }
}

以上為什麼 CAS 能拿到鎖呢?因為 CAS 會原子性地判斷old state和當前鎖的狀態是否一致;而總有一個 goroutine 會滿足以上條件成功拿鎖。

canSpin

接下來我們來看看上文提到的canSpin條件如何:

// Active spinning for sync.Mutex.
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
func sync_runtime_canSpin(i int) bool {
    // 這裡的active_spin是個常量,值為4
    // 簡單來說,sync.Mutex是有可能被多個goroutine競爭的,所以不應該大量自旋(消耗CPU)
    // 自旋的條件如下:
    // 1. 自旋次數小於active_spin(這裡是4)次;
    // 2. 在多核機器上;
    // 3. GOMAXPROCS > 1並且至少有一個其它的處於執行狀態的P;
    // 4. 當前P沒有其它等待執行的G;
    // 滿足以上四個條件才可以進行自旋。
    if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
        return false
    }
    if p := getg().m.p.ptr(); !runqempty(p) {
        return false
    }
    return true
}

所以可以看出來,並不是一直無限自旋下去的,當自旋次數到達 4 次或者其它條件不符合的時候,就改為訊號量拿鎖了。

doSpin

然後我們來看看doSpin的實現(其實也沒啥好看的):

//go:linkname sync_runtime_doSpin sync.runtime_doSpin
//go:nosplit
func sync_runtime_doSpin() {
    procyield(active_spin_cnt)
}

這是一個彙編實現的函式,簡單看兩眼 amd64 上的實現:

TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVL    cycles+0(FP), AX
again:
    PAUSE
    SUBL    $1, AX
    JNZ again
    RET

看起來沒啥好看的,直接跳過吧。

Unlock

接下來我們來看看 Unlock 的實現,對於 Unlock 來說,有兩個比較關鍵的特性:

  1. 如果說鎖不是處於 locked 狀態,那麼對鎖執行 Unlock 會導致 panic;
  2. 鎖和 goroutine 沒有對應關係,所以我們完全可以在 goroutine 1 中獲取到鎖,然後在 goroutine 2 中呼叫 Unlock 來釋放鎖(這是什麼騷操作!)(雖然不推薦大家這麼幹……)
func (m *Mutex) Unlock() {
    if race.Enabled {
        _ = m.state
        race.Release(unsafe.Pointer(m))
    }

    // Fast path: drop lock bit.
    // 這裡獲取到鎖的狀態,然後將狀態減去被獲取的狀態(也就是解鎖),稱為new(期望)狀態
    // 注意以上兩個操作是原子的,所以不用擔心多個goroutine併發的問題
    new := atomic.AddInt32(&m.state, -mutexLocked)
    // 如果說,期望狀態加上被獲取的狀態,不是被獲取的話
    // 那麼就panic
    // 在這裡給大家提一個問題:幹嘛要這麼大費周章先減去再加上,直接比較一下原來鎖的狀態是否被獲取不就完事了?
    if (new+mutexLocked)&mutexLocked == 0 {
        throw("sync: unlock of unlocked mutex")
    }
    // 如果說new狀態(也就是鎖的狀態)不是飢餓狀態
    if new&mutexStarving == 0 {
        // 複製一下原先狀態
        old := new
        for {
            // 如果說鎖沒有等待拿鎖的goroutine
            // 或者鎖被獲取了(在迴圈的過程中被其它goroutine獲取了)
            // 或者鎖是被喚醒狀態(表示有goroutine被喚醒,不需要再去嘗試喚醒其它goroutine)
            // 或者鎖是飢餓模式(會直接轉交給佇列頭的goroutine)
            // 那麼就直接返回,啥都不用做了
            if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
            // 走到這一步的時候,說明鎖目前還是空閒狀態,並且沒有goroutine被喚醒且佇列中有goroutine等待拿鎖
            // 那麼我們就要把鎖的狀態設定為被喚醒,等待佇列-1
            new = (old - 1<<mutexWaiterShift) | mutexWoken
            // 又是熟悉的CAS
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                // 如果狀態設定成功了,我們就通過訊號量去喚醒goroutine
                runtime_Semrelease(&m.sema, false)
                return
            }
            // 迴圈結束的時候,更新一下狀態,因為有可能在執行的過程中,狀態被修改了(比如被Lock改為了飢餓狀態)
            old = m.state
        }
    } else {
        // 如果是飢餓狀態下,那麼我們就直接把鎖的所有權通過訊號量移交給佇列頭的goroutine就好了
        // handoff = true表示直接把鎖交給佇列頭部的goroutine
        // 注意:在這個時候,鎖被獲取的狀態沒有被設定,會由被喚醒的goroutine在喚醒後設定
        // 但是當鎖處於飢餓狀態的時候,我們也認為鎖是被獲取的(因為我們手動指定了獲取的goroutine)
        // 所以說新來的goroutine不會嘗試去獲取鎖(在Lock中有體現)
        runtime_Semrelease(&m.sema, true)
    }
}

總結

根據以上程式碼的分析,可以看出,sync.Mutex這把鎖在你的工作負載(所需時間)比較低,比如只是對某個關鍵變數賦值的時候,效能還是比較好的,但是如果說對於臨界資源的操作耗時很長(特別是單個操作就大於 1ms)的話,實際上效能上會有一定的問題,這也就是我們經常看到 “的鎖一直處於飢餓狀態” 的問題,對於這種情況,可能就需要另尋他法了。

好了,至此整個sync.Mutex的分析就此結束了,雖然只有短短 200 行程式碼(包括 150 行註釋,實際程式碼估計就 50 行),但是其中的演算法、設計的思想、程式設計的理念卻是值得感悟,所謂大道至簡、少即是多可能就是如此吧。

更多原創文章乾貨分享,請關注公眾號
  • 原始碼剖析 golang 中 sync.Mutex
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章