Mutex
Go 语言的 sync.Mutex 由两个字段state和sema组成。其中state表示当前互斥锁的状态,而 sema是信号量,用于控制goroutine的阻塞与唤醒。
state字段为int32类型,其低三位分别代表了锁的一些状态:
mutexLocked是state中的低1位,用二进制表示为0001(为了方便,这里只描述后4位),它代表该互斥锁是否被加锁。mutexWoken是低2位,用二进制表示为0010,它代表互斥锁上是否有被唤醒的goroutine。mutexStarving是低3位,用二进制表示为0100,它代表当前互斥锁是否处于饥饿模式。state剩下的29位用于统计在互斥锁上的等待队列中goroutine数目(waiter)。
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拿到锁
深入分析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_doSpin和 runtime·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)
}
}
创作不易,希望大家能顺手点个赞~这对我很重要,蟹蟹各位啦~
参考: