【Golang詳解】go語言中併發安全和鎖

cooper發表於2021-10-28

go語言中併發安全和鎖


首先可以先看看這篇文章,對鎖有些瞭解

【鎖】詳解區分 互斥鎖、⾃旋鎖、讀寫鎖、樂觀鎖、悲觀鎖


Mutex-互斥鎖

Mutex 的實現主要藉助了 CAS 指令 + 自旋 + 訊號量

資料結構

type Mutex struct {
	state int32
	sema  uint32
}

上述兩個加起來只佔 8 位元組空間的結構體表示了 Go語言中的互斥鎖


狀態

在預設情況下,互斥鎖的所有狀態位都是 0,int32 中的不同位分別表示了不同的狀態:

  • 1位表示是否被鎖定
  • 1位表示是否有協程已經被喚醒
  • 1位表示是否處於飢餓狀態
  • 剩下29位表示阻塞的協程數

正常模式和飢餓模式

正常模式:所有goroutine按照FIFO的順序進行鎖獲取,被喚醒的goroutine和新請求鎖的goroutine同時進行鎖獲取,通常新請求鎖的goroutine更容易獲取鎖(持續佔有cpu),被喚醒的goroutine則不容易獲取到鎖

飢餓模式:所有嘗試獲取鎖的goroutine進行等待排隊,新請求鎖的goroutine不會進行鎖獲取(禁用自旋),而是加入佇列尾部等待獲取鎖

如果一個 Goroutine 獲得了互斥鎖並且它在佇列的末尾或者它等待的時間少於 1ms,那麼當前的互斥鎖就會切換回正常模式。

與飢餓模式相比,正常模式下的互斥鎖能夠提供更好地效能,飢餓模式的能避免 Goroutine 由於陷入等待無法獲取鎖而造成的高尾延時。

互斥鎖加鎖過程
  • 如果互斥鎖處於初始狀態,會直接加鎖
  • 如果互斥鎖處於加鎖狀態,並且工作在普通模式下,goroutine會進入自旋,等待鎖的釋放

goroutine 進入自旋的條件非常苛刻:

  • 互斥鎖只有在普通模式才能進入自旋;
  • runtime.sync_runtime_canSpin需要返回 true
    • 執行在多 CPU 的機器上;
    • 當前 Goroutine 為了獲取該鎖進入自旋的次數小於四次;
    • 當前機器上至少存在一個正在執行的處理器 P 並且處理的執行佇列為空;
  • 如果當前 Goroutine 等待鎖的時間超過了 1ms,互斥鎖就會切換到飢餓模式;
  • 互斥鎖在正常情況下會通runtime.sync_runtime_SemacquireMutex將嘗試獲取鎖的 Goroutine 切換至休眠狀態,等待鎖的持有者喚醒;
  • 如果當前 Goroutine 是互斥鎖上的最後一個等待的協程或者等待的時間小於 1ms,那麼它會將互斥鎖切換回正常模式;

互斥鎖解鎖過程

當互斥鎖已經被解鎖時,再解鎖會丟擲異常

當互斥鎖處於飢餓模式時,將鎖的所有權交給等待佇列最前面的 Goroutine

當互斥鎖處於正常模式時,如果沒有 Goroutine 等待鎖的釋放或者已經有被喚醒的 Goroutine 獲得了鎖,會直接返回;在其他情況下會通過喚醒對應的 Goroutine;

關於互斥鎖鎖的使用建議
  1. 寫業務時不能全域性使用同一個 Mutex
  2. 千萬不要將要加鎖和解鎖分到兩個以上 Goroutine 中進行
  3. Mutex 千萬不能被複制(包括不能通過函式引數傳遞),否則會複製傳參前鎖的狀態:已鎖定 or 未鎖定。很容易產生死鎖,關鍵是編譯器還發現不了這個 Deadlock~

RWMutex-讀寫鎖

Go 中 RWMutex 使用的是寫優先的設計

資料結構

type RWMutex struct {
	w           Mutex	//複用互斥鎖提供的能力
	writerSem   uint32	//writer訊號量
	readerSem   uint32	//reader訊號量
	readerCount int32	//儲存了當前正在執行的讀運算元量
	readerWait  int32	// 表示寫操作阻塞時,等待讀操作完成的個數
}
寫鎖

獲取寫鎖

  1. 呼叫結構體持有的Mutex結構體的Mutex.Lock阻塞後續的寫操作
  2. readerCount減少2^30,成為負數,以阻塞後續讀操作
  3. 如果有其他Goroutine 持有讀鎖,該 Goroutine會進入休眠狀態等待所有讀鎖執行結束後釋放writerSem訊號量將當前協程喚醒

釋放寫鎖

  1. readerCount變回正數,釋放讀鎖
  2. 喚醒所有因為讀鎖而睡眠的Goroutine
  3. 呼叫Mutex.Unlock 釋放寫鎖

獲取寫鎖時會先阻塞寫鎖的獲取,後阻塞讀鎖的獲取,這種策略能夠保證讀操作不會被連續的寫操作『餓死』。


讀鎖

獲取讀鎖

獲取讀鎖的方法 sync.RWMutex.RLock 很簡單,該方法會將readerCount加一:

  • 如果該方法返回負數(代表其他 goroutine 獲得了寫鎖,當前 goroutine 就會使其陷入休眠等待鎖的釋放
  • 如果該方法返回結果為非負數,代表沒有 goroutine 獲得寫鎖,會成功返回

釋放讀鎖

解鎖讀鎖的方法sync.RWMutex.RUnlock,該方法會:

  • readerCount減一,根據返回值的不同會分別進行處理
  • 如果返回值大於等於0,讀鎖直接解鎖成功
  • 如果小於0代表有正在執行的寫操作,會呼叫sync.RWMutex.rUnlockSlow,將readerWait減一,並且當所有讀操作都被釋放後觸發訊號量 writerSem,該訊號量被觸發時,排程器就會喚醒嘗試獲取寫鎖的 Goroutine

WaitGroup

sync.WaitGroup可以等待一組 Goroutine 的返回

sync.WaitGroup 對外暴露了三個方法:

方法名 功能
(wg * WaitGroup) Add(delta int) 計數器+delta
(wg *WaitGroup) Done() 計數器減1
(wg *WaitGroup) Wait() 阻塞直到計數器變為0

sync.WaitGroup.Done只是對 sync.WaitGroup.Add 方法的簡單封裝,相當於是加 -1


Sync.Map

Go語言中內建的map不是併發安全的。

Go語言的sync包中提供了一個開箱即用的併發安全版map–sync.Map。使用互斥鎖保證併發安全

資料結構

type Map struct {
    mu Mutex
    read atomic.Value // readOnly
    dirty map[interface{}]*entry
    misses int
}

開箱即用表示不用像內建的map一樣使用make函式初始化就能直接使用。同時sync.Map內建了方法:

方法名 功能
(m *sync.Map)Store(key, value interface{}) 儲存鍵值對
(m *sync.Map)Load(key interface{}) 根據key獲取對應的值
(m *sync.Map)Delete(key interface{}) 刪除鍵值對
(m *sync.Map)Range(f func(key, value interface{}) bool) 遍歷 sync.Map。Range 的引數是一個函式

*sync.map 沒有Len( ) 方法


原子操作(atomic包)

程式碼中的加鎖操作因為涉及核心態的上下文切換會比較耗時、代價比較高。針對基本資料型別我們還可以使用原子操作來保證併發安全,因為原子操作是Go語言提供的方法它在使用者態就可以完成,因此效能比加鎖操作更好。Go語言中原子操作由內建的標準庫sync/atomic提供。


參考資料:

Go 語言併發程式設計、同步原語與鎖 | Go 語言設計與實現 (draveness.me)

相關文章