這可能是最容易理解的 Go Mutex 源碼剖析

上一篇文章《一文完全掌握 Go math/rand》,我們知道 math/rand 的 global rand 有一個全局鎖,我的文章裏面有一句話:“修復方案: 就是把 rrRand 換成了 globalRand, 在線上高併發場景下, 發現全局鎖影響並不大.”, 有同學私聊我 “他們遇到線上服務的鎖競爭特別激烈”。確實我這句話說的並不嚴謹。但是也讓我有了一個思考:到底多高的 QPS 才能讓 Mutex 產生強烈的鎖競爭?

到底加鎖的代碼會不會產生線上問題?到底該不該使用鎖來實現這個功能?線上的問題是不是由於使用了鎖造成的?針對這些問題,本文就從源碼角度剖析 Go Mutex, 揭開 Mutex 的迷霧。

源碼分析

Go mutex 源碼只有短短的 228 行,但是卻包含了很多的狀態轉變在裏面,很不容易看懂,具體可以參見下面的流程圖。Mutex 的實現主要藉助了 CAS 指令 + 自旋 + 信號量來實現,具體代碼我就不再每一行做分析了,有興趣的可以根據下面流程圖配合源碼閱讀一番。

Lock

Unlock

一些例子

1. 一個 goroutine 加鎖解鎖過程

加鎖加鎖

2. 沒有加鎖,直接解鎖問題

沒有加鎖直接解鎖

3. 兩個 Goroutine,互相加鎖解鎖

互相加鎖解鎖

4. 三個 Goroutine 等待加鎖過程

三個 goroutine 等待加鎖

整篇源碼其實涉及比較難以理解的就是 Mutex 狀態(mutexLocked,mutexWoken,mutexStarving,mutexWaiterShift) 與 Goroutine 之間的狀態(starving,awoke)改變, 我們下面將逐一說明。

什麼是 Goroutine 排隊?

排隊

如果 Mutex 已經被一個 Goroutine 獲取了鎖, 其它等待中的 Goroutine 們只能一直等待。那麼等這個鎖釋放後,等待中的 Goroutine 中哪一個會優先獲取 Mutex 呢?

正常情況下, 當一個 Goroutine 獲取到鎖後, 其他的 Goroutine 開始進入自旋轉 (爲了持有 CPU) 或者進入沉睡阻塞狀態 (等待信號量喚醒). 但是這裏存在一個問題, 新請求的 Goroutine 進入自旋時是仍然擁有 CPU 的, 所以比等待信號量喚醒的 Goroutine 更容易獲取鎖. 用官方話說就是,新請求鎖的 Goroutine 具有優勢,它正在 CPU 上執行,而且可能有好幾個,所以剛剛喚醒的 Goroutine 有很大可能在鎖競爭中失敗.

於是如果一個 Goroutine 被喚醒過後, 仍然沒有拿到鎖, 那麼該 Goroutine 會放在等待隊列的最前面. 並且那些等待超過 1 ms 的 Goroutine 還沒有獲取到鎖,該 Goroutine 就會進入飢餓狀態。該 Goroutine 是飢餓狀態並且 Mutex 是 Locked 狀態時,纔有可能給 Mutex 設置成飢餓狀態.

獲取到鎖的 Goroutine Unlock, 將 Mutex 的 Locked 狀態解除, 發出來解鎖信號, 等待的 Goroutine 開始競爭該信號. 如果發現當前 Mutex 是飢餓狀態, 直接將喚醒信號發給第一個等待的 Goroutine

這就是所謂的 Goroutine 排隊

排隊功能是如何實現的

我們知道在正常狀態下,所有等待鎖的 Goroutine 按照 FIFO 順序等待,在 Mutex 飢餓狀態下,會直接把釋放鎖信號發給等待隊列中的第一個 Goroutine。排隊功能主要是通過 runtime_SemacquireMutex, runtime_Semrelease 來實現的.

1. runtime_SemacquireMutex -- 入隊

當 Mutex 被其他 Goroutine 持有時,新來的 Goroutine 將會被 runtime_SemacquireMutex 阻塞。阻塞會分爲 2 種情況:

Goroutine 第一次被阻塞:

當 Goroutine 第一次嘗試獲取鎖時,由於當前鎖可能不能被鎖定,於是有可能進入下面邏輯

1queueLifo := waitStartTime != 0
2if waitStartTime == 0 {
3    waitStartTime = runtime_nanotime()
4}
5runtime_SemacquireMutex(&m.sema, queueLifo, 1)
6
7

由於 waitStartTime 等於 0,runtime_SemacquireMutex 的 queueLifo 等於 false, 於是該 Goroutine 放入到隊列的尾部。

