原始碼剖析 golang 中 sync.Mutex
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 來說,有兩個比較關鍵的特性:
- 如果說鎖不是處於 locked 狀態,那麼對鎖執行 Unlock 會導致 panic;
- 鎖和 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 行),但是其中的演算法、設計的思想、程式設計的理念卻是值得感悟,所謂大道至簡、少即是多可能就是如此吧。
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- go中sync.Mutex原始碼解讀GoMutex原始碼
- golang 中 sync.Mutex 的實現GolangMutex
- Go For Web:Golang http 包詳解(原始碼剖析)WebGolangHTTP原始碼
- Java集合原始碼剖析——ArrayList原始碼剖析Java原始碼
- Spring原始碼剖析9:Spring事務原始碼剖析Spring原始碼
- epoll–原始碼剖析原始碼
- Thread原始碼剖析thread原始碼
- Handler原始碼剖析原始碼
- HashMap原始碼剖析HashMap原始碼
- Kafka 原始碼剖析(一)Kafka原始碼
- Flutter 原始碼剖析(一)Flutter原始碼
- 全面剖析 Redux 原始碼Redux原始碼
- 深入剖析LinkedList原始碼原始碼
- Java LinkedList 原始碼剖析Java原始碼
- vue原始碼剖析(一)Vue原始碼
- spark核心原始碼深度剖析Spark原始碼
- STL原始碼剖析——vector容器原始碼
- mmdetection原始碼剖析(1)--NMS原始碼
- Dolphinscheduler DAG核心原始碼剖析原始碼
- 深入剖析(JDK)ArrayQueue原始碼JDK原始碼
- 深入剖析RocketMQ原始碼-NameServerMQ原始碼Server
- jQuery原始碼剖析(五) - 事件繫結原理剖析jQuery原始碼事件
- Golang WaitGroup原始碼分析GolangAI原始碼
- 我的原始碼閱讀之路:redux原始碼剖析原始碼Redux
- Spring AOP 原理原始碼深度剖析Spring原始碼
- Redux 原始碼剖析及應用Redux原始碼
- YYImage原始碼剖析與學習原始碼
- YYModel 原始碼剖析:關注效能原始碼
- Flutter事件分發原始碼剖析Flutter事件原始碼
- 剖析 React 原始碼:render 流程(一)React原始碼
- Graphx 原始碼剖析-圖的生成原始碼
- Flutter原始碼剖析(一):原始碼獲取與構建Flutter原始碼
- 在Golang中實現Actor模型的原始碼 - GauravGolang模型原始碼
- ArrayList原始碼剖析與程式碼實測原始碼
- 深入剖析Vue原始碼 - 響應式系統構建(中)Vue原始碼
- petite-vue原始碼剖析-為什麼要讀原始碼?Vue原始碼
- Axios原始碼深度剖析 – AJAX新王者iOS原始碼
- Redis原始碼剖析之主從複製Redis原始碼