深入分析 Golang 的 Mutex

1,502 阅读4分钟

Mutex

Go 语言的 sync.Mutex 由两个字段statesema组成。其中state表示当前互斥锁的状态,而 sema是信号量,用于控制goroutine的阻塞与唤醒。 state字段为int32类型,其低三位分别代表了锁的一些状态:

  • mutexLockedstate中的低1位,用二进制表示为0001(为了方便,这里只描述后4位),它代表该互斥锁是否被加锁。
  • mutexWoken低2位,用二进制表示为0010,它代表互斥锁上是否有被唤醒的goroutine。
  • mutexStarving低3位,用二进制表示为0100,它代表当前互斥锁是否处于饥饿模式。
  • state剩下的29位用于统计在互斥锁上的等待队列中goroutine数目(waiter)。

image.png

const (
	mutexLocked = 1 << iota // 是否上锁
	mutexWoken	// 是否唤醒		
	mutexStarving	// 是否饥饿
	mutexWaiterShift = iota	// mutexWaiterShift值为3,通过右移3位的位运算,可计算waiter个数
	starvationThresholdNs = 1e6 // 1ms,进入饥饿状态的等待时间
)  

锁的三种处理方式

Barging方式

这种模式追求最高的吞吐量:当锁被释放时,它将唤醒第一个waiter(等待锁的Goroutine),并将锁给第一个请求锁Goroutine或这个waiter

Handoff方式

锁释放后,互斥锁仍将保持锁定,直到第一个waiter准备好。它会降低吞吐量, 因为即使另一个goroutine准备好获取锁,也不会把锁移交给它。

Spinning方式

自旋锁

Mutex的两种模式

Go实现的互斥锁有两种模式,分别是 正常模式饥饿模式

正常情况下,Golang使用 Barging方式 移交锁的所有权,在并发抢占锁的过程中,抢不到锁的Goroutine会进入waiter队列(按照先进先出(FIFO)的方式获取锁)。但是一个刚被唤醒的waiter与新到达的goroutine竞争锁时,大概率是干不过的。这是为什么呢?

因为新来的goroutine有一个优势:它已经在CPU上运行,并且有可能不止一个新来的goroutine,因此waiter极有可能还是拿不到锁,灰溜溜的继续在waiter队列中排队。

饥饿模式

为了避免waiter长时间抢不到锁,当waiter超过 1ms 没有获取到锁,它就会将当前互斥锁切换到饥饿模式。

在饥饿模式下,Golang使用 Handoff方式 来移交锁的所有权直接从解锁的goroutine转移到等待队列中的队头waiter。新来的goroutine不会尝试去获取锁,也不会自旋。它们将在waiter队列的队尾排队。

何时退出饥饿模式

如果某waiter获取到了锁,并且满足以下两个条件之一,它就会将锁从饥饿模式切换回正常模式。

  • 它是waiter队列中最后一个Goroutine
  • 它等待获取锁的时间 < 1ms

整体流程图

1.waiter队列处理器P的本地队列中的Goroutine并发抢占锁

2.没有获取到锁的Goroutine会根据情况选择进入自旋状态

3.自旋结束后,如果锁是饥饿状态,则当前Goroutine直接进入waiter队列。如果是正常状态,再次尝试CAS加锁

4.如果CAS加锁再次失败,进入waiter队列

5.从waiter队列醒来的Goroutine将再次尝试获取锁,如果锁处于饥饿模式,则使用handoff方式确保waiter拿到锁

image.png

深入分析Lock

理想路径是使用CAS函数将mutexLocked从 0 改成 1,这就是所谓的 fast path 如果失败了,就会进入 slow path: 我们看看源码实现~

fast path

mutexLocked = 0 时,使用 CAS 函数 sync/atomic.CompareAndSwapInt32 更新 mutexLocked = 1

func (m *Mutex) Lock() {
	// fast path
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	// slow path
	m.lockSlow()
}

slow path

Mutex.lockSlow函数的主体是一个大for循环,大概可以分为三部分:自旋,计算状态和上锁(更新状态)。

自旋

如果互斥锁的状态不是 0 时,说明锁被占用了。当前Goroutine没获取到锁,这时会先尝试自旋(Spinnig):

old := m.state
// 判断是否能进入自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
    // 判断当前goroutine是不是在唤醒状态
    // 尝试将当前锁的Woken状态设置为1,表示已被唤醒(这块属于锁的细节,不理解的同学可以忽略)
    if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
        awoke = true
    }
    // 自旋
    runtime_doSpin()
    iter++
    old = m.state
    continue
}

自旋是一种多线程同步机制,当前的进程在自旋过程中会一直保持 CPU 的占用,持续检查某个条件是否为真。

在多核的 CPU 上,自旋可以避免 Goroutine 的切换,使用恰当会对性能带来很大的增益。 但是随意使用自旋也有可能拖累CPU。

Golang作者认为应该保守的引入自旋,所以 Goroutine 进入自旋的条件非常苛刻:持有锁的goroutine能在较短的时间内归还锁时,才允许它自旋

互斥锁只有在普通模式才能进入自旋 这个条件对应源码的old&(mutexLocked|mutexStarving) == mutexLocked

runtime_canSpin返回值为true才能进入自旋 runtime_canSpin方法判断自旋是否有意义(说白了就是自旋是否能获得性能提升) 进入自旋的前提条件:

  • 自旋次数 < 4
  • 必须是多核CPU 且 GOMAXPROCS>1
  • 至少有一个其他的正在运行的P 并且本地运行队列为空(不理解P的同学可以Google下GMP模型)
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
func sync_runtime_canSpin(i int) bool {
	// 自旋次数 < 4
	// 必须是多核CPU 且 GOMAXPROCS>1
	if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
		return false
	}
	// 至少有一个其他的正在运行的P 并且本地运行队列为空
	if p := getg().m.p.ptr(); !runqempty(p) {
		return false
	}
	return true
}

一旦进入自旋,就会调用sync.runtime_doSpinruntime·procyield 并执行 30 次的 PAUSE 指令,该指令只会占用 CPU 并消耗 CPU 时间:

const (
	active_spin_cnt = 30
)

//go:linkname sync_runtime_doSpin sync.runtime_doSpin
//go:nosplit
func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}

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

更新状态

这部分内容都是互斥锁的一些细节,大概看一下就可以。

比较重要的内容是:如果当前申请锁的Goroutine发现锁的已经是饥饿状态,就会直接去队尾排队

// old是锁当前的状态,new是期望的状态,以期于在后面的CAS操作中更改锁的状态
new := old
// 如果当前锁不是饥饿模式,则将new的低1位的Locked状态位设置为1,表示加锁
if old&mutexStarving == 0 {
    new |= mutexLocked
}
// 如果当前锁已被加锁或者处于饥饿模式,则将waiter数加1,表示申请上锁的Goroutine直接滚去排队
if old&(mutexLocked|mutexStarving) != 0 {
    new += 1 << mutexWaiterShift
}
// 当 Mutex是锁定状态,且申请上锁的Goroutine饥饿,则标记为饥饿状态
if starving && old&mutexLocked != 0 {
    new |= mutexStarving
}
// 当awoke为true,修改锁的Woken状态位为1
if awoke {
    if new&mutexWoken == 0 {
        throw("sync: inconsistent mutex state")
    }
    new &^= mutexWoken
}

上锁

使用 CAS 函数 atomic.CompareAndSwapInt32 更新状态,如果失败则进入waiter队列: 调用 runtime.sync_runtime_SemacquireMutex 休眠当前goroutine并且尝试获取信号量。

运行到 SemacquireMutex就证明当前goroutine在前面的过程中获取锁失败了。 此时需要sleep原语来阻塞当前goroutine,并通过信号量来排队获取锁

一旦当前 Goroutine 获取到信号量被唤醒,它就会接着跑剩下的代码:

  • 在正常模式下,这段代码会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环;
  • 在饥饿模式下,当前 Goroutine 会获得互斥锁,如果等待队列中只存在当前 Goroutine,互斥锁还会从饥饿模式中退出
if atomic.CompareAndSwapInt32(&m.state, old, new) {
    // 如果锁的原状态是锁定状态或饥饿状态,则当前Goroutine上锁失败
    if old&(mutexLocked|mutexStarving) == 0 {
        break 
    }
    // 这里判断waitStartTime != 0就证明当前goroutine之前已经等待过了,则需要将其放置在等待队列队头
    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 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 {
    // 如果CAS未成功,更新锁状态,重新一个大循环
    old = m.state
}

深入分析UnLock

互斥锁的UnLock过程会先使用 sync/atomic.AddInt32 函数快速解锁。

func (m *Mutex) Unlock() {
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		m.unlockSlow(new)
	}
}

slow path

  • 在正常模式下,会根据锁的状态唤醒waiter并移交锁的所有权;
  • 在饥饿模式下,上述代码会直接调用 sync.runtime_Semrelease 将当前锁交给waiiter
func (m *Mutex) unlockSlow(new int32) {
	// 校验锁状态
	if (new+mutexLocked)&mutexLocked == 0 {
		throw("sync: unlock of unlocked mutex")
	}
	if new&mutexStarving == 0 { // 正常模式
		old := new
		for {
			// 如果锁没有waiter,或者锁有其他以下已发生的情况之一,则后面的工作就不用做了,直接返回
			// 1. 锁处于锁定状态,表示锁已经被其他goroutine获取了
			// 2. 锁处于被唤醒状态,这表明有等待goroutine被唤醒,不用再尝试唤醒其他goroutine
			// 3. 锁处于饥饿模式,那么锁之后会被直接交给等待队列队头goroutine
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}
			// 代码走到这,说明当前锁是空闲状态,等待队列中有waiter,且没有goroutine被唤醒
			// 所以,这里我们想要把锁的状态设置为被唤醒,等待队列waiter数-1
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
				// 通过信号量唤醒goroutine,然后退出
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else { // 饥饿模式
		// 直接唤醒等待队列队头goroutine
		runtime_Semrelease(&m.sema, true, 1)
	}
}

创作不易,希望大家能顺手点个赞~这对我很重要,蟹蟹各位啦~

参考:

medium.com/a-journey-w…

draveness.me/golang/docs…

jishuin.proginn.com/p/763bfbd36…