Goroutine 被喚醒過,但是沒加鎖成功,再次被阻塞:

由於 Goroutine 被喚醒過,waitStartTime 不等於 0,runtime_SemacquireMutex 的 queueLifo 等於 true, 於是該 Goroutine 放入到隊列的頭部。

2. runtime_Semrelease -- 出隊

當某個 Goroutine 釋放鎖時,調用 Unlock,這裏同樣存在兩種情況:

當前 mutex 不是飢餓狀態:

 1if new&mutexStarving == 0 {
 2    old := new
 3    for {
 4        if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
 5            return
 6        }
 7        // Grab the right to wake someone.
 8        new = (old - 1<<mutexWaiterShift) | mutexWoken
 9        if atomic.CompareAndSwapInt32(&m.state, old, new) {
10            runtime_Semrelease(&m.sema, false, 1)
11            return
12        }
13        old = m.state
14    }
15}
16
17

Unlock Mutex 的 Locked 狀態被去掉。當發現當前 Mutex 不是飢餓狀態,設置 runtime_Semrelease 的 handoff 參數是 false, 於是喚醒其中一個 Goroutine。

當前 mutex 已經是飢餓狀態:

 1} else {
 2    // Starving mode: handoff mutex ownership to the next waiter, and yield
 3    // our time slice so that the next waiter can start to run immediately.
 4    // Note: mutexLocked is not set, the waiter will set it after wakeup.
 5    // But mutex is still considered locked if mutexStarving is set,
 6    // so new coming goroutines won't acquire it.
 7    runtime_Semrelease(&m.sema, true, 1)
 8}
 9
10

同樣 Unlock 時 Mutex 的 Locked 狀態被去掉。由於當前 Mutex 是飢餓狀態,於是設置 runtime_Semrelease 的 handoff 參數是 true, 於是讓等待隊列頭部的第一個 Goroutine 獲得鎖。

Goroutine 的排隊 與 mutex 中記錄的 Waiters 之間的關係?

通過上面的分析,我們知道 Goroutine 的排隊是通過 runtime_SemacquireMutex 來實現的。Mutex.state 記錄了目前通過 runtime_SemacquireMutex 排隊的 Goroutine 的數量

Goroutine 的飢餓與 Mutex 飢餓之間的關係?

Goroutine 的狀態跟 Mutex 的是息息相關的。只有在 Goroutine 是飢餓狀態下,纔有可能給 Mutex 設置成飢餓狀態。在 Mutex 是飢餓狀態時,纔有可能讓飢餓的 Goroutine 優先獲取到鎖。不過需要注意的是,觸發 Mutex 飢餓的 Goroutine 並不一定獲取鎖,有可能被其他的飢餓的 Goroutine 截胡。

Goroutine 能夠加鎖成功的情況

Mutex 沒有被 Goroutine 佔用 Mutex.state = 0, 這種情況下一定能獲取到鎖. 例如: 第一個 Goroutine 獲取到鎖 還有一種情況 Goroutine 有可能加鎖成功:

  1. 當前 Mutex 不是飢餓狀態, 也不是 Locked 狀態, 嘗試 CAS 加鎖時, Mutex 的值還沒有被其他 Goroutine 改變, 當前 Goroutine 才能加鎖成功.

  2. 某個 Goroutine 剛好被喚醒後, 重新獲取 Mutex, 這個時候 Mutex 處於飢餓狀態. 因爲這個時候只喚醒了飢餓的 Goroutine, 其他的 Goroutine 都在排隊中, 沒有其他 Goroutine 來競爭 Mutex, 所以能直接加鎖成功

Mutex 鎖競爭的相關問題

探測鎖競爭

日常開發中鎖競爭的問題還是能經常遇到的,我們如何去發現鎖競爭呢?其實還是需要靠 pprof 來人肉來分析。

《一次錯誤使用 go-cache 導致出現的線上問題》就是我真是遇到的一次線上問題,表象就是接口大量超時,打開 pprof 發現大量 Goroutine 都集中 Lock 上。這個真實場景的具體的分析過程,有興趣的可以閱讀一下。簡單總結一下:壓測或者流量高的時候發現系統不正常,打開 pprof 發現 goroutine 指標在飆升,並且大量 Goroutine 都阻塞在 Mutex  的 Lock 上,這個基本就可以確定是鎖競爭。

pprof 裏面是有個 pprof/mutex 指標,不過該指標默認是關閉的,而且並沒有太多資料有介紹這個指標如何來分析 Mutex。有知道這個指標怎麼用的大佬,歡迎留言。

mutex 鎖的瓶頸

