mutex工作机制
Mutex有两种工作模式:正常模式和饥饿模式
在正常模式中,等待着按照FIFO的顺序排队获取锁,但是一个被唤醒的等待者有时候并不能获取mutex,它还需要和新到来的goroutine们竞争mutex的使用权。新到来的goroutine有一个优势,因为新到达的goroutine已经在CPU上运行了,因此一个被唤醒的等待者有很大的概率获取不到锁。在这种情况下它处在等待队列的前面。如果一个goroutine等待mutex释放的时间超过1ms,它就会将mutex切换到饥饿模式。
在饥饿模式中,mutex的所有权直接从解锁的goroutine递交到等待队列中排在最前方的goroutine。新到达的goroutine们不要尝试去获取mutex,即便它看起来是解锁状态,也不要尝试自旋,而是排到等待队列的尾部。
在饥饿模式下,有一个goroutine获取到mutex锁了,如果它满足下条件中的任意一个,mutex将会切换回去正常模式:
- 等待队列只有一个goroutine
- goroutine的等待时间小于1ms
正常模式有更好的性能,因为goroutine可以连续多次获得mutex锁,以及避免多个线程的排队消耗。
饥饿模式需要预防队列尾部goroutine一致无法获取mutex锁的问题。
mutex数据结构以及调用的函数
type Mutex struct {
state int32 //将一个32位整数拆分为:从最高位排列
//当前阻塞的goroutine数(29位)
//饥饿状态(1位)
//唤醒状态(1位)
//锁状态(1位)
sema uint32 // 信号量
}
const (
mutexLocked = 1 << iota // 用最后一位表示当前锁的状态,0-未锁住 1-已锁住
mutexWoken // 用倒数第二位表示当前锁是否被唤醒 0-唤醒 1-未唤醒
mutexStarving // 用倒数第三位表示当前锁是否为饥饿模式,0为正常模式,1为饥饿模式。
mutexWaiterShift = iota // 3,从倒数第四位往前的bit位表示在排队等待的goroutine数
starvationThresholdNs = 1e6 // 1ms
)
- runtime_canSpin。判断是否需要自选,golang中自旋锁并不会一直自旋下去,在runtime包中runtime_canSpin方法做了一些限制, 传递过来的iter大等于4或者cpu核数小等于1,最大逻辑处理器大于1(多核),至少有个本地的P队列,并且本地的P队列可运行G队列为空才会进行自旋。
func sync_runtime_canSpin(i int) bool {
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。 会调用
procyield
函数,该函数也是汇编语言实现。函数内部[循环]调用PAUSE指令。PAUSE指令什么都不做,但是会消耗CPU时间,在执行PAUSE指令时,CPU不会对它做不必要的优化。
func sync_runtime_doSpin() {
procyield(active_spin_cnt)
}
- runtime_SemacquireMutex。 一个gotoutine的等待队列,如果lifo为true,则插入队列头,否则插入队尾
func runtime_SemacquireMutex(s *uint32, lifo bool)
- runtime_Semrelease。唤醒被runtime_SemacquireMutex函数挂起的等待goroutine,如果handoff为true,唤醒队列头第一个等待者,否则的话可能是随机
func runtime_Semrelease(s *uint32, handoff bool)
Lock方法实现
Lock方法申请对mutex加锁,Lock执行的时候,分三种情况:
- 无冲突。通过CAS操作把当前状态设置为加锁状态。
- 有冲突。开始runtime_canSpin自旋,并等待锁释放,如果其他goroutine在这段时间内释放了该锁,直接获得该锁;如果没有释放进入第3步。
- 有冲突,且已经过了自旋阶段。通过调用seamacquire函数来让当前goroutine进入等待状态。
// 无冲突
// CompareAndSwapInt32相等才赋值,且返回true
// 查看 state 是否为0(空闲状态), 如果是则表示可以加锁,将其锁置为锁定状态
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled { // 数据竞争检测
race.Acquire(unsafe.Pointer(m))
}
return
}
var waitStartTime int64 // 当前goroutine开始等待时间
starving := false // goroutine当前所处的模式
awoke := false // 当前goroutine是否被唤醒
iter := 0 // 自旋迭代的次数
old := m.state // old 保存当前 mutex 的状态
for {
// 有冲突,开始自旋状态
// 当mutex处于锁定非饥饿工作模式且支持自旋操作的时候。其实就是在自旋然后等待别人释放锁,如果有人释放锁,则会立刻进行下面的尝试获取锁的逻辑
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 将 mutex.state 的倒数第二位设置为1,用来告知 Unlock 操作,存在 goroutine 即将得到锁,不需要唤醒其他goroutine
// 当前线程没有处于唤醒状态,当前锁处于唤醒状态,当前有正在等待的goroutine
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
// 进入自旋
runtime_doSpin()
iter++
old = m.state
continue
}
// 有冲突,且已经过了自旋阶段。走到这里出现三种情况:1. 当前锁是未锁定状态;2. 锁是饥饿模式;3. 自旋超过指定的次数,不再允许自旋。
new := old
// 1. 如果当前不是饥饿模式,则这里其实就可以尝试进行锁的获取了|=其实就是将锁的那个bit位设为1表示锁定状态
if old&mutexStarving == 0 {
new |= mutexLocked
}
// 当mutex处于加锁或饥饿状态的时候,新到来的goroutine进入等待队列
// 2.此处需要判断是否为加锁状态,因为从1到2的时候可能mutex 重新被其他goroutine加锁了
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift //等待队列加1操作
}
// 如果当前goroutine已经处于饥饿状态,并且当前锁还是被占用,则锁尝试进行饥饿模式的切换,但如果当前 mutex 未锁定,则不需要切换。Unlock操作希望饥饿模式存在等待者
// 3.starving条件是为了防止: 如果在2处判断mutex没有处于加锁,而在这里判断mutex却加锁了,这时候加入饥饿模式,可是goroutine没有入列
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
// awoke为true则表明当前线程在上面自旋的时候,修改mutexWoken状态成功,清除唤醒标志位
// 为什么要清除标志位呢?实际上是因为后续流程很有可能当前线程会被挂起,就需要等待其他释放锁的goroutine来唤醒
// 但如果unlock的时候发现mutexWoken的位置不是0,则就不会去唤醒,则该线程就无法再醒来加锁
if awoke {
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
// &^操作:后面对应的位为1,则清0前面对应的位,若后面对应的位为0,则前面对应的位保持不变
new &^= mutexWoken
}
// 调用CAS更新state状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 如果原来的状态等于0则表明当前已经释放了锁并且也不处于饥饿模式下,表明可以加锁成功,退出CAS
if old&(mutexLocked|mutexStarving) == 0 {
break
}
// 排队逻辑,如果发现waitStatrTime不为0,则表明当前线程之前已经在排队来,后面可能因为unlock被唤醒,但是本次依旧没获取到锁,所以就将它移动到等待队列的头部
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
// 记录开始等待时间
waitStartTime = runtime_nanotime()
}
// 将被唤醒却没得到锁的 goroutine 插入当前等待队列的最前端
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 如果当前goroutine等待时间超过starvationThresholdNs,mutex 进入饥饿模式
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
// 重新获取状态
old = m.state
// 如果发现当前已经是饥饿模式,注意饥饿模式唤醒的是第一个goroutine
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 {
old = m.state
}
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
Unlock方法实现
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
// 直接进行cas操作: mutex 的state减去1, 加锁状态 -> 未加锁
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 正常模式释放
if new&mutexStarving == 0 {
old := new
for {
// 如果没有等待者,或者已经存在一个 goroutine 被唤醒或得到锁,或处于饥饿模式,无需唤醒任何处于等待状态的 goroutine
// 因为lock方法存在自旋一直在获取锁,所以可能解锁后就已经有goroutine获取到锁了
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 减去一个等待计数,然后将当前模式切换成mutexWoken
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 随机唤醒一个阻塞的 goroutine
runtime_Semrelease(&m.sema, false)
return
}
old = m.state
}
// 饥饿模式唤醒
} else {
// 唤醒第一个等待的线程
runtime_Semrelease(&m.sema, true)
}
}
}
参考文献
golang mutex源码详细解析
golang之sync.Mutex互斥锁源码分析
go sync.Mutex 设计思想与演化过程 (一)
sync包 mutex源码阅读
图解Go里面的互斥锁mutex了解编程语言核心实现源码