golang sync.Mutex互斥鎖的實現原理

sync.Mutex是一個不可重入的排他鎖。 這點和Java不同,golang裏面的排它鎖是不可重入的。

當一個 goroutine 獲得了這個鎖的擁有權後, 其它請求鎖的 goroutine 就會阻塞在 Lock 方法的調用上,直到鎖被釋放。

數據結構與狀態機

sync.Mutex 由兩個字段 state 和 sema 組成。其中 state 表示當前互斥鎖的狀態,而 sema 是用於控制鎖狀態的信號量。

type Mutex struct {
	state int32
	sema  uint32
}

需要強調的是Mutex一旦使用之後,一定不要做copy操作。

Mutex的狀態機比較複雜,使用一個int32來表示:

const (
	mutexLocked = 1 << iota // mutex is locked
	mutexWoken  //2
	mutexStarving //4
	mutexWaiterShift = iota //3
)
                                                                                             
32                                               3             2             1             0 
 |                                               |             |             |             | 
 |                                               |             |             |             | 
 v-----------------------------------------------v-------------v-------------v-------------+ 
 |                                               |             |             |             v 
 |                 waitersCount                  |mutexStarving| mutexWoken  | mutexLocked | 
 |                                               |             |             |             | 
 +-----------------------------------------------+-------------+-------------+-------------+                                                                                                              

最低三位分別表示 mutexLocked、mutexWoken 和 mutexStarving,剩下的位置用來表示當前有多少個 Goroutine 等待互斥鎖的釋放:

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

  • mutexLocked — 表示互斥鎖的鎖定狀態;
  • mutexWoken — 表示從正常模式被從喚醒;
  • mutexStarving — 當前的互斥鎖進入飢餓狀態;
  • waitersCount — 當前互斥鎖上等待的 goroutine 個數;

爲了保證鎖的公平性,設計上互斥鎖有兩種狀態:正常狀態和飢餓狀態。

正常模式下,所有等待鎖的goroutine按照FIFO順序等待。喚醒的goroutine不會直接擁有鎖,而是會和新請求鎖的goroutine競爭鎖的擁有。新請求鎖的goroutine具有優勢:它正在CPU上執行,而且可能有好幾個,所以剛剛喚醒的goroutine有很大可能在鎖競爭中失敗。在這種情況下,這個被喚醒的goroutine會加入到等待隊列的前面。 如果一個等待的goroutine超過1ms沒有獲取鎖,那麼它將會把鎖轉變爲飢餓模式

飢餓模式下,鎖的所有權將從unlock的gorutine直接交給交給等待隊列中的第一個。新來的goroutine將不會嘗試去獲得鎖,即使鎖看起來是unlock狀態, 也不會去嘗試自旋操作,而是放在等待隊列的尾部。

如果一個等待的goroutine獲取了鎖,並且滿足一以下其中的任何一個條件:(1)它是隊列中的最後一個;(2)它等待的時候小於1ms。它會將鎖的狀態轉換爲正常狀態。

正常狀態有很好的性能表現,飢餓模式也是非常重要的,因爲它能阻止尾部延遲的現象。

Lock

func (m *Mutex) Lock() {
	// 如果mutex的state沒有被鎖,也沒有等待/喚醒的goroutine, 鎖處於正常狀態,那麼獲得鎖,返回.
    // 比如鎖第一次被goroutine請求時,就是這種狀態。或者鎖處於空閒的時候,也是這種狀態。
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

func (m *Mutex) lockSlow() {
	// 標記本goroutine的等待時間
	var waitStartTime int64
	// 本goroutine是否已經處於飢餓狀態
	starving := false
	// 本goroutine是否已喚醒
	awoke := false
	// 自旋次數
	iter := 0
	old := m.state
	for {
		// 第一個條件:1.mutex已經被鎖了;2.不處於飢餓模式(如果時飢餓狀態,自旋時沒有用的,鎖的擁有權直接交給了等待隊列的第一個。)
		// 嘗試自旋的條件:參考runtime_canSpin函數
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			// 進入這裏肯定是普通模式
			// 自旋的過程中如果發現state還沒有設置woken標識,則設置它的woken標識, 並標記自己爲被喚醒。
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				awoke = true
			}
			runtime_doSpin()
			iter++
			old = m.state
			continue
		}
		
		// 到了這一步, state的狀態可能是:
        // 1. 鎖還沒有被釋放,鎖處於正常狀態
        // 2. 鎖還沒有被釋放, 鎖處於飢餓狀態
        // 3. 鎖已經被釋放, 鎖處於正常狀態
        // 4. 鎖已經被釋放, 鎖處於飢餓狀態
        // 並且本gorutine的 awoke可能是true, 也可能是false (其它goutine已經設置了state的woken標識)
        
		// new 複製 state的當前狀態, 用來設置新的狀態
        // old 是鎖當前的狀態
		new := old
		
		// 如果old state狀態不是飢餓狀態, new state 設置鎖, 嘗試通過CAS獲取鎖,
        // 如果old state狀態是飢餓狀態, 則不設置new state的鎖,因爲飢餓狀態下鎖直接轉給等待隊列的第一個.
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}
		// 將等待隊列的等待者的數量加1
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}
		
		// 如果當前goroutine已經處於飢餓狀態, 並且old state的已被加鎖,
        // 將new state的狀態標記爲飢餓狀態, 將鎖轉變爲飢餓狀態.
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}
		
 		// 如果本goroutine已經設置爲喚醒狀態, 需要清除new state的喚醒標記, 因爲本goroutine要麼獲得了鎖,要麼進入休眠,
        // 總之state的新狀態不再是woken狀態.
		if awoke {
			// The goroutine has been woken from sleep,
			// so we need to reset the flag in either case.
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			new &^= mutexWoken
		}

		// 通過CAS設置new state值.
        // 注意new的鎖標記不一定是true, 也可能只是標記一下鎖的state是飢餓狀態.
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			
			// 如果old state的狀態是未被鎖狀態,並且鎖不處於飢餓狀態,
            // 那麼當前goroutine已經獲取了鎖的擁有權,返回
			if old&(mutexLocked|mutexStarving) == 0 {
				break // locked the mutex with CAS
			}
			// If we were already waiting before, queue at the front of the queue.
			// 設置並計算本goroutine的等待時間
			queueLifo := waitStartTime != 0
			if waitStartTime == 0 {
				waitStartTime = runtime_nanotime()
			}
			// 既然未能獲取到鎖, 那麼就使用sleep原語阻塞本goroutine
            // 如果是新來的goroutine,queueLifo=false, 加入到等待隊列的尾部,耐心等待
            // 如果是喚醒的goroutine, queueLifo=true, 加入到等待隊列的頭部
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)

			// sleep之後,此goroutine被喚醒
            // 計算當前goroutine是否已經處於飢餓狀態.
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			// 得到當前的鎖狀態
			old = m.state

			// 如果當前的state已經是飢餓狀態
            // 那麼鎖應該處於Unlock狀態,那麼應該是鎖被直接交給了本goroutine
			if old&mutexStarving != 0 {
				// If this goroutine was woken and mutex is in starvation mode,
				// ownership was handed off to us but mutex is in somewhat
				// inconsistent state: mutexLocked is not set and we are still
				// accounted as waiter. Fix that.
				if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
					throw("sync: inconsistent mutex state")
				}
				// 當前goroutine用來設置鎖,並將等待的goroutine數減1.
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				// 如果本goroutine是最後一個等待者,或者它並不處於飢餓狀態,
                // 那麼我們需要把鎖的state狀態設置爲正常模式.
				if !starving || old>>mutexWaiterShift == 1 {
					 // 退出飢餓模式
					delta -= mutexStarving
				}
				// 設置新state, 因爲已經獲得了鎖,退出、返回
				atomic.AddInt32(&m.state, delta)
				break
			}
			awoke = true
			iter = 0
		} else {
			old = m.state
		}
	}
}

整個過程比較複雜,這裏總結一下一些重點:

  1. 如果鎖處於初始狀態(unlock, 正常模式),則通過CAS(0 -> Locked)獲取鎖;如果獲取失敗,那麼就進入slowLock的流程:

slowLock的獲取鎖流程有兩種模式: 飢餓模式 和 正常模式。

(1)正常模式

  1. mutex已經被locked了,處於正常模式下;
  2. 前 Goroutine 爲了獲取該鎖進入自旋的次數小於四次;
  3. 當前機器CPU核數大於1;
  4. 當前機器上至少存在一個正在運行的處理器 P 並且處理的運行隊列爲空;

滿足上面四個條件的goroutine纔可以做自旋。自旋就會調用sync.runtime_doSpin 和 runtime.procyield 並執行 30 次的 PAUSE 指令,該指令只會佔用 CPU 並消耗 CPU 時間。

處理了自旋相關的特殊邏輯之後,互斥鎖會根據上下文計算當前互斥鎖最新的狀態new。幾個不同的條件分別會更新 state 字段中存儲的不同信息 — mutexLocked、mutexStarving、mutexWoken 和 mutexWaiterShift:

計算最新的new之後,CAS更新,如果更新成功且old狀態是未被鎖狀態,並且鎖不處於飢餓狀態,就代表當前goroutine競爭成功並獲取到了鎖返回。(這也就是當前goroutine在正常模式下競爭時更容易獲得鎖的原因)

如果當前goroutine競爭失敗,會調用 sync.runtime_SemacquireMutex 使用信號量保證資源不會被兩個 Goroutine 獲取。sync.runtime_SemacquireMutex 會在方法中不斷調用嘗試獲取鎖並休眠當前 Goroutine 等待信號量的釋放,一旦當前 Goroutine 可以獲取信號量,它就會立刻返回,sync.Mutex.Lock 方法的剩餘代碼也會繼續執行。

(2) 飢餓模式

飢餓模式本身是爲了一定程度保證公平性而設計的模式。所以飢餓模式不會有自旋的操作,新的 Goroutine 在該狀態下不能獲取鎖、也不會進入自旋狀態,它們只會在隊列的末尾等待。

不管是正常模式還是飢餓模式,獲取信號量,它就會從阻塞中立刻返回,並執行剩下代碼:

  1. 在正常模式下,這段代碼會設置喚醒和飢餓標記、重置迭代次數並重新執行獲取鎖的循環;
  2. 在飢餓模式下,當前 Goroutine 會獲得互斥鎖,如果等待隊列中只存在當前 Goroutine,互斥鎖還會從飢餓模式中退出;

Unlock

func (m *Mutex) Unlock() {
	// Fast path: drop lock bit.
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		// Outlined slow path to allow inlining the fast path.
		// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
		m.unlockSlow(new)
	}
}

func (m *Mutex) unlockSlow(new int32) {
	if (new+mutexLocked)&mutexLocked == 0 {
		throw("sync: unlock of unlocked mutex")
	}
	if new&mutexStarving == 0 {
		old := new
		for {
			// If there are no waiters or a goroutine has already
			// been woken or grabbed the lock, no need to wake anyone.
			// In starvation mode ownership is directly handed off from unlocking
			// goroutine to the next waiter. We are not part of this chain,
			// since we did not observe mutexStarving when we unlocked the mutex above.
			// So get off the way.
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}
			// Grab the right to wake someone.
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else {
		// Starving mode: handoff mutex ownership to the next waiter, and yield
		// our time slice so that the next waiter can start to run immediately.
		// Note: mutexLocked is not set, the waiter will set it after wakeup.
		// But mutex is still considered locked if mutexStarving is set,
		// so new coming goroutines won't acquire it.
		runtime_Semrelease(&m.sema, true, 1)
	}
}

互斥鎖的解鎖過程 sync.Mutex.Unlock 與加鎖過程相比就很簡單,該過程會先使用 AddInt32 函數快速解鎖,這時會發生下面的兩種情況:

  1. 如果該函數返回的新狀態等於 0,當前 Goroutine 就成功解鎖了互斥鎖;
  2. 如果該函數返回的新狀態不等於 0,這段代碼會調用 sync.Mutex.unlockSlow 方法開始慢速解鎖:

sync.Mutex.unlockSlow 方法首先會校驗鎖狀態的合法性 — 如果當前互斥鎖已經被解鎖過了就會直接拋出異常 sync: unlock of unlocked mutex 中止當前程序。

在正常情況下會根據當前互斥鎖的狀態,分別處理正常模式和飢餓模式下的互斥鎖:

  • 在正常模式下,這段代碼會分別處理以下兩種情況處理;
  1. 如果互斥鎖不存在等待者或者互斥鎖的 mutexLocked、mutexStarving、mutexWoken 狀態不都爲 0,那麼當前方法就可以直接返回,不需要喚醒其他等待者;
  2. 如果互斥鎖存在等待者,會通過 sync.runtime_Semrelease 喚醒等待者並移交鎖的所有權;
  • 在飢餓模式下,上述代碼會直接調用 sync.runtime_Semrelease 方法將當前鎖交給下一個正在嘗試獲取鎖的等待者,等待者被喚醒後會得到鎖,在這時互斥鎖還不會退出飢餓狀態;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章