現在模擬業務開發中的某接口,平均耗時 10 ms, 在 32C 物理機上壓測。CentOS Linux release 7.3.1611 (Core),  go1.15.8 壓測代碼如下:

 1package main
 2
 3import (
 4 "fmt"
 5 "log"
 6 "net/http"
 7 "sync"
 8 "time"
 9
10 _ "net/http/pprof"
11)
12
13var mux sync.Mutex
14
15func testMutex(w http.ResponseWriter, r *http.Request) {
16 mux.Lock()
17 time.Sleep(10 * time.Millisecond)
18 mux.Unlock()
19}
20
21func main() {
22 go func() {
23  log.Println(http.ListenAndServe(":6060", nil))
24 }()
25
26 http.HandleFunc("/test/mutex", testMutex)
27 if err := http.ListenAndServe(":8000", nil); err != nil {
28  fmt.Println("start http server fail:", err)
29 }
30}
31
32

這個例子寫的比較極端了,全局共享一個 Mutex。經過壓測發現在 100 qps 時,Mutex 沒啥競爭,在 150 QPS 時競爭就開始變的激烈了。

當然我們寫業務代碼並不會這麼寫,但是可以通過這個例子發現 Mutex 在 QPS 很低的時候,鎖競爭就會很激烈。需要說明的一點:這個壓測數值沒啥具體的意義,不同的機器上表現肯定還會不一樣。

這個例子告訴我們幾點:

  1. 寫業務時不能全局使用同一個 Mutex

  2. 儘量避免使用 Mutex,如果非使用不可,儘量多聲明一些 Mutex,採用取模分片的方式去使用其中一個 Mutex

日常使用注意點

1. Lock/Unlock 成對出現

我們日常開發中使用 Mutex 一定要記得:先 Lock 再 Unlock。

特別要注意的是:沒有 Lock 就去 Unlock。當然這個 case 一般情況下我們都不會這麼寫。不過有些變種的寫法我們要尤其注意,例如

 1var mu sync.Mutex
 2
 3func release() {
 4 mu.Lock()
 5    fmt.Println("lock1 success")
 6 time.Sleep(10 * time.Second)
 7
 8 mu.Lock()
 9 fmt.Println("lock2 success")
10}
11
12func main() {
13 go release()
14
15 time.Sleep(time.Second)
16 mu.Unlock()
17 fmt.Println("unlock success")
18 for {}
19}
20
21

輸出結果:

1release lock1 success
2main unlock success
3release lock2 success
4
5

我們看到 release goroutine 的鎖竟然被 main goroutine 給釋放了,同時 release goroutine 又能重新獲取到鎖。

這段代碼可能你想不到有啥問題,其實這個問題蠻嚴重的,想象一下你的代碼中,本來是要加鎖給用戶加積分的,但是竟然被別的 goroutine 給解鎖了,導致積分沒有增加成功,同時解鎖的時候還別的 Goroutine 的鎖給 Unlock 了,互相加鎖解鎖,導致莫名其妙的問題。

所以一般情況下,要在本 Goroutine 中完成 Mutex 的 Lock&Unlock,千萬不要將要加鎖和解鎖分到兩個 Goroutine 中進行。如果你確實需要這麼做,請抽支菸冷靜一下,你真的是否需要這麼做。

2. Mutex 千萬不能被複制

我之前發過的《當 Go struct 遇上 Mutex》裏面詳細分析了不能被複制的原因,以及如何 Mutex 的最佳使用方式,建議沒看過的同學去看一遍。我們還是舉個例子說下爲啥不能被複制,以及如何用源碼進行分析

 1type Person struct {
 2 mux sync.Mutex
 3}
 4
 5func Reduce(p1 Person) {
 6 fmt.Println("step...", )
 7 p1.mux.Lock()
 8 fmt.Println(p1)
 9 defer p1.mux.Unlock()
10 fmt.Println("over...")
11}
12
13func main() {
14 var p Person
15 p.mux.Lock()
16 go Reduce(p)
17 p.mux.Unlock()
18 fmt.Println(111)
19 for {}
20}
21
22

問題分析:

  1. main Goroutine 已經給 p.mux 加了鎖 , 這個時候 p.mux  的 state 的值是 mutexLocked。

  2. 然後將 p.mux 複製給了 Reduce Goroutine。這個時候被複制的 p1.mux 的 state 的值也是 mutexLocked。

  3. main Goroutine 雖然已經解鎖了, 但是 Reduce Goroutine 跟 main Goroutine 的 mutex 已經不是同一個 mutex 了, 所以 Reduce Goroutine 就會加鎖失敗, 產生死鎖,關鍵是編譯器還發現不了這個 Deadlock.

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BZvfNn_Vre7o2T8BZ4LLMw