基本併發原語
臨界區:避免程序中併發訪問或修改造成嚴重後果。
- 數據庫、共享數據結構、I/O 設備、連接池中的連接
同步原語
包含:互斥鎖 Mutex、讀寫鎖 RWMutex、併發編排 WaitGroup、條件變量 Cond、Channel 等
適用場景:
- 共享資源
- 任務編排:goroutine + WaitGroup/Channel
- 消息傳遞:goroutine +Channel。
Mutex實現了Locker接口
race detector:
檢測併發訪問共享資源是否有問題的工具,檢測data race
缺點:不能在編譯時檢測,而且只有出現了問題才能顯示data race
執行方式:go run -race counter.go
mutex:
用法一:直接使用
用法二:結構體中嵌套使用
用法三:結構體嵌套 + 方法嵌套
CAS(compare-and-swap)(原子操作)
CAS 指令將給定的值和一個內存地址中的值進行比較,如果它們是同一個值,就使用新值替換內存地址中的值。
Mutex的危險性:
Go語言的互斥鎖不記錄是哪個 goroutine(線程)給它上的鎖。
- 導致:任何 goroutine 都能開鎖,因為 Mutex 不知道是誰鎖了它,所以任何一個 goroutine 都可以調用 Unlock 將其釋放。
所以記得 Unlock
Mutex全貌
- 基石: CAS 比較並交換
-
Lock :幸運則持有,擁堵則自旋一段時間,搶到則佔有鎖,沒搶到則加入等待隊列。
- 正常模式:線程可以插隊,搶奪本應是隊頭的鎖。
- 飢餓模式:隊頭等太久(超1ms),不讓搶了,隊列變空 / 等時間變短 會恢復正常模式。
- Unlock :如果線程執行完臨界區代碼,有別人直接拎包入住,它就不用管,否則要主動喚醒別人。
常見的 4 種錯誤場景:
- 錯誤場景一:Lock/Unlock 不是成對出現
- 錯誤場景二:Copy 已使用的 Mutex (vet 工具可以檢查)
- 錯誤場景三:重入:mutex是不可重入的
mutex的不可重入
可重入鎖的意義:防止在函數遞歸或嵌套調用中,同一個 goroutine 對同一個鎖的重複加鎖請求導致自我死鎖
在Go極少被使用,通常被視為一個設計缺陷的標誌
可重入mutex的實現⬇️
1️⃣如果是鎖的持有者,就增加計數,直接放行(“可重入”)。如果不是,就自己搞個自己的鎖。
2️⃣釋放別人的鎖,直接報錯。
3️⃣當計數器為零時,才是真正釋放鎖。
TokenRecursiveMutex vs. RecursiveMutex
TokenRecursiveMutex是巨大升級和範式轉變。
- Goid 鎖是 “認人(goroutine)不認理(任務)” 。
- Token 鎖是 “認理(token)不認人(goroutine)” 。
<!---->
// Token方式的遞歸鎖
type TokenRecursiveMutex struct {
sync.Mutex
token int64
recursion int32
}
// 請求鎖,需要傳入token
func (m *TokenRecursiveMutex) Lock(token int64) {
if atomic.LoadInt64(&m.token) == token { //如果傳入的token和持有鎖的token一致,説明是遞歸調用
m.recursion++
return
}
m.Mutex.Lock() // 傳入的token不一致,説明不是遞歸調用
// 搶到鎖之後記錄這個token
atomic.StoreInt64(&m.token, token)
m.recursion = 1
}
// 釋放鎖
func (m *TokenRecursiveMutex) Unlock(token int64) {
if atomic.LoadInt64(&m.token) != token { // 釋放其它token持有的鎖
panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
}
m.recursion-- // 當前持有這個鎖的token釋放鎖
if m.recursion != 0 { // 還沒有回退到最初的遞歸調用
return
}
atomic.StoreInt64(&m.token, 0) // 沒有遞歸調用了,釋放鎖
m.Mutex.Unlock()
}
- 錯誤場景四:死鎖(爭奪資源而相互等待)
死鎖的四個必要條件:互斥、持有和等待、不可剝奪、環路等待
異常檢測手段
通過搜索日誌、查看日誌,我們能夠知道程序有異常了,比如某個流程一直沒有結束。
- 通過 Go pprof 工具分析,block profiler 可以監控阻塞的 goroutine。
- 查看全部的 goroutine 的堆棧信息,查看阻塞的 groutine 究竟阻塞在哪一行哪一個對象。
額外功能
鎖是性能下降的“罪魁禍首”之一,所以,有效地降低鎖的競爭,就能夠很好地提高性能。因此,監控關鍵互斥鎖上等待的 goroutine 的數量,是我們分析鎖競爭的激烈程度的一個重要指標。
TryLock
原理:有則持有,沒有也不會阻塞等待。
-
場景一:執行降級或替代方案
- 成功去更新緩存。
- 失敗不會等待,跳過更新緩存,返回舊一點的緩存數據。
-
場景二:提高系統吞吐量
- Worker 嘗試 TryLock(A)。
- 成功則處理高優先級任務A。
- 失敗則立即去嘗試 TryLock(B),處理低優先級的任務。
-
場景三:避免死鎖
- 協程1:Lock(A) -> 然後 TryLock(B)。
- 如果 TryLock(B) 失敗了,説明可能要發生死鎖。協程1會主動釋放已經持有的鎖A (Unlock(A)),然後等待一小段時間,從頭再來。
- 通過這種“獲取失敗就主動放棄”的策略,打破了死鎖的循環等待條件。
獲取等待者的數量等指標
危險⚠️略過
Mutex 實現一個線程安全的隊列
Mutex + 結構體 + 方法
RWMutex
使用場景:可以明確區分 reader 和 writer,且有大量的併發讀、少量的併發寫,並且有強烈的性能需求。
- Lock/Unlock:寫操作時調用的方法。
- RLock/RUnlock:讀操作時調用的方法。
- RLocker:為讀操作返回一個 Locker 接口的對象。它的 Lock / Unlock方法會調用 RWMutex 的 RLock / RUnlock 方法。
Read-preferring 和 Write-preferring
Go 標準庫中的 RWMutex 設計是 寫優先(Write-preferring) 方案
RWMutex 的 3 個踩坑點
- 坑點 1:不可複製
-
坑點 2:重入導致死鎖
- 1️⃣writer 重入調用 Lock
- 2️⃣鎖升級:Goroutine A(讀者身份)等待 Goroutine A(作家身份)完成,而 Goroutine A(作家身份)在等待 Goroutine A(讀者身份)釋放。A -> A 的內部循環。
- 3️⃣環形依賴:多個Goroutine形成一個等待環。作家等老讀者,老讀者等新讀者,新讀者又等作家。
- 坑點 3:釋放未加鎖的 RWMutex
避免重入!!!
WaitGroup
基本方法:
func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
- Add,用來設置 WaitGroup 的計數值;
- Done,用來將 WaitGroup 的計數值減 1,其實就是調用了 Add(-1);
- Wait,調用這個方法的 goroutine 會一直阻塞,直到 WaitGroup 的計數值變為 0。
WaitGroup 編排任務:需要啓動多個 goroutine 執行任務,主 goroutine 需要等待子 goroutine 都完成後才繼續執行。
使用 WaitGroup 時的常見錯誤
- 常見問題一:計數器設置為負值
- 常見問題二:沒有等所有的 Add 方法調用之後再調用 Wait
- 常見問題三:前一個 Wait 還沒結束就重用 WaitGroup