Golang 讀寫鎖RWMutex 互斥鎖Mutex 原始碼詳解

LinkinStar發表於2019-05-23

前言

Golang中有兩種型別的鎖,Mutex (互斥鎖)和RWMutex(讀寫鎖)對於這兩種鎖的使用這裡就不多說了,本文主要側重於從原始碼的角度分析這兩種鎖的具體實現。

 

引子問題

我一般喜歡帶著問題去看原始碼。那麼對於讀寫鎖,你是否有這樣的問題,為什麼可以有多個讀鎖?有沒有可能出現有協程一直無法獲取到寫鎖的情況?帶著你的疑問來往下看看,具體這個鎖是如何實現的。

如果你自己想看,我給出閱讀的一個思路,可以先看讀寫鎖,因為讀寫鎖的實現依賴於互斥鎖,並且讀寫鎖比較簡單一些,然後整理思路之後再去想一下實際的應用場景,然後再去看互斥鎖。

下面我就會按照這個思路一步步往下走。

 

基礎知識點

  • 知識點1:訊號量
    訊號量是 Edsger Dijkstra 發明的資料結構(沒錯就是那個最短路徑演算法那個牛人),在解決多種同步問題時很有用。其本質是一個整數,並關聯兩個操作:

申請acquire(也稱為 wait、decrement 或 P 操作)
釋放release(也稱 signal、increment 或 V 操作)

acquire操作將訊號量減 1,如果結果值為負則執行緒阻塞,且直到其他執行緒進行了訊號量累加為正數才能恢復。如結果為正數,執行緒則繼續執行。
release操作將訊號量加 1,如存在被阻塞的執行緒,此時他們中的一個執行緒將解除阻塞。

  • 知識點2:鎖的定義


    在goalng中如果實現了Lock和Unlock方法,那麼它就可以被稱為鎖。

  • 知識點3:鎖的自旋:(詳見百度)

  • 知識點4:cas演算法:(最好有所瞭解,不知道問題也不大)

讀寫鎖RWMutex

首先我們來看看RWMutex大體結構


看到結構發現讀寫鎖內部包含了一個w Mutex互斥鎖
註釋也很明確,這個鎖的目的就是控制多個寫入操作的併發執行
writerSem是寫入操作的訊號量
readerSem是讀操作的訊號量
readerCount是當前讀操作的個數
readerWait當前寫入操作需要等待讀操作解鎖的個數
這幾個現在看不懂沒關係,後面等用到了你再回來看就好了。

 

然後我們看看方法


一共有5個方法,看起來就不復雜,我們一個個來看。


這個最簡單,就是返回一個locker物件沒啥好說的

問題的關鍵就在於鎖和解鎖的幾個方法,因為我已經看過,所以推薦這幾個方法的閱讀順序是RLock Lock RUnlock Unlock

 

RLock(獲取讀鎖)


先不看競態檢測的部分,先重點看紅色框中的部分
可以看到,其實很簡單,每當有協程需要獲取讀鎖的時候,就將readerCount + 1
但是需要注意的是,這裡有一個條件,當readerCount + 1之後的值 < 0的時候,那麼將會呼叫runtime_Semacquire方法

這個方法是一個runtime的方法,會一直等待傳入的s出現>0的時候
然後我們可以記得,這裡有這樣一個情況,當出先readerCount + 1為負數的情況那麼就會被等待,看註釋我們可以猜到,是當有寫入操作出現的時候,那麼讀操作就會被等待。

 

Lock(獲取寫鎖)


寫鎖稍微複雜一些,但是樣子也差不多,我們還是先來看紅色框中的部分。
首先操作最前面說的互斥鎖,目的就是處理多個寫鎖併發的情況,因為我們知道寫鎖只有一把。這裡不需要深入互斥鎖,只需要知道,互斥鎖只有一個人能拿到,所以寫鎖只有一個人能拿到。

然後重點來了,這裡的這個操作細細體會一下,atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders)
是將當前的readerCount減去一個非常大的值rwmutexMaxReaders為1 << 30
大概是1073741823這麼大吧

所以我們可以從原始碼中看出,readerCount由於每有一個協程獲取讀鎖就+1,一直都是正數,而當有寫鎖過來的時候,就瞬間減為很大的負數。
然後做完上面的操作以後的r其實就是原來的readerCount。
後面進行判斷,如果原來的readerCount不為0(原來有協程已經獲取到了讀鎖)並且將readerWait加上readerCount(表示需要等待readerCount這麼多個讀鎖進行解鎖),如果滿足上述條件證明原來有讀鎖,所以暫時沒有辦法獲取到寫鎖,所以呼叫runtime_Semacquire進行等待,等待的訊號量為writerSem

 

RUnlock(釋放讀鎖)


如果是我們來寫的話,可能就是將之前+1的readerCount,-1就完事了,但是其實還有一些操作需要注意。
如果-1之後+1==0是啥情況?沒錯就是我們常見的,新手程式設計師,沒有獲取讀鎖就想去釋放讀鎖,於是異常了。當然+1之後剛好是rwmutexMaxReaders,就證獲取了寫鎖而去釋放了讀鎖,導致異常。
除去異常情況,剩下的就是r還是<0的情況,那麼證明確實有協程正在想要獲取寫鎖,那麼就需要操作我們前面看到的readerWait,當readerWait減到0的時候就證明沒有人正在持有寫鎖了,就通過訊號量writerSem的變化告知剛才等待的協程(想要獲取寫鎖的協程):你可以進行獲取了。

到這裡你可以把思路大致串起來了,然後懂了再往下看。

 

Unlock(釋放寫鎖)


寫鎖釋放需要恢復readerCount,還記得上鎖的時候減了一個很大的數,這個時候要加回來了。
當然加完之後如果>=rwmutexMaxReaders本身,那麼還是新手程式設計師的問題,當沒有獲取寫鎖的時候就開始想著釋放寫鎖了。
然後for迴圈就是為了通知所有在我們RLock方法中看到的,當有因為持有寫鎖所以等待的那些協程,通過訊號量readerSem告訴他們可以動了。
最後別忘記還有一個互斥鎖需要釋放,讓別的協程也可以開始搶寫鎖了。

至此,讀寫鎖的分析基本上告一段落了。
針對於其中關於競態分析的程式碼,有興趣的小夥伴可以去了解一下。

 

 

互斥鎖Mutex

互斥鎖比讀寫鎖複雜,但是好在golang給的註釋很詳細,所以也不困難(註釋真的很重要)。
我們先來看看裡面的一段註釋:

很長的一段英文,我用英語四級的翻譯能力給你翻譯一下,可以將就看看,如果可以建議你仔細看英文看懂它,因為這對於後面的原始碼閱讀非常重要。


///
這個互斥鎖是公平鎖

互斥鎖有兩種操作模式:正常模式和飢餓模式。
在正常模式下等待獲取鎖的goroutine會以一個先進先出的方式進行排隊,但是被喚醒的等待者並不能代表它已經擁有了這個mutex鎖,它需要與新到達的goroutine爭奪mutex鎖。新來的goroutine有一個優勢 —— 他們已經在CPU上執行了並且他們,所以搶到的可能性大一些,所以一個被喚醒的等待者有很大可能搶不過。在這樣的情況下,被喚醒的等待者在佇列的頭部。如果一個等待者搶鎖超過1ms失敗了,就會切換為飢餓模式。

在飢餓模式下,mutex鎖會直接由解鎖的goroutine交給佇列頭部的等待者。
新來的goroutine不能嘗試去獲取鎖,即使可能根本就沒goroutine在持有鎖,並且不能嘗試自旋。取而代之的是他們只能排到隊伍尾巴上乖乖等著。

如果一個等待者獲取到了鎖,並且遇到了下面兩種情況之一,就恢復成正常工作模式。
情況1:它是最後一個佇列中的等待者。
情況2:它等待的時間小於1ms

正常模式下,即使有很多阻塞的等待者,有更好的表現,因為一輪能多次獲得鎖的機會。飢餓模式是為了避免那些一直在隊尾的倒黴蛋。
///

 

 

我的話簡單總結就是,互斥鎖有兩種工作模式,競爭模式和佇列模式,競爭就是大家一起搶,佇列就是老老實實排隊,這兩種工作模式會通過一些情況進行切換。

 

首先還是來看看大體結構


可以看到,相對讀寫鎖,結構上面很簡單,只有兩個值,但是千萬不要小瞧它,減少了欄位就增加了理解難度。
state:將一個32位整數拆分為:
當前阻塞的goroutine數(29位)
飢餓狀態(1位,0為正常模式;1為飢餓模式)
喚醒狀態(1位,0未喚醒;1已喚醒)
鎖狀態(1位,0可用;1佔用)

sema:訊號量


方法也很簡單,就是Lock和Unlock兩個方法,一個上鎖,一個解鎖,沒啥好說的。

 

一個方法

我們先來看一個的要用到的方法

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
這個函式,會先判斷引數addr指向的被操作值與引數old的值是否相等,如果相等會將引數new替換引數addr所指向的值,不然的話就啥也不做。
需要特別說明的是,這個方法並不會阻塞。

 

幾個常量

這是定義的幾個常量,我們在一開始的註釋周圍可以看到,後面需要用到,暫時記住它們的初始值就好。

mutexLocked = 1 << iota // 1左移0位,是1,二進位制是1,(1表示已經上鎖)
mutexWoken // 1左移1位,是2,二進位制是10
mutexStarving // 1左移2位,是4,二進位制是100
mutexWaiterShift = iota // 就是3, 二進位制是11

starvationThresholdNs = 1e6 // 這個就是我們一開始在註釋裡面看到的1ms,一定超過這個門限值就會更換模式

 

Lock獲取鎖

因為Lock方法比較長,所以我切分一段段看,需要完整的請自己翻看原始碼。要注意的一點是,一定要時刻記住,Lock方法是做什麼的,很簡單,就是要搶鎖。看不懂的時候想想這個目標。

第一步,判斷state狀態是否為0,如果為0,證明沒有協程持有鎖,那麼就很簡單了,直接獲取到鎖,將mutexLocked(為1)賦值到state就可以了。

看後面的方法時,告訴需要告訴你們一個小技巧,當遇到這種位操作很多的情況,有兩個方法挺好用,對於你看原始碼會有幫助:
第一個是將所有定值先計算,然後判斷非定值的情況;
第二個是將所有的計算寫下來,自己用筆去計算,不要執著於打字。

然後我們以下面這個段舉例:

首先,看註釋應該能明白這一段大致意思是,如果不是飢餓模式,就會進行自旋操作,然後不斷迴圈。

然後根據上面的技巧,old&(mutexLocked|mutexStarving) == mutexLocked
(下面均為二進位制)
mutexLocked = 1
mutexStarving = 11
mutexLocked = 1
這三個是定值,所以我們容易得到,滿足情況的結果為,當old為xxxx0xx(二進位制第三位為0)等式成立。
也就是我們一開始說的,state的第三位是表示這個鎖當前的模式,0為正常模式,1為飢餓模式。

那麼第一個if就表示,如果當前模式為正常模式,且可以自旋,就進入if條件內部。


if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&

同樣的分析,awoke表示是否喚醒,old&mutexWoken是取第二位,0表示當前協程未被喚醒,old>>mutexWaiterShift表示右移3位,也就是前29位,不為0證明有協程在等待,並且嘗試去對比當前m.state與取出時的old狀態,嘗試去喚醒自己。然後自旋,並且增加自旋次數表示iter,然後重新賦值old。再迴圈下一次。

(你自己理一理,確實有點繞,仔細想想就想通了就對了。)

以上是使用自旋的情況,就是canSpin的。


然後進行判斷old&mutexStarving == 0就是第三位為0的情況,還是所說的正常模式。new就馬上拿到鎖了,new |= mutexLocked,表示或1,就是第一位無論是啥都賦值為1

 

old&(mutexLocked|mutexStarving),也就是old & 0101
必須當old的1和3兩個位置為1的時候才是true,也就是說當前處於飢餓模式,並且鎖已經被佔用的情況,那麼就需要排隊去。
排隊也很精妙,new += 1 << mutexWaiterShift
這邊注意是先計算1 << mutexWaiterShift也就是將new的前29位+1,就是表示有一個協程在等待了。

 

好了到這裡你的位操作應該就習慣的差不多了,之後我就直接說結論,不仔細的幫你01表示了,你已經長大了,要學會自己動手了。

如果當前已經標記為飢餓模式,並且沒有鎖住,那麼設定new為飢餓模式
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}

 

如果喚醒,需要在兩種情況下重設標誌
if awoke {
如果喚醒標誌為與awoke不相協調就panic
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
設定喚醒狀態位0,被喚醒
new &= mutexWoken
}


如果獲取鎖成功

old&(mutexLocked|mutexStarving) == 0成立表示已經獲取鎖,就直接退出CAS

中間這一段我就不多解釋了,就是最前面註釋說的,滿足什麼條件轉換什麼模式,不多說了。然後從佇列中,也就是前29位-1。
需要注意其中有一個runtime_SemacquireMutex和之前看的的runtime_Semacquire是一個意思,只是多了一個引數。

這個就是這個方法的註釋。可以看到,就是多了個佇列去排隊。


如果獲取鎖失敗,old重新整理狀態再次迴圈,繼續cas

 

UnLock釋放鎖

Unlock就相對簡單一些,競態分析不看。
其實我們自己想也能想到,unlock就是將標識位改回來嘛。
然後因為我們已經看過讀寫鎖了,也是同樣的道理,如果沒有上鎖就直接解鎖,那肯定報錯嘛。


然後如果是正常模式,如果沒有等待的goroutine或goroutine已經解鎖完成的情況就直接返回了。如果有等待的goroutine那就通過訊號量去喚醒runtime_Semrelease(注意這裡是false),同時操作一下佇列-1


如果是飢餓模式就直接喚醒(注意這裡是true),反正有佇列嘛。

 

總結

其實話說回來,我們其實看起來也簡單,沒有衝突的情況下,能拿就拿唄,如果出現衝突了就嘗試自旋解決(自旋一般都能解決)如果解決不了就通過訊號量解決,同時如果正常模式就是我們說的搶佔式,非公平,如果是飢餓模式,就是我們說的排隊,公平,防止有一些倒黴蛋一直搶不到。

整體總結一下,看完原始碼我們發現,其實鎖的設計並不複雜,主要設計我們要學到cas和處理讀寫狀態的訊號量通知,對於那些位操作,能看懂,學可能一時半會學不會,因為很難在一開始就設計的那麼巧妙,你也體會到了只用一個變數就維護了整個體系是一種藝術。

 寫的著急,難免有疏漏,如果有任何問題請評論,馬上修改,以免誤導。

 

 

 

 

作者:LinkinStar
未經允許,不得轉載
出處:https://www.cnblogs.com/linkstar/p/10913502.html

 

相關文章