Go 中鎖的那些姿勢,估計你不知道

a_wei發表於2020-01-12

什麼是鎖,為什麼使用鎖

用俗語來說,鎖意味著一種保護,對資源的一種保護,在程式設計師眼中,這個資源可以是一個變數,一個程式碼片段,一條記錄,一張資料庫表等等。

就跟小孩需要保護一樣,不保護的話小孩會收到傷害,同樣的使用鎖的原因是資源不保護的話,可能會受到汙染,在併發情況下,多個人對同一資源進行操作,有可能導致資源不符合預期的修改。

常見的鎖的種類

鎖的種類細分的話,非常多,主要原因是從不同角度看,對鎖的定義不一樣,我這裡總結了一下,畫一個思維腦圖,大家瞭解一下。

我個人認為鎖都可以歸為一下四大類,其它的叫法不同只是因為其實現方式或者應用場景而得名,但本質上上還是下面的這四大類中一種。

Go中鎖的那些姿勢,估計你不知道

其它各種類的鎖總結如下,這些鎖只是為了高效能,為了各種應用場景在程式碼實現上做了很多工作,因此而得名,關於他們的資料很多

Go中鎖的那些姿勢,估計你不知道

更多鎖的詳細解釋參考我github的名詞描述,這裡不在贅述,地址如下:

https://github.com/sunpengwei1992/java_common/tree/master/src/lock

Go中的鎖使用和實現分析

Go的程式碼庫中為開發人員提供了一下兩種鎖:

  1. 互斥鎖 sync.Mutex
  2. 讀寫鎖 sync.RWMutex

第一個互斥鎖指的是在Go程式設計中,同一資源的鎖定對各個協程是相互排斥的,當其中一個協程獲取到該鎖時,其它協程只能等待,直到這個獲取鎖的協程釋放鎖之後,其它的協程才能獲取。

第二個讀寫鎖依賴於互斥鎖的實現,這個指的是當多個協程對某一個資源都是隻讀操作,那麼多個協程可以獲取該資源的讀鎖,並且互相不影響,但當有協程要修改該資源時就必須獲取寫鎖,如果獲取寫鎖時,已經有其它協程獲取了讀寫或者寫鎖,那麼此次獲取失敗,也就是說讀寫互斥,讀讀共享,寫寫互斥。

Go中關於鎖的介面定義如下:,該介面的實現就是上面的兩個鎖種類,篇幅有限,這篇文章主要是分析一下互斥鎖的使用和實現,因為RWMutex也是基於Mutex的,大家可以參考文章自行學習一下。

type Locker interface {
   Lock()
   Unlock()
}
type Mutex struct {
   state int32 //初始值預設為0
   sema  uint32 //初始值預設為0
}

Mutex使用也非常的簡單,,宣告一個Mutex變數就可以直接呼叫Lock和Unlock方法了,如下程式碼例項,但使用的過程中有一些注意點,如下:

  1. 同一個協程不能連續多次呼叫Lock,否則發生死鎖
  2. 鎖資源時儘量縮小資源的範圍,以免引起其它協程超長時間等待
  3. mutex傳遞給外部的時候需要傳指標,不然就是例項的複製,會引起鎖失敗
  4. 善用defer確保在函式內釋放了鎖
  5. 使用-race在執行時檢測資料競爭問題,go test -race ....,go build -race ....
  6. 善用靜態工具檢查鎖的使用問題
  7. 使用go-deadlock檢測死鎖,和指定鎖超時的等待問題(自己百度工具用法)
  8. 能用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的原始碼較長,我將註釋寫入程式碼中,方便大家理解,整個鎖的過程其實分為三部分,建議大家參考原始碼和我的註釋一塊學習。

  1. 直接獲取鎖,返回
  2. 自旋和喚醒
  3. 判斷各種狀態,特殊情況處理

第一部分程式碼如下,較為簡單,獲取鎖成功之後直接返回

//對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的原始碼並不是很複雜,只是各種位運算讓開發人員難以直接觀察到結果值,另外閱讀原始碼前一定要先明白各個變數和常量的含義,不然讀起來非常費勁。

Go中鎖的那些姿勢,估計你不知道

本作品採用《CC 協議》,轉載必須註明作者和本文連結
那小子阿偉

相關文章