什麼是鎖,為什麼使用鎖
用俗語來說,鎖意味著一種保護,對資源的一種保護,在程式設計師眼中,這個資源可以是一個變數,一個程式碼片段,一條記錄,一張資料庫表等等。
就跟小孩需要保護一樣,不保護的話小孩會收到傷害,同樣的使用鎖的原因是資源不保護的話,可能會受到汙染,在併發情況下,多個人對同一資源進行操作,有可能導致資源不符合預期的修改。
常見的鎖的種類
鎖的種類細分的話,非常多,主要原因是從不同角度看,對鎖的定義不一樣,我這裡總結了一下,畫一個思維腦圖,大家瞭解一下。
我個人認為鎖都可以歸為一下四大類,其它的叫法不同只是因為其實現方式或者應用場景而得名,但本質上上還是下面的這四大類中一種。
其它各種類的鎖總結如下,這些鎖只是為了高效能,為了各種應用場景在程式碼實現上做了很多工作,因此而得名,關於他們的資料很多
更多鎖的詳細解釋參考我github的名詞描述,這裡不在贅述,地址如下:
https://github.com/sunpengwei1992/java_common/tree/master/src/lock
Go中的鎖使用和實現分析
Go的程式碼庫中為開發人員提供了一下兩種鎖:
- 互斥鎖 sync.Mutex
- 讀寫鎖 sync.RWMutex
第一個互斥鎖指的是在Go程式設計中,同一資源的鎖定對各個協程是相互排斥的,當其中一個協程獲取到該鎖時,其它協程只能等待,直到這個獲取鎖的協程釋放鎖之後,其它的協程才能獲取。
第二個讀寫鎖依賴於互斥鎖的實現,這個指的是當多個協程對某一個資源都是隻讀操作,那麼多個協程可以獲取該資源的讀鎖,並且互相不影響,但當有協程要修改該資源時就必須獲取寫鎖,如果獲取寫鎖時,已經有其它協程獲取了讀寫或者寫鎖,那麼此次獲取失敗,也就是說讀寫互斥,讀讀共享,寫寫互斥。
Go中關於鎖的介面定義如下:,該介面的實現就是上面的兩個鎖種類,篇幅有限,這篇文章主要是分析一下互斥鎖的使用和實現,因為RWMutex也是基於Mutex的,大家可以參考文章自行學習一下。
type Locker interface {
Lock()
Unlock()
}
type Mutex struct {
state int32 //初始值預設為0
sema uint32 //初始值預設為0
}
Mutex使用也非常的簡單,,宣告一個Mutex變數就可以直接呼叫Lock和Unlock方法了,如下程式碼例項,但使用的過程中有一些注意點,如下:
- 同一個協程不能連續多次呼叫Lock,否則發生死鎖
- 鎖資源時儘量縮小資源的範圍,以免引起其它協程超長時間等待
- mutex傳遞給外部的時候需要傳指標,不然就是例項的拷貝,會引起鎖失敗
- 善用defer確保在函式內釋放了鎖
- 使用-race在執行時檢測資料競爭問題,go test -race ....,go build -race ....
- 善用靜態工具檢查鎖的使用問題
- 使用go-deadlock檢測死鎖,和指定鎖超時的等待問題(自己百度工具用法)
- 能用channel的場景別使用成了lock
var lock sync.Mutex
func MutexStudy(){
//獲取鎖
lock.Lock()
//業務邏輯操作
time.Sleep(1 * time.Second)
//釋放鎖
defer lock.Unlock()
}
我們瞭解了Mutext的使用和注意事項,那麼具體原理是怎麼實現的呢?運用到了那些技術,下面一起分析一下Mutex的實現原理。
Mutex實現中有兩種模式,1:正常模式,2:飢餓模式,前者指的是當一個協程獲取到鎖時,後面的協程會排隊(FIFO),釋放鎖時會喚醒最早排隊的協程,這個協程會和正在CPU上執行的協程競爭鎖,但是大概率會失敗,為什麼呢?因為你是剛被喚醒的,還沒有獲得CPU的使用權,而CPU正在執行的協程肯定比你有優勢,如果這個被喚醒的協程競爭失敗,並且超過了1ms,那麼就會退回到後者(飢餓模式),這種模式下,該協程在下次獲取鎖時直接得到,不存在競爭關係,本質是為了防止協程等待鎖的時間太長。
兩種模式都瞭解了,我們再來分析一下幾個核心常量,程式碼如下:
const (
mutexLocked = 1 << iota //1, 0001 最後一位表示當前鎖的狀態,0未鎖,1已鎖
mutexWoken //2, 0010,倒數第二位表示當前鎖是否會被喚醒,0喚醒,1未喚醒
mutexStarving //4, 0100 倒數第三位表示當前物件是否為飢餓模式,0正常,1飢餓
mutexWaiterShift = iota //3 從倒數第四位往前的bit表示排隊的gorouting數量
starvationThresholdNs = 1e6 // 飢餓的閾值:1ms
)
//Mutex中的變數,這裡主要是將常量對映到state上面
state //0代表未獲取到鎖,1代表得到鎖,2-2^31表示gorouting排隊的數量的
sema //非負數的訊號量,阻塞協程的依據
這幾個變數你要是都弄白了,那麼程式碼看起來就相對好理解一些了,整個Lock的原始碼較長,我將註釋寫入程式碼中,方便大家理解,整個鎖的過程其實分為三部分,建議大家參考原始碼和我的註釋一塊學習。
- 直接獲取鎖,返回
- 自旋和喚醒
- 判斷各種狀態,特殊情況處理
第一部分程式碼如下,較為簡單,獲取鎖成功之後直接返回
//對state進行cas修改操作,修改成功相當於獲取鎖,修改之後state=1
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
第二部分自旋的程式碼如下
//開始等待時間
var waitStartTime int64
//這幾個變數含義依次是:是否飢餓,是否喚醒,自旋次數,鎖的當前狀態
starving := false;awoke := false;iter := 0;old := m.state
//進入死迴圈,直到獲得鎖成功(獲得鎖成功就是有別的協程釋放鎖了)
for {
//這個if的核心邏輯是判斷:已經獲得鎖了並且不是飢餓模式 && 可以自旋,與cpu核數有關
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
//這個是判斷:沒有被喚醒 && 有排隊等待的協程 && 嘗試設定通知被喚醒
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
//說明上個協程此時已經unlock了,喚醒當前協程
awoke = true
}
//自旋一段時間
runtime_doSpin()
//自選次數加1
iter++
old = m.state
continue
}
}
第三部分程式碼,判斷各種狀態,特殊情況處理
new := old
//1:原協程已經unlock了,對new的修改為已鎖
if old&mutexStarving == 0 {
new |= mutexLocked
}
//2:這裡是執行完自旋或者沒執行自旋(原協程沒有unlock)
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift //排隊
}
//3:如果是飢餓模式,並且已鎖的狀態
if starving && old&mutexLocked != 0 {
new |= mutexStarving //設定new為飢餓狀態
}
//4:上面的awoke被設定為true
if awoke {
//當前協程被喚醒了,肯定不為0
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
//既然當前協程被喚醒了,重置喚醒標誌為0
new &^= mutexWoken
}
//修改state的值為new,但這裡new的值會有四種情況,
//就是上面4個if情況對new做的修改,這一步獲取鎖成功
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
//這裡代表的是正常模式獲取鎖成功
break
}
//下面的程式碼是判斷是否從飢餓模式恢復正常模式
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
//進入阻塞狀態
runtime_SemacquireMutex(&m.sema, queueLifo)
//設定是否為飢餓模式,等待的時間大於1ms就是飢餓模式
starving=starving||runtime_nanotime()-waitStartTime> starvationThresholdNs
old = m.state
//如果當前鎖是飢餓模式,但這個gorouting被喚醒
if old&mutexStarving != 0 {
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
//減去當前鎖的排隊
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
//退出飢餓模式
delta -= mutexStarving
}
//修改狀態,終止
atomic.AddInt32(&m.state, delta)
break
}
}
//設定被喚醒
awoke = true
iter = 0
} else {
old = m.state
}
Lock的原始碼我們弄明白了,那麼Unlock呢,大家看程式碼的時候最好Lock和Unlock結合一起來看,因為他們是對同一變數state在操作
func (m *Mutex) Unlock() {
//釋放鎖
new := atomic.AddInt32(&m.state, -mutexLocked)
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
//判斷當前鎖是否飢餓模式,==0代表不是
if new&mutexStarving == 0 {
old := new
for {
//如果沒有未排隊的協程 或者 有已經被喚醒,得到鎖或飢餓的協程,則直接返回
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
//喚醒其它協程
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false)
return
}
old = m.state
}
} else {
//釋放訊號量
runtime_Semrelease(&m.sema, true)
}
}
到這裡整個Mutex的原始碼分析完成,可以看到Metux的原始碼並不是很複雜,只是各種位運算讓開發人員難以直接觀察到結果值,另外閱讀原始碼前一定要先明白各個變數和常量的含義,不然讀起來非常費勁。
本作品採用《CC 協議》,轉載必須註明作者和本文連結