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

haohongfan發表於2021-04-20

Hi,大家好,我是 haohongfan。

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

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

原始碼分析

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

Lock

Unlock

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 第一次嘗試獲取鎖時,由於當前鎖可能不能被鎖定,於是有可能進入下面邏輯

queueLifo := waitStartTime != 0
if waitStartTime == 0 {
    waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 1)

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

Goroutine 被喚醒過,但是沒加鎖成功,再次被阻塞
由於 Goroutine 被喚醒過,waitStartTime 不等於 0,runtime_SemacquireMutex 的 queueLifo 等於 true, 於是該 Goroutine 放入到佇列的頭部。

2. runtime_Semrelease -- 出隊

當某個 Goroutine 釋放鎖時,呼叫 Unlock,這裡同樣存在兩種情況:

當前 mutex 不是飢餓狀態

if new&mutexStarving == 0 {
    old := new
    for {
        if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
            return
        }
        // Grab the right to wake someone.
        new = (old - 1<<mutexWaiterShift) | mutexWoken
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            runtime_Semrelease(&m.sema, false, 1)
            return
        }
        old = m.state
    }
}

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

當前 mutex 已經是飢餓狀態

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

同樣 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 上。這個真實場景的具體的分析過程,有興趣的可以閱讀一下。
mutex 競爭
簡單總結一下:
壓測或者流量高的時候發現系統不正常,開啟 pprof 發現 goroutine 指標在飆升,並且大量 Goroutine 都阻塞在 Mutex 的 Lock 上,這個基本就可以確定是鎖競爭。

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

mutex 鎖的瓶頸

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

package main

import (
	"fmt"
	"log"
	"net/http"
	"sync"
	"time"

	_ "net/http/pprof"
)

var mux sync.Mutex

func testMutex(w http.ResponseWriter, r *http.Request) {
	mux.Lock()
	time.Sleep(10 * time.Millisecond)
	mux.Unlock()
}

func main() {
	go func() {
		log.Println(http.ListenAndServe(":6060", nil))
	}()

	http.HandleFunc("/test/mutex", testMutex)
	if err := http.ListenAndServe(":8000", nil); err != nil {
		fmt.Println("start http server fail:", err)
	}
}

moni_mutex.png

yaceresult.png

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

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

這個例子告訴我們幾點:

  1. 寫業務時不能全域性使用同一個 Mutex
  2. 儘量避免使用 Mutex,如果非使用不可,儘量多宣告一些 Mutex,採用取模分片的方式去使用其中一個 Mutex

日常使用注意點

1. Lock/Unlock 成對出現

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

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

var mu sync.Mutex

func release() {
	mu.Lock()
    fmt.Println("lock1 success")
	time.Sleep(10 * time.Second)

	mu.Lock()
	fmt.Println("lock2 success")
}

func main() {
	go release()

	time.Sleep(time.Second)
	mu.Unlock()
	fmt.Println("unlock success")
	for {}
}

輸出結果:

release lock1 success
main unlock success
release lock2 success

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

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

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

2. Mutex 千萬不能被複制

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

type Person struct {
	mux sync.Mutex
}

func Reduce(p1 Person) {
	fmt.Println("step...", )
	p1.mux.Lock()
	fmt.Println(p1)
	defer p1.mux.Unlock()
	fmt.Println("over...")
}

func main() {
	var p Person
	p.mux.Lock()
	go Reduce(p)
	p.mux.Unlock()
	fmt.Println(111)
	for {}
}

問題分析:

  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.

關於為什麼編譯器不能發現這個死鎖,可以看我的部落格《一次 Golang Deadlock 的討論》

至此 Go Mutex 的原始碼剖析全部完畢了,有什麼想跟我交流的可以再評論區留言。

相關文章