Golang Mutex 原理詳細解析
前言
互斥鎖是在並發程序中對共享資源進行訪問控制的主要手段。對此 Go 語言提供瞭簡單易用的 Mutex
。Mutex 和 Goroutine 合作緊密,概念容易混淆,一定註意要區分各自的概念。
Mutex
是一個結構體,對外提供 Lock()
和Unlock()
兩個方法,分別用來加鎖和解鎖。
// A Locker represents an object that can be locked and unlocked. type Locker interface { Lock() Unlock() } type Mutex struct { state int32 sema uint32 } const ( mutexLocked = 1 << iota // mutex is locked mutexWoken mutexStarving mutexWaiterShift = iota )
- Mutex 是一個互斥鎖,其零值對應瞭未上鎖的狀態,不能被拷貝;
- state 代表互斥鎖的狀態,比如是否被鎖定;
- sema 表示信號量,協程阻塞會等待該信號量,解鎖的協程釋放信號量從而喚醒等待信號量的協程。
註意到 state 是一個 int32 變量,內部實現時把該變量分成四份,用於記錄 Mutex 的狀態。
- Locked: 表示該 Mutex 是否已經被鎖定,0表示沒有鎖定,1表示已經被鎖定;
- Woken: 表示是否有協程已經被喚醒,0表示沒有協程喚醒,1表示已經有協程喚醒,正在加鎖過程中;
- Starving: 表示該 Mutex 是否處於饑餓狀態,0表示沒有饑餓,1表示饑餓狀態,說明有協程阻塞瞭超過1ms;
上面三個表示瞭 Mutex 的三個狀態:鎖定 – 喚醒 – 饑餓。
Waiter 信息雖然也存在 state 中,其實並不代表狀態。它表示阻塞等待鎖的協程個數,協程解鎖時根據此值來判斷是否需要釋放信號量。
協程之間的搶鎖,實際上爭搶給Locked
賦值的權利,能給 Locked
置為1,就說明搶鎖成功。搶不到就阻塞等待 sema
信號量,一旦持有鎖的協程解鎖,那麼等待的協程會依次被喚醒。
Woken
和 Starving
主要用於控制協程間的搶鎖過程。
Lock
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 } // Slow path (outlined so that the fast path can be inlined) m.lockSlow() }
若當前鎖已經被使用,請求 Lock() 的 goroutine 會阻塞,直到鎖可用為止。
單協程加鎖
若隻有一個協程加鎖,無其他協程幹擾,在加鎖過程中會判斷 Locked
標志位是否為 0,若當前為 0 則置為 1,代表加鎖成功。這裡本質是一個 CAS 操作,依賴瞭 atomic.CompareAndSwapInt32
。
加鎖被阻塞
假設協程B在嘗試加鎖前,已經有一個協程A獲取到瞭鎖,此時的狀態為:
此時協程B嘗試加鎖,被阻塞,Mutex 的狀態為:
Waiter 計數器增加瞭1,協程B將會持續阻塞,直到 Locked
值變成0 後才會被喚醒。
Unlock
func (m *Mutex) Unlock() { if race.Enabled { _ = m.state race.Release(unsafe.Pointer(m)) } // Fast path: drop lock bit. new := atomic.AddInt32(&m.state, -mutexLocked) if new != 0 { // Outlined slow path to allow inlining the fast path. // To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock. m.unlockSlow(new) } }
如果 Mutex 沒有被加鎖,就直接 Unlock
,會拋出一個 runtime error。
從源碼註釋來看,一個 Mutex 並不會與某個特定的 goroutine 綁定,理論上講用一個 goroutine 加鎖,另一個 goroutine 解鎖也是允許的,不過為瞭代碼可維護性,一般還是建議不要這麼搞。
A locked Mutex is not associated with a particular goroutine. It is allowed for one goroutine to lock a Mutex and then arrange for another goroutine to unlock it.
無協程阻塞下的解鎖
假定在解鎖時,沒有其他協程阻塞等待加鎖,那麼隻需要將 Locked
置為 0 即可,不需要釋放信號量。
解鎖並喚醒協程
假定解鎖時有1個或多個協程阻塞,解鎖過程分為兩個步驟:
- 將
Locked
位置0; - 看到
Waiter
> 0,釋放一個信號量,喚醒一個阻塞的協程,被喚醒的協程把Locked
置為1,獲取到鎖。
自旋
加鎖時,如果當前 Locked
位為1,則說明當前該鎖由其他協程持有,嘗試加鎖的協程並不是馬上轉入阻塞,而是會持續探測 Locked
位是否變為0,這個過程就是「自旋」。
自旋的時間很短,如果在自旋過程中發現鎖已經被釋放,那麼協程可以立即獲取鎖。此時即便有協程被喚醒,也無法獲取鎖,隻能再次阻塞。
自旋的好處是,當加鎖失敗時不必立即轉入阻塞,有一定機會獲取到鎖,這樣可以避免一部分協程的切換。
什麼是自旋
自旋對應於 CPU 的 PAUSE
指令,CPU 對該指令什麼都不做,相當於空轉。對程序而言相當於sleep
瞭很小一段時間,大概 30個時鐘周期。連續兩次探測Locked
位的間隔就是在執行這些 PAUSE
指令,它不同於sleep
,不需要將協程轉為睡眠態。
自旋條件
加鎖時 Golang 的 runtime 會自動判斷是否可以自旋,無限制的自旋將給 CPU 帶來巨大壓力,自旋必須滿足以下所有條件:
- 自旋次數要足夠少,通常為 4,即自旋最多 4 次;
- CPU 核數要大於 1,否則自旋沒有意義,因為此時不可能有其他協程釋放鎖;
- 協程調度機制中的 P 的數量要大於 1,比如使用
GOMAXPROCS()
將處理器設置為 1 就不能啟用自旋; - 協程調度機制中的可運行隊列必須為空,否則會延遲協程調度。
可見自旋的條件是很苛刻的,簡單說就是不忙的時候才會啟用自旋。
自旋的優勢
自旋的優勢是更充分地利用 CPU,盡量避免協程切換。因為當前申請加鎖的協程擁有 CPU,如果經過短時間的自旋可以獲得鎖,則當前寫成可以繼續運行,不必進入阻塞狀態。
自旋的問題
如果在自旋過程中獲得鎖,那麼之前被阻塞的協程就無法獲得。如果加鎖的協程特別多,每次都通過自旋獲取鎖,則之前被阻塞的協程將很難獲取鎖,從而進入【饑餓狀態】。
為此,Golang 1.8 版本後為Mutex
增加瞭Starving
模式,在這個狀態下不會自旋,一旦有協程釋放鎖。那麼一定會喚醒一個協程並成功加鎖。
Mutex 的模式
每個 Mutex 都有兩種模式:Normal, Starving。
Normal 模式
默認情況下的模式就是 Normal。 在該模式下,協程如果加鎖不成功,不會立即轉入阻塞排隊(先進先出),而是判斷是否滿足自旋條件,如果滿足則會啟動自旋過程,嘗試搶鎖。
Starving 模式
自旋過程中能搶到鎖,一定意味著同一時刻有協程釋放瞭鎖。我們知道釋放鎖時,如果發現有阻塞等待的協程,那麼還會釋放一個信號量來喚醒一個等待協程,被喚醒的協程得到 CPU 後開始運行,此時發現鎖已經被搶占瞭,自己隻好再次阻塞,不過阻塞前會判斷,自上次阻塞到本次阻塞經過瞭多長時間,如果超過 1ms,則會將 Mutex 標記為 Starving
模式,然後阻塞。
在Starving
模式下,不會啟動自旋過程,一旦有協程釋放瞭鎖,一定會喚醒協程,被喚醒的協程將成功獲取鎖,同時會把等待計數減 1。
Woken 狀態
Woken 狀態用於加鎖和解鎖過程中的通信。比如,同一時刻,兩個協程一個在加鎖,一個在解鎖,在加鎖的協程可能在自旋過程中,此時把 Woken 標記為 1,用於通知解鎖協程不必釋放信號量,類似知會一下對方,不用釋放瞭,我馬上就拿到鎖瞭。
到此這篇關於Golang Mutex 原理詳細解析的文章就介紹到這瞭,更多相關Golang Mutex 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Golang Mutex互斥鎖源碼分析
- Go語言底層原理互斥鎖的實現原理
- 一文掌握go的sync.RWMutex鎖
- 一文掌握Go語言並發編程必備的Mutex互斥鎖
- 詳解Golang五種原子性操作的用法