介绍
Mutex结构如下:
type Mutex struct {
// 状态
state int32
// 信号量
sema uint32
}
其中state被分为4个部分:
对应的常量如下:
const (
// 1:加锁标志位
mutexLocked = 1 << iota // mutex is locked
// 2:唤醒标志位
mutexWoken
// 4:饥饿标志位
mutexStarving
// 3:第三位以后的位,代表等待者数量
mutexWaiterShift = iota
// 进饥饿模式的阈值:1e6纳秒 = 1毫秒
starvationThresholdNs = 1e6
)
有两种操作模式:正常和饥饿
-
正常模式下:
- 等待者按FIFO顺序排队,但被叫醒的等待者不拥有锁,而是与新到达的goroutines竞争所有权新来的goroutines有一个优势——它们是已经在CPU上运行了,所以被唤醒的等待者很有可能竞争不过。这种情况下,它在队列的头等待如果等待者在1ms以上未能获取锁,就将锁切换到饥饿模式
-
在饥饿模式下:
- 锁的所有权直接从刚解锁的goroutine传递给队列前面的等待者。新到达的goroutines不会尝试获取互斥体,即使锁是未加锁状态。也不会自旋。相反,他们在/等待队列的尾部
正常模式具有更好的性能,而饥饿模式有更好的公平性,避免了某些goroutine长时间的等待锁
因此,go Mutex有以下特性:
- 新协程友好:新来的协程首先可以通过快速路径CAS获取到锁,在慢路径中也有4次自旋机会
-
保证公平性:当锁处于饥饿模式时,等待者队列的中的协程优先级就高于新协程
-
关于唤醒位标志:
- 刚接触Mutex,可能会对唤醒位标志比较费解,该位有啥用?
- 整个Mutex只有解锁时会使用该标志位,即如果该位为1,则解锁后不唤醒等待者,因为已经有被唤醒的协程正在尝试加锁了,后文也会详细分析
源码
Lock
func (m *Mutex) Lock() {
// 能将锁从0改为 mutexLocked,加锁成功
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// 执行慢路径
m.lockSlow()
}
func (m *Mutex) lockSlow() {
// 等待时间
var waitStartTime int64
// 是否饥饿
starving := false
// 是否被唤醒
awoke := false
// 自旋次数
iter := 0
old := m.state
for {
// 判断是否自旋:
// 1.已经加锁,且不处与饥饿模式
// 2.可以进行自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 这里尝试将mutexwoken标志改为true,避免正在解锁的协程唤醒其他没必要唤醒的阻塞协程
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
// 执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间,进行忙等待
runtime_doSpin()
iter++
old = m.state
continue
}
// 结束自旋:要么超过自旋次数,要么锁变为饥饿模式,要么已经解锁
new := old
// 如果不是饥饿,则这里尝试加锁
// 饥饿模式不能加锁,而只能进入等待队列
if old&mutexStarving == 0 {
new |= mutexLocked
}
// 如果锁是已加锁状态,或处于饥饿模式,这里都要当自己阻塞,即等待者数量+1
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// 如果饥饿,且锁已经被锁住,进入饥饿模式
// 如果没被加锁,则不进入饥饿模式
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
// 如果被唤醒,清除唤醒标志
if awoke {
if new&mutexWoken == 0 {
throw( "sync: inconsistent mutex state" )
}
new &^= mutexWoken
}
// 尝试CAS将new更新到m.state
if atomic.CompareAndSwapInt32(&m.state, old, new) {、
// 如果以前没加锁,且不是饥饿,这里又更新m.state成功了,表明加锁成功
if old&(mutexLocked|mutexStarving) == 0 {
break
}
// 如果第一次到这,加入队列尾部,否则加入队列头部
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 入队,阻塞
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 被唤醒
// 更新饥饿状态,如果被唤醒后发现等待时间超过1ms,就需要将锁更新为饥饿模式
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
// 如果不是饥饿模式,则被唤醒后重试获取锁,和其他协竞争,不进下面的if
// 否则被唤醒后是饥饿模式,此时该协程拥有锁
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
// CAS失败,则重试
} else {
old = m.state
}
}
}
lockSlow流程如下:
-
首先尝试自旋:
- 只有已经被加锁,且不是饥饿模式,且经过
runtime_canSpin
(关于该方法下文分析)判断可以自旋,才进行自旋 - 每次自旋尝试将锁设为被唤醒标志,这样正在解锁的协程就不用唤醒等待者了,因为已经有协程在尝试加锁
- 只有已经被加锁,且不是饥饿模式,且经过
- 一旦超过自旋次数,或者锁变为饥饿模式,或者已经解锁,就会结束自旋
-
如果不是饥饿模式,尝试加锁:
new |=
mutexLocked
- 饥饿模式是不能加锁的,只能进入等待队列
-
如果已经进入饥饿模式,或者已经被加锁,则将等待者数量+1:
-
` new += 1 << `***`mutexWaiterShift`***
- 因为这两种情况下,当前协程都要进入等待者队列等待
-
-
如果当前协程判断要进入饥饿模式,且已经被加锁,则进入饥饿模式:
new |=
mutexStarving
- 如果没被加锁,就没必要进入饥饿模式
- 清除唤醒标志位
-
CAS将m.state从old修改为new,如果成功:
- 如果以前没加锁,且不是饥饿,表明加锁成功
-
否则就是加锁失败:
- 根据是被唤醒,还是刚来,决定加入队列头部还是尾部,然后陷入阻塞
-
被唤醒:
- 判断阻塞时间是否大于阈值,如果是,则需要在下次CAS操作时将锁设为饥饿状态
- 如果是饥饿模式,说明此时该协程拥有锁
- 设置锁标志位,并减一个等待者,也就是自己
- 如果发现不再饥饿,即当前协程的阻塞时间小于1ms,或者只有自己这个等待者,就将锁退出饥饿模式
判断是否能自旋
func sync_runtime_canSpin(i int) bool {
// active_spin = 4
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
}
- 自旋次数在4次以内
-
CPU必须为多核
- 单核就没必要自旋,因为只有一个给程序运行,应该都给持有锁的协程使用,这里自旋只会推迟自己获得锁的时间
-
gomaxprocs > 1
- 同理,如果逻辑核心数为1,也没必要自旋
-
至少还有1个其他正在运行的P
-
如果一个其他正在运行的P也没用,那么这里自旋也没用,因为持有锁的协程需要在P上运行才能解锁,即这里持有锁的协程也陷入阻塞了
-
例如:
- 若gomaxprocs=5,sched.npidle=2,sched.nmspinning=2
- 总共5个P,两个空闲,两个自旋,加上当前协程所在的P,满足
gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1
,即没有其他P正在运行 - 否则就是有其他P正在运行
-
-
当前g绑定的p的本地队列没有其他g
- 防止其他g饥饿
Unlock
func (m *Mutex) Unlock() {
// 消除锁标志
new := atomic.AddInt32(&m.state, -mutexLocked)
// 如果有等待者,或处于饥饿模式,就执行unlockSlow
if new != 0 {
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
// 非饥饿模式
if new&mutexStarving == 0 {
old := new
for {
// 如果没有等待者,已经被加锁,已经有被唤醒标志,或者饥饿标志,这里啥也不用做,直接返回
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 {
// 这一步不会将锁标志位设为1,而是由被唤醒的协程来设置
// 并且其他协程不会尝试获得锁,因为锁还是处于饥饿模式
runtime_Semrelease(&m.sema, true, 1)
}
}
解锁流程如下:
- 清除锁标志位,如果清除后有等待者,或处于饥饿模式,就执行unlockSlow
-
如果锁处于饥饿模式:
- 直接唤醒头部的等待者
- 这一步不会将锁标志位设为1,而是由被唤醒的协程来设置,并且其他协程不会尝试获得锁,因为锁还是处于饥饿模式
-
如果不是饥饿模式,会根据当然锁的状态,判断是直接返回,还是需要唤醒等待者。以下情况啥也不用做:
-
没有等待者:
- 没有需要唤醒的等待者,直接返回
-
已经被加锁:
- 非饥饿模式下,上一步清除标志位后,就有可能被新来的协程加锁,那么这里就没必要唤醒等待队列中的等待者,又刚加锁的协程后续解锁后再去唤醒
-
已经有被唤醒标志:
- 加锁的自旋操作中,有一步是尝试将锁设置唤醒标志,如果设置成功,表明有协程正在尝试加锁,那么这里也没必要唤醒等待队列中的等待者。因为已经有协程在尝试了
-
已有饥饿标志:
- 说明在清除标志位,到这一步判断之前,以后有协程加锁解锁,并把锁设为饥饿模式,那这里也什么都不用做
-
-
否则需要尝试唤醒一个等待者:
- 设置唤醒标志,减少一个等待者
- 为啥是尝试:因为如果后面CAS失败,重新进入for循环,这次第一个if成立,就不用唤醒等待者
关于饥饿模式
通过源码分析可以看出,锁要进入饥饿模式,一定是某个被唤醒的协程发现阻塞时间超过阈值
而通过解锁的流程可以发现,如果一直有新的协程不断获取到锁,那么解锁的协程就不会去唤醒等待者队列的协程,也就是说锁一直无法进入饥饿模式,也就是说可能有等待者等待超过1ms很久,也无法使锁进入饥饿模式
但是一旦有等待者被唤醒,就会不用CAS将锁置位饥饿模式
一旦设置成功,新协程就不会尝试加锁,而是乖乖入队等待,直到后面某个协程发现等待时间少于1ms,或者没有等待者时,才会解除饥饿模式
注意事项
-
加解锁需要成对出现
- 一般在调用Lock()后,调用defer Unlock(),保证即使panic了也一定能解锁
-
Copy已使用的Mutex:
- 因为Mutex是值对象,一旦Copy,state也会跟着复制,这样其加锁信息也会跟着复制,一般不符合业务预期
- 当需要一把新锁时,使用零值的Mutex结构体对象
-
Mutex不可重入:
- 如果按可重入使用,会陷入死锁
- 因为Mutex没有记录当前持有锁的协程是哪个,也没有记录重入次数,因此其是不可重入的
- 如果想支持可重入,可以自己改造,增加goroutineid,或者自定义token,来标识是谁加锁和重入次数,并包装Lock和UnLock方法