Go 互斥鎖 Mutex 原始碼分析(二)

lubanseven發表於2024-08-24

原創文章,歡迎轉載,轉載請註明出處,謝謝。


0. 前言

Go 互斥鎖 Mutex 原始碼分析(一) 一文中分析了互斥鎖的結構和基本的搶佔互斥鎖的場景。在學習鎖的過程中,看的不少文章是基於鎖的狀態解釋的,個人經驗來看,從鎖的狀態出發容易陷入細節,瞭解鎖的狀態轉換過一段時間就忘,難以做到真正的理解。想來是用靜態的方法分析動態的問題導致的。在實踐中發現結合場景分析互斥鎖對筆者來說更加清晰,因此有了 Go 互斥鎖 Mutex 原始碼分析(一),本文接著結合不同場景分析互斥鎖。

1. 不同場景下的鎖狀態

1.1 喚醒 goroutine

給出示意圖:

image

G1 透過 Fast path 拿到鎖,G2 在自旋之後,鎖還是已鎖狀態。這是和 Go 互斥鎖 Mutex 原始碼分析(一) 中的場景不一樣的地方。接著自旋之後看,這種場景下會發生什麼:

func (m *Mutex) lockSlow() {
	...
	for {
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            ...
        }
        // step2: 當前鎖未釋放,old = 1
        new := old

        // step2: 如果當前鎖是飢餓的,跳過期望狀態 new 的更新
        // -      這裡鎖不是飢餓鎖,new = old = 1
        if old&mutexStarving == 0 {
			new |= mutexLocked
		}

        // step2: 當前鎖未釋放,更新 new
        // -      更新 new 的等待 goroutine 位,表示有一個 goroutine 等待
        // -      更新 new 為 1001,new = 9 
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}

		// step2: 當前 goroutine 不是飢餓狀態,跳過 new 更新
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}

        // step2: 當前 goroutine 不是喚醒狀態,跳過 new 更新
        if awoke {
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			new &^= mutexWoken
		}

        // step3: 原子 CAS 更新鎖的狀態
        // -      這裡更新鎖 m.state = 1 為 m.state = new = 9
        // -      表示當前有一個 goroutine 在等待鎖
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            ...
            // waitStartTime = 0, queueLifo = false
            queueLifo := waitStartTime != 0
			if waitStartTime == 0 {
                // 更新 waitStartTime
				waitStartTime = runtime_nanotime()
			}

            // step4: 呼叫 runtime_SemacquireMutex 阻塞 goroutine
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            ...
        }
    }
}

Mutex.lockSlow 中更新了鎖狀態,接著進入 runtime_SemacquireMutexruntime_SemacquireMutex 是個非常重要的函式,我們有必要介紹它。

runtime_SemacquireMutex 接收三個引數。其中,重點是訊號量 &m.semaqueueLifo。如果 queueLifo = false,當前 goroutine 將被新增到等待鎖佇列的隊尾,阻塞等待喚醒。

G2 執行到 runtime_SemacquireMutex 時將進入阻塞等待喚醒狀態,那麼怎麼喚醒 G2 呢? 我們需要看解鎖過程。

1.1.1 sync.Mutex.Unlock

在 G2 阻塞等待喚醒時,G1 開始釋放鎖。進入 sync.Mutex.Unlock

func (m *Mutex) Unlock() {
	...
	// 將 m.state 的鎖標誌位置為 0,表示鎖已釋放
	new := atomic.AddInt32(&m.state, -mutexLocked)
    // 檢查 new 是否為 0,如果為 0 則表示當前無 goroutine 等待,直接退出
    // 這裡 new = 9,G2 在等待喚醒
	if new != 0 {
		m.unlockSlow(new)
	}
}

進入 Mutex.unlockSlow

func (m *Mutex) unlockSlow(new int32) {
    // 檢查鎖是否已釋放,釋放一個已經釋放的鎖將報錯
	if (new+mutexLocked)&mutexLocked == 0 {
		fatal("sync: unlock of unlocked mutex")
	}

    // 檢查鎖是普通鎖還是飢餓鎖
    if new&mutexStarving == 0 {
        // 這裡 new = 8 是普通鎖,進入處理普通鎖邏輯
		old := new
		for {
            // 如果沒有 goroutine 等待,則返回
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}

            // old 的喚醒位置 1,並且將等待的 goroutine 減 1,表示將喚醒一個等待中的 goroutine
            // 這裡 new = 2
			new = (old - 1<<mutexWaiterShift) | mutexWoken
            // m.state = 8, old = 8, new = 2
            // CAS 更新 m.state = new = 2
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
                // 進入 runtime_Semrelease 喚醒 goroutine
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else {
        // 處理飢餓鎖邏輯,暫略
		runtime_Semrelease(&m.sema, true, 1)
	}
}

sync.Mutex.Unlock 中的 runtime_Semrelease 喚醒佇列中等待的 goroutine。其中,主要接收訊號量 &m.semahandoff 兩個引數。這裡 handoff = false,將增加訊號量,喚醒佇列中等待的 goroutine G2。

1.1.2 喚醒 G2

喚醒之後,G2 繼續執行後續程式碼:

func (m *Mutex) lockSlow() {
	...
	for {
		...
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			...
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)

			// 檢查喚醒的 goroutine 是否是飢餓模式
			// 如果是飢餓模式,或等待鎖時間超過 1ms 則將 goroutine 置為飢餓模式
			// 注意這是 goroutine 是飢餓的,不是鎖是飢餓鎖
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			
			// m.state 在 G1 unlock 時被更新為 2
			old = m.state

			// 鎖不是飢餓鎖,跳過
			if old&mutexStarving != 0 {
				...
			}
			awoke = true
			iter = 0
		}
	}
}

喚醒後的 G2 將 old 更新為 2。訊號量增加,釋放鎖,只會喚醒一個 goroutine,被喚醒的 goroutine,這裡是 G2,將繼續迴圈:

func (m *Mutex) lockSlow() {
	...
	for {
		// old = 2,不會進入自旋
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			...
		}
		// 更新 new:new 是期望 goroutine 更新的狀態
		// 這裡 new = old = 2
		new := old

		// old = 2,不是飢餓鎖
		// 更新 new 為 011,3
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}
		// old = 2,表示鎖已釋放,不會將 goroutine 加入等待位
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}
		// 不飢餓,跳過
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}
		// awoke = true
		if awoke {
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			// 重置喚醒位,將 new 更新為 001,1
			new &^= mutexWoken
		}

		// m.state = 2, old = 2, new =1
		// CAS 更新 m.state= new = 1,表示當前 goroutine 已加鎖
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			// 當前 goroutine 已加鎖跳出迴圈
			if old&(mutexLocked|mutexStarving) == 0 {
				break // locked the mutex with CAS
			}
			...
		}
	}
}

在迴圈一輪後,G2 將拿到鎖,接著執行臨界區程式碼,最後在釋放鎖。

這裡的場景是喚醒之後,goroutine 不飢餓。那麼飢餓鎖又是如何觸發的呢?我們繼續看飢餓鎖的場景。

1.2 飢餓鎖

飢餓鎖場景下的示意圖如下:

image

當 G1 釋放鎖時,G3 正在自旋等待鎖釋放。當 G1 釋放鎖時,被喚醒的 G2 和自旋的 G3 競爭大機率會拿不到鎖。Go 在 1.9 中引入互斥鎖的 飢餓模式 來確保互斥鎖的公平性。

對於互斥鎖迴圈中的大部分流程,我們在前兩個場景下也過了一遍,這裡有重點的摘寫,以防贅述。

首先,還是看 G2,當 G1 釋放鎖時,G2 被喚醒,執行後續程式碼。如下:

func (m *Mutex) lockSlow() {
	...
	for {
		...
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			...
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)

			// 喚醒 G2,G2 等待鎖時間超過 1ms
			// starving = true
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs

			// 鎖被 G3 搶佔,m.state = 0011
			old = m.state

			// 這時候 old 還不是飢餓鎖,跳過
			if old&mutexStarving != 0 {
				...
			}
			awoke = true
			iter = 0
		}
	}
}

喚醒 G2 之後,G2 等待鎖時間超過 1ms 進入飢餓模式。接著進入下一輪迴圈:

func (m *Mutex) lockSlow() {
	...
	for {
		// old 是喚醒鎖,不會進入自旋
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			...
		}

		// 鎖的期望狀態,new = old = 0011
		new := old

		// 鎖不是飢餓鎖,更新 new 的鎖標誌位為已鎖
		// new = 0011
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}

		// 鎖如果是飢餓或者已鎖狀態更新 goroutine 等待位
		// new = 1011
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}

		// goroutine 飢餓,且鎖已鎖
		// 更新 new 為飢餓狀態,new = 1111
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}

		// 這裡 G2 是喚醒的,重置喚醒位
		// new = 1101
		if awoke {
			if new&mutexWoken == 0 {
				throw("sync: inconsistent mutex state")
			}
			new &^= mutexWoken
		}

		// CAS 更新 m.state = new = 1101
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			...
			// G2 入佇列過,這裡 queueLifo = true
			queueLifo := waitStartTime != 0

			// 將 G2 重新加入佇列,並加入到隊首,阻塞等待
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
			...
		}
	}
}

G2 進入飢餓模式,將互斥鎖置為飢餓模式,當前互斥鎖狀態為 m.state = 1101。G2 作為佇列中的隊頭,阻塞等待鎖釋放。

類似的,我們看 G3 釋放鎖的過程。

1.2.1 釋放飢餓鎖

G3 開始釋放鎖:

func (m *Mutex) Unlock() {
	...

	// new = 1100
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		// 進入 Mutex.unlockSlow
		m.unlockSlow(new)
	}
}

func (m *Mutex) unlockSlow(new int32) {
	...
	// new = 1100,是飢餓鎖
	if new&mutexStarving == 0 {
		...
	} else {
		// 進入處理飢餓鎖邏輯
		// handoff = true,直接將隊頭阻塞的 goroutine 喚醒
		runtime_Semrelease(&m.sema, true, 1)
	}
}

1.2.2 飢餓鎖喚醒

在一次的在隊頭中阻塞的 G2 被喚醒,接著執行喚醒後的程式碼:

func (m *Mutex) lockSlow() {
	...
	for {
		...
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			...
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			old = m.state

			// old = 1100,是飢餓鎖
			if old&mutexStarving != 0 {
				...

				// delta = -(1001)
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				if !starving || old>>mutexWaiterShift == 1 {
					...
					// delta = -(1101)
					delta -= mutexStarving
				}

				//更新互斥鎖狀態 m.state = 0001,退出迴圈
				atomic.AddInt32(&m.state, delta)
				break
			}
		}
	}
}

喚醒之後的 G2 直接獲得鎖,將互斥鎖狀態置為已鎖,直到釋放。

2. 鎖狀態流程

前面我們根據幾個場景給出了互斥鎖的狀態轉換過程,這裡直接給出互斥鎖的流程圖如下:

image

3. 總結

本文是 Go 互斥鎖 Mutex 原始碼分析的第二篇,進一步透過兩個場景分析互斥鎖的狀態轉換。互斥鎖的狀態轉換如果陷入狀態更新,很容易頭暈,這裡透過不同場景,逐步分析,整個狀態,接著給出狀態轉換流程圖,力圖做到原始碼層面瞭解鎖的狀態轉換。


相關文章