Go併發程式設計--Mutex/RWMutex

failymao發表於2021-10-31

一.前言

我們反覆提到了goroutine的建立時簡單的。 但是仍然要小心, 習慣總是會導致我們可能寫出一些bug.對於語言規範沒有定義的內容不要做任何的假設。

需要通過同步語義來控制程式碼的執行順序 這一點很重要。 這些包提供了一些基礎的同步語義,但是在實際的併發程式設計當中,我們應該使用 channel 來進行同步控制。

二. Mutex

2.1 案例

上一篇文章中有這樣一個例子

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup
var counter int

func main() {
	// 多跑幾次來看結果
	for i := 0; i < 100000; i++ {
		run()
	}
	fmt.Printf("Final Counter: %d\n", counter)
}


func run() {
    // 開啟兩個 協程,操作
	for i := 1; i <= 2; i++ {
		wg.Add(1)
		go routine(i)
	}
	wg.Wait()
}

func routine(id int) {
	for i := 0; i < 2; i++ {
		value := counter
		value++
		counter = value
	}
	wg.Done()
}

測試後我們會發現,每次執行的結果都不一樣, 那麼如何運用Mutex進行修改呢?

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup
var counter int
var mu sync.Mutex

func main() {
	// 多跑幾次來看結果
	for i := 0; i < 100000; i++ {
		run()
	}
	fmt.Printf("Final Counter: %d\n", counter)
}


func run() {
	for i := 1; i <= 2; i++ {
		wg.Add(1)
		go routine(i)
	}
	wg.Wait()
	fmt.Printf("Final Counter: %d\n", counter)
}

func routine(id int) {
	for i := 0; i < 2; i++ {
	    // 加鎖
		mu.Lock()
		counter++
		// 解鎖
		mu.Unlock()
	}
	wg.Done()
}

這裡主要的目的就是為了保護我們臨界區的資料,通過鎖來進行保證。鎖的使用非常的簡單,但是還是有幾個需要注意的點

  • 鎖的範圍要儘量的小,不要搞很多大鎖
  • 用鎖一定要解鎖,小心產生死鎖

三. 實現原理

3.1 鎖的實現模式

  • Barging: 這種模式是為了提高吞吐量,當鎖被釋放時,它會喚醒第一個等待者,然後把鎖給第一個等待者或者給第一個請求鎖的人
  • Handoff: 當鎖釋放的時候, 鎖會一直持有直到第一個等待者準備好獲取鎖。 它降低了吞吐量,因為鎖被持有, 即使另一個 goroutine 準備獲取它。這種模式可以解決公平性的問題,因為在 Barging 模式下可能會存在被喚醒的 goroutine 永遠也獲取不到鎖的情況,畢竟一直在 cpu 上跑著的 goroutine 沒有上下文切換會更快一些。缺點就是效能會相對差一些
  • Spining:自旋在等待佇列為空或者應用程式重度使用鎖時效果不錯。Parking 和 Unparking goroutines 有不低的效能成本開銷,相比自旋來說要慢得多。但是自旋是有成本的,所以在 go 的實現中進入自旋的條件十分的苛刻。

3.2 Go Mutex 實現原理

3.2.1 加鎖

  1. 首先如果當前鎖處於初始化狀態就直接用CAS方法嘗試獲取鎖,這是 Fast Path
  2. 如果失敗就進入 Slow Path
    • 會首先判斷當前能不能進入自旋狀態,如果可以就進入自旋,最多自旋 4 次
    • 自旋完成之後,就會去計算當前的鎖的狀態
    • 然後嘗試通過 CAS 獲取鎖
    • 如果沒有獲取到就呼叫 runtime_SemacquireMutex 方法休眠當前 goroutine 並且嘗試獲取訊號量
    • goroutine 被喚醒之後會先判斷當前是否處在飢餓狀態,(如果當前 goroutine 超過 1ms 都沒有獲取到鎖就會進飢餓模式)
      1. 如果處在飢餓狀態就會獲得互斥鎖,如果等待佇列中只存在當前 Goroutine,互斥鎖還會從飢餓模式中退出
      2. 如果不在,就會設定喚醒和飢餓標記、重置迭代次數並重新執行獲取鎖的迴圈

CAS 方法在這裡指的是 atomic.CompareAndSwapInt32(addr, old, new) bool 方法,這個方法會先比較傳入的地址的值是否是 old,如果是的話就嘗試賦新值,如果不是的話就直接返回 false,返回 true 時表示賦值成功
飢餓模式是 Go 1.9 版本之後引入的優化,用於解決公平性的問題[10]

3.2.2 解鎖

解鎖的流程相對於加鎖簡單很多,這裡直接上圖,過程不過多贅述

四. 原始碼分析

4.1 Mutex基本結構

Mutex是個結構體,原始碼如下

type Mutex struct {
	state int32
	sema  uint32
}

Mutex 結構體由 state sema 兩個 4 位元組成員組成,其中 state 表示了當前鎖的狀態, sema 是用於控制鎖的訊號量

state 欄位的最低三位表示三種狀態,分別是 mutexLocked mutexWoken mutexStarving ,剩下的用於統計當前在等待鎖的 goroutine 數量

  • mutexLocked 表示是否處於鎖定狀態
  • mutexWoken 表示是否處於喚醒狀態
  • mutexStarving 表示是否處於飢餓狀態

4.2 加鎖

互斥鎖加鎖邏輯如下

  • 當呼叫 Lock 方法的時候,會先嚐試走 Fast Path,也就是如果當前互斥鎖如果處於未加鎖的狀態,嘗試加鎖,只要加鎖成功就直接返回

    func (m *Mutex) Lock() {
    	// Fast path: grab unlocked mutex.
    	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    		return
    	}
    	// Slow path (outlined so that the fast path can be inlined)
    	m.lockSlow()
    }
    
  • 否則的話就進入 slow path

    func (m *Mutex) lockSlow() {
    var waitStartTime int64 // 等待時間
    starving := false // 是否處於飢餓狀態
    awoke := false // 是否處於喚醒狀態
    iter := 0 // 自旋迭代次數
    old := m.state
    for {
    	// Don't spin in starvation mode, ownership is handed off to waiters
    	// so we won't be able to acquire the mutex anyway.
    	if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
    		// Active spinning makes sense.
    		// Try to set mutexWoken flag to inform Unlock
    		// to not wake other blocked goroutines.
    		if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
    			atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
    			awoke = true
    		}
    		runtime_doSpin()
    		iter++
    		old = m.state
    		continue
    	}
    
  • lockSlow 方法中可以看到,有一個大的for 迴圈,不斷的嘗試去獲取互斥鎖,在迴圈的內部,第一步就是判斷能否自旋狀態。
    進入自旋狀態的判斷比較苛刻,具體需要滿足什麼條件呢? runtime_canSpin 原始碼見下方

    • 當前互斥鎖的狀態是非飢餓狀態,並且已經被鎖定了
    • 自旋次數不超過 4 次
    • cpu 個數大於一,必須要是多核 cpu
    • 當前正在執行當中,並且佇列空閒的 p 的個數大於等於一
    // Active spinning for sync.Mutex.
    //go:linkname sync_runtime_canSpin sync.runtime_canSpin
    //go:nosplit
    func sync_runtime_canSpin(i int) bool {
        // 自旋次數不超過4
        // cpu個數大於1--所以必須是多核CPU
        // 佇列空閒的p的個數大於等於1
    	if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
    		return false
    	}
    	if p := getg().m.p.ptr(); !runqempty(p) {
    		return false
    	}
    	return true
    }
    
  • 如果可以進入自旋狀態之後就會呼叫 runtime_doSpin 方法進入自旋, doSpin 方法會呼叫 procyield(30) 執行三十次 PAUSE 指令

    TEXT runtime·procyield(SB),NOSPLIT,$0-0
    MOVL	cycles+0(FP), AX
    again:
    	PAUSE
    	SUBL	$1, AX
    	JNZ	again
    	RET
    

    為什麼使用 PAUSE 指令呢?
    PAUSE 指令會告訴 CPU 我當前處於處於自旋狀態,這時候 CPU 會針對性的做一些優化,並且在執行這個指令的時候 CPU 會降低自己的功耗,減少能源消耗

    if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
        awoke = true
    }
    
  • 在自旋的過程中會嘗試設定 mutexWoken 來通知解鎖,從而避免喚醒其他已經休眠的 goroutine 在自旋模式下,當前的 goroutine 就能更快的獲取到鎖

    new := old
    // Don't try to acquire starving mutex, new arriving goroutines must queue.
    if old&mutexStarving == 0 {
    	new |= mutexLocked
    }
    if old&(mutexLocked|mutexStarving) != 0 {
    	new += 1 << mutexWaiterShift
    }
    // The current goroutine switches mutex to starvation mode.
    // But if the mutex is currently unlocked, don't do the switch.
    // Unlock expects that starving mutex has waiters, which will not
    // be true in this case.
    if starving && old&mutexLocked != 0 {
    	new |= mutexStarving
    }
    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
    }
    
  • 自旋結束之後就會去計算當前互斥鎖的狀態,如果當前處在飢餓模式下則不會去請求鎖,而是會將當前 goroutine 放到佇列的末端

    if atomic.CompareAndSwapInt32(&m.state, old, new) {
    if old&(mutexLocked|mutexStarving) == 0 {
        break // locked the mutex with CAS
    }
    // If we were already waiting before, queue at the front of the queue.
    queueLifo := waitStartTime != 0
    if waitStartTime == 0 {
        waitStartTime = runtime_nanotime()
    }
    runtime_SemacquireMutex(&m.sema, queueLifo, 1)
    starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
    old = m.state
    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")
        }
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        if !starving || old>>mutexWaiterShift == 1 {
            // Exit starvation mode.
            // Critical to do it here and consider wait time.
            // Starvation mode is so inefficient, that two goroutines
            // can go lock-step infinitely once they switch mutex
            // to starvation mode.
            delta -= mutexStarving
        }
        atomic.AddInt32(&m.state, delta)
        break
    }
    awoke = true
    iter = 0
    }
    

狀態計算完成之後就會嘗試使用 CAS 操作獲取鎖,如果獲取成功就會直接退出迴圈
如果獲取失敗,則會呼叫 runtime_SemacquireMutex(&m.sema, queueLifo, 1) 方法保證鎖不會同時被兩個 goroutine 獲取。runtime_SemacquireMutex 方法的主要作用是:

  • 不斷呼叫嘗試獲取鎖
  • 休眠當前goroutine
  • 等待訊號量, 喚醒 goroutine

goroutine 被喚醒後就會去判斷當前是否處於飢餓模式,如果當前等待超過1ms 就會進入飢餓模式

  • 飢餓模式下: 會獲得互斥鎖,如果等待佇列中只存在當前Goroutine, 互斥鎖還會從飢餓模式中退出
  • 正常模式下: 會設定喚醒和飢餓標識, 重置迭代次數並重新執行獲取鎖的迴圈

4.3 解鎖

加鎖比解鎖簡單多了,原理直接參考原始碼的註釋

// 解鎖沒有繫結關係,可以一個 goroutine 鎖定,另外一個 goroutine 解鎖
func (m *Mutex) Unlock() {
	// Fast path: 直接嘗試設定 state 的值,進行解鎖
	new := atomic.AddInt32(&m.state, -mutexLocked)
    // 如果減去了 mutexLocked 的值之後不為零就會進入慢速通道,這說明有可能失敗了,或者是還有其他的 goroutine 等著
	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 {
			// 如果當前沒有等待者.或者 goroutine 已經被喚醒或者是處於鎖定狀態了,就直接返回
			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, 1)
				return
			}
			old = m.state
		}
	} else {
		// 飢餓模式,走 handoff 流程,直接將鎖交給下一個等待的 goroutine,注意這個時候不會從飢餓模式中退出
		runtime_Semrelease(&m.sema, true, 1)
	}
}

通過原始碼註解,總結解鎖需要注意以下

  1. 解鎖一個沒有鎖定的互斥量會報執行時錯誤

五. 讀寫鎖(RWMutex)

讀寫鎖相對於互斥鎖來說粒度更細,使用讀寫鎖可以併發讀,但是不能併發讀寫,或者併發寫寫

5.1 案例

大部分的業務應用都是讀多寫少的場景,這個時候使用讀寫鎖的效能就會比互斥鎖要好一些,例如下面的這個例子,是一個配置讀寫的例子,我們分別使用讀寫鎖和互斥鎖實現

  1. 讀寫鎖
// RWMutexConfig 讀寫鎖實現
type RWMutexConfig struct {
	rw   sync.RWMutex
	data []int
}

// Get get config data
func (c *RWMutexConfig) Get() []int {
	c.rw.RLock()
	defer c.rw.RUnlock()
	return c.data
}

// Set set config data
func (c *RWMutexConfig) Set(n []int) {
	c.rw.Lock()
	defer c.rw.Unlock()
	c.data = n
}
  1. 互斥鎖

// MutexConfig 互斥鎖實現
type MutexConfig struct {
	data []int
	mu   sync.Mutex
}

// Get get config data
func (c *MutexConfig) Get() []int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.data
}

// Set set config data
func (c *MutexConfig) Set(n []int) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data = n
}

併發基準測試,測試兩種鎖的效能

type iConfig interface {
	Get() []int
	Set([]int)
}

func bench(b *testing.B, c iConfig) {
	b.RunParallel(func(p *testing.PB) {
		for p.Next() {
			c.Set([]int{100})
			c.Get()
			c.Get()
			c.Get()
			c.Set([]int{100})
			c.Get()
			c.Get()
		}
	})
}

func BenchmarkMutexConfig(b *testing.B) {
	conf := &MutexConfig{data: []int{1, 2, 3}}
	bench(b, conf)
}

func BenchmarkRWMutexConfig(b *testing.B) {
	conf := &RWMutexConfig{data: []int{1, 2, 3}}
	bench(b, conf)
}

執行測試結果如下

root@failymao:/mnt/d/gopath/src/Go_base/daily_test/mutex# go test -race -bench=.
goos: linux
goarch: amd64
pkg: Go_base/daily_test/mutex
BenchmarkMutexConfig-8            179932              5820 ns/op
BenchmarkRWMutexConfig-8          279578              3939 ns/op
PASS
ok      Go_base/daily_test/mutex        3.158s

可以看到首先是沒有 data race 問題,其次讀寫鎖的效能幾乎是互斥鎖的一倍

5.2 原始碼解析

5.2.1 基本結構

type RWMutex struct {
    w           Mutex  // 複用互斥鎖
	writerSem   uint32 // 訊號量,用於寫等待讀
	readerSem   uint32 // 訊號量,用於讀等待寫
	readerCount int32  // 當前執行讀的 goroutine 數量
	readerWait  int32  // 寫操作被阻塞的準備讀的 goroutine 的數量
}

由於複用了互斥鎖的程式碼,讀寫鎖的原始碼很簡單

5.2.2 讀鎖

加鎖

func (rw *RWMutex) RLock() {
    // 直接對讀的goroutine記錄加1,並判斷當前執行的讀的goroutines數量是否為空
	if atomic.AddInt32(&rw.readerCount, 1) < 0 {
		// A writer is pending, wait for it.
		runtime_SemacquireMutex(&rw.readerSem, false, 0)
	}
}

首先是讀鎖, atomic.AddInt32(&rw.readerCount, 1) 呼叫這個原子方法,對當前在讀的數量加一,如果返回負數,那麼說明當前有其他寫鎖,這時候就呼叫 runtime_SemacquireMutex 休眠 goroutine 等待被喚醒

解鎖

func (rw *RWMutex) RUnlock() {
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
		// Outlined slow-path to allow the fast-path to be inlined
		rw.rUnlockSlow(r)
	}
}

解鎖的時候對正在讀的操作減一,如果返回值小於 0 那麼說明當前有在寫的操作,這個時候呼叫 rUnlockSlow 進入慢速通道

func (rw *RWMutex) rUnlockSlow(r int32) {
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		race.Enable()
		throw("sync: RUnlock of unlocked RWMutex")
	}
	// A writer is pending.
	if atomic.AddInt32(&rw.readerWait, -1) == 0 {
		// The last reader unblocks the writer.
		runtime_Semrelease(&rw.writerSem, false, 1)
	}
}

5.2.3 寫鎖

寫鎖

func (rw *RWMutex) Lock() {
	// First, resolve competition with other writers.
	rw.w.Lock()
	// Announce to readers there is a pending writer.
	r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
	// Wait for active readers.
	if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
		runtime_SemacquireMutex(&rw.writerSem, false, 0)
	}
}

首先呼叫互斥鎖的 lock,獲取到互斥鎖之後

  • atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) 呼叫這個函式阻塞後續的讀操作
  • 如果計算之後當前仍然有其他 goroutine 持有讀鎖,那麼就呼叫 runtime_SemacquireMutex 休眠當前的 goroutine 等待所有的讀操作完成

解鎖

func (rw *RWMutex) Unlock() {
	// Announce to readers there is no active writer.
	r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
	if r >= rwmutexMaxReaders {
		race.Enable()
		throw("sync: Unlock of unlocked RWMutex")
	}
	// Unblock blocked readers, if any.
	for i := 0; i < int(r); i++ {
		runtime_Semrelease(&rw.readerSem, false, 0)
	}
}

解鎖的操作,會先呼叫 atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) 將恢復之前寫入的負數,然後根據當前有多少個讀操作在等待,迴圈喚醒

六.參考

  1. https://mojotv.cn/go/golang-muteex-starvation
  2. https://lailin.xyz/post/go-training-week3-sync.html
  3. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/

相關文章