golang中的Mutex設計原理詳解(一)

三水木~發表於2020-12-28

Mutex系列是根據我對晁嶽攀老師的《Go 併發程式設計實戰課》的吸收和理解整理而成,如有偏差,歡迎指正~

目標

本系列除了希望徹底學習和了解 golang 中 sync.Mutex 的原理和使用,更希望借 golang 中 Mutex 的發展和演變,瞭解併發場景下鎖的設計與實現方法以及不通業務場景下的一些特殊考慮。

Mutex 簡介

Mutex 是什麼

Mutex 是 golang 標準庫的互斥鎖,主要用來處理併發場景下共享資源的訪問衝突問題。

Mutex 定義

儘管 Mutex 的實現經歷了多次的重大改版,但是因為設計的巧妙,使用上並沒有發生任何變化。

package sync // import "sync"

type Mutex struct {
    state int32
    sema  uint32
}
    A Mutex is a mutual exclusion lock. The zero value for a Mutex is an
    unlocked mutex.

    A Mutex must not be copied after first use.

func (m *Mutex) Lock()
func (m *Mutex) Unlock()

從 Mutex 的定義一眼就能看出來如何使用,加鎖使用 Lock 函式,解鎖使用 Unlock 函式。

其實 package sync 中定義了 Locker 介面:

package sync // import "sync"

type Locker interface {
        Lock()
        Unlock()
}
    A Locker represents an object that can be locked and unlocked.

Mutex 實現了 Locker 介面。除了互斥鎖 Mutex,像之後會介紹的讀寫鎖 RWMutex,也實現了 Locker 介面。

golang 中 Mutex 演變的4個階段

現在去看 go1.14 中 Mutex 的實現,是比較複雜和精巧的,但是 Mutex 的複雜和精巧不是一蹴而就的。從初版的 Mutex,到現在的 Mutex,大致經過了以下4個階段的演變:
在這裡插入圖片描述

接下來會通過這4個階段對應的 Mutex 原始碼來理解 golang 在互斥鎖的設計思路上的逐漸進化的過程。

希望通過這樣一個學習,不僅能更好的掌握 Mutex 這個工具,還能學習到如何設計一個兼顧公平和效能的互斥鎖。

初版 Mutex 實現

初版 Mutex 的具體實現如下:

   // CAS操作,當時還沒有抽象出atomic包
    func cas(val *int32, old, new int32) bool
    func semacquire(*int32)
    func semrelease(*int32)
    // 互斥鎖的結構,包含兩個欄位
    type Mutex struct {
        key  int32 // 鎖是否被持有的標識
        sema int32 // 訊號量專用,用以阻塞/喚醒goroutine
    }
    
    // 保證成功在val上增加delta的值
    func xadd(val *int32, delta int32) (new int32) {
        for {
            v := *val
            if cas(val, v, v+delta) {
                return v + delta
            }
        }
        panic("unreached")
    }
    
    // 請求鎖
    func (m *Mutex) Lock() {
        if xadd(&m.key, 1) == 1 { //標識加1,如果等於1,成功獲取到鎖
            return
        }
        semacquire(&m.sema) // 否則阻塞等待
    }
    
    func (m *Mutex) Unlock() {
        if xadd(&m.key, -1) == 0 { // 將標識減去1,如果等於0,則沒有其它等待者
            return
        }
        semrelease(&m.sema) // 喚醒其它阻塞的goroutine
    }

理解程式碼之前,先簡單介紹下 cas、semacquire 和 semrelease。

cas 的全拼是 compare and set 或者 compare and swap。cas 指令實現的功能是將給定的值 old 和記憶體中的值 *val 比較,如果相等,將 new 賦值給 *val,否則返回失敗。

semacquire 和 semrelease 利用訊號量 sema 實現了阻塞和喚醒功能。

接下來我們開始分析上面的程式碼。

初版 Mutex 的定義

首先看 Mutex 的定義。這個初版的定義其實和最新版的定義的區別在 key 這個欄位上。初版中,key 的含義比較簡單,就是一個標誌位,等於0表示鎖未被持有,1表示被某個 goroutine 持有,等於 n 表示還有 n-1 個等待者。

加鎖

加鎖(Lock)的過程首先是給 key 加1。

如果 key 返回1,則表示當前 goroutine 佔有了這把鎖,其它 goroutine 只能做候選者。

如果 key 返回n(n > 1),這說明當前有其它 gorutine 正在佔用這把鎖,所以接下來需要通過訊號量機制將當前 goroutine 掛起,加到等待佇列,進入阻塞狀態。

解鎖

解鎖(Unlock)的過程是給 key 減1。

如果 key 返回0,表示當前沒有其它 goroutine 在等待,可以直接返回;如果 key 返回 n (n > 0),說明還有其它 goroutine 在等待,因此需要通過訊號量機制將等待佇列中的其它 goroutine 喚醒。

初版 Mutex 的問題

初版 Mutex 在實現的時候,有兩個問題:1)Unlock 呼叫無限制;2)goroutine 喚醒機制效能低下。

Unlock 呼叫無限制問題

Mutex 本身並沒有包含當前 goroutine 的任何資訊,因此 Unlock 方法能被任意的 goroutine 呼叫。這樣會導致一個問題,如果某個 goroutine 不按套路來,隨便呼叫 Unlock 函式,讓標誌位 key 清零,那麼資料競爭的問題還是會出現。

Mutex 的這個特性一直保留至今。因此使用 Mutex 的時候,一定要遵循 “誰加鎖,誰解鎖” 的原則。

goroutine 喚醒機制效能低下

初版 Mutex 喚醒 goroutine 的機制是按排隊順序,誰在前面就先喚醒誰。這樣看著很公平,但是從效能上看,並不是最優。因為沉睡的 goroutine 喚醒之後,還需要進行上下文的切換,如果把喚醒機會給當前正佔用 CPU 時間片的 goroutine,那麼高併發的時候,可能會有更好的效能。

這也是下一個版本的 Mutex 重點解決的問題。

結尾

初版的 Mutex 通過 標誌位 key 實現了互斥鎖的基本功能。在下一個版本的 Mutex 中,我會重點闡述 Mutex 是如何解決 goroutine 喚醒機制效能低下的問題。

1、初版 Mutex 的詳細程式碼
2、初版 cas 的詳細程式碼
3、初版 semacquire 和 semrelease 詳細程式碼

相關文章