1.sync.Mutex数据结构
type Mutex struct {
state int32
sema uint32
}
state 是锁的状态,有以下三种:
mutexLocked = 1 << iota // 互斥锁是锁定状态 1
mutexWoken // 唤醒状态(唤醒锁) 2
mutexStarving // 互斥锁进入饥饿模式 4
sema 用于控制goroutine的阻塞与唤醒
2.互斥锁有两种模式:
正常模式和饥饿模式
正常模式下,锁的等待者会按照先进先出的顺序获取锁,被唤醒的goroutine和新创建的goroutine竞争时,可能会获取不到锁,一旦等待时间超过1ms,锁就会进入饥饿模式。
饥饿模式下,新创建的goroutine不会获取到锁,也不会进入自旋,会放到队列末尾等待。如果一个goroutine获得了锁,并且它在队列末尾或者等待的时间小于1ms,那么当前互斥锁会切回正常模式。
3.互斥锁的源码,这里有四个方法需要解释:
runtime_canSpin 比较保守的自旋,golang中自旋锁并不会一直自旋下去,在runtime包中runtime_canSpin方法做了一些限制, 传递过来的iter大等于4或者cpu核数小等于1,最大逻辑处理器大于1,至少有个本地的P队列,并且本地的P队列可运行G队列为空。
runtime_doSpin 函数内部循环调用PAUSE指令,PAUSE指令什么都不做,但是会消耗CPU时间,就是执行 30 次 PAUSE 指令,通过该指令占用 CPU 并消耗 CPU 时间。
runtime_SemacquireMutex runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int) 函数,它是用于同步库的sleep原语,它的实现是位于src/runtime/sema.go中的semacquire1函数,与它类似的还有runtime_Semacquire(s *uint32) 函数。两个睡眠原语需要等到 *s>0 (本场景中 m.sema>0 ),然后原子递减 *s。SemacquireMutex用于分析竞争的互斥对象,如果lifo(本场景中queueLifo)为true,则将等待者排在等待队列的队头。skipframes是从SemacquireMutex的调用方开始计数,表示在跟踪期间要忽略的帧数。
runtime_Semrelease runtime_Semrelease(s uint32, handoff bool, skipframes int)函数。它是用于同步库的wakeup原语,Semrelease原子增加s值(本场景中m.sema),并通知阻塞在Semacquire中正在等待的goroutine。如果handoff为真,则跳过计数,直接唤醒队头waiter。skipframes是从Semrelease的调用方开始计数,表示在跟踪期间要忽略的帧数。
4.加锁
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// 尝试通过自旋等状态获取锁
m.lockSlow()
}
Lock先尝试快速加锁,如果m.state不是0,则通过lockSlow尝试获取锁
自旋
m.lockSlow 中的一些比较的变量:
waitStartTime: 表示等待唤醒的时间,如果等待的时间超过了 1ms 则会走锁的饥饿模式
starving: 表示是饥饿标识
awoke: 是否被唤醒的标识
iter: 等待锁的次数
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
for {
// 第一个判断语句就是判断了当前锁的状态是否为普通模式(只有普通模式才会进入自旋)。
// runtime_canSpin 会判断当前自旋的次数是否超过 4 次、当前操作系统是否为多核 cpu 、 GOMAXPROCS > 1 以及至少有一个正在运行的 P 并且 P 下的局部队列 runq 是空闲的
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// !awoke 判断当前goroutine是不是在唤醒状态
// old&mutexWoken == 0 表示没有其他正在唤醒的goroutine
// old>>mutexWaiterShift != 0 表示等待队列中有正在等待的goroutine
// 尝试设置 mutexWoken 标志表示已被唤醒, 以通知 Unlock 不唤醒其他阻塞的 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
}
if条件判断程序是否可以进入自旋状态
自旋是自旋锁的行为,它通过忙等待,让线程在某段时间内一直保持执行,从而避免线程上下文的调度开销。自旋本身是空转CPU
在本场景中,之所以想让当前goroutine进入自旋行为的依据是,我们乐观地认为:当前正在持有锁的goroutine能在较短的时间内归还锁。
如果不满足自旋条件,则进入以下逻辑:
计算期望值
new := old
// 如果当前锁不是饥饿模式,则将new的低1位的Locked状态位设置为1,表示加锁 (不要尝试获取饥饿的互斥锁,新到达的 goroutine 必须排队)
if old&mutexStarving == 0 {
new |= mutexLocked
}
// 如果当前锁已被加锁或者处于饥饿模式,则将waiter数加1,表示当前goroutine将被作为waiter置于等待队列队尾
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// 如果当前锁处于饥饿模式,并且已被加锁,则将低3位的Starving状态位设置为1,表示饥饿。
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
// 当awoke为true,则表明当前goroutine在自旋逻辑中,成功修改锁的Woken状态位为1
if awoke {
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
// 将唤醒标志位Woken置回为0
// 因为在后续的逻辑中,当前goroutine要么是拿到锁了,要么是被挂起。
// 如果是挂起状态,那就需要等待其他释放锁的goroutine来唤醒。
// 假如其他goroutine在unlock的时候发现Woken的位置不是0,则就不会去唤醒,那该goroutine就无法再醒来加锁。
new &^= mutexWoken
}
计算当前互斥锁最新的状态,old是锁当前的状态,new是期望的状态,以期于在后面的CAS操作中更改锁的状态
更新期望值
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 如果锁的原状态既不是被获取状态,也不是处于饥饿模式
// 那就直接返回,表示当前goroutine已获取到锁
if old&(mutexLocked|mutexStarving) == 0 {
break
}
// 如果走到这里,那就证明当前goroutine没有获取到锁
// 这里判断waitStartTime != 0就证明当前goroutine之前已经等待过了,则需要将其放置在等待队列队头
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 阻塞等待
// runtime_SemacquireMutex 方法,目的就是获取当前锁的 m.sema 变量的地址作为信号量将当前的等待者进入 semaRoot 的等待优先级队列(链表结构)中。
// 并且根据传的变量 queueLifo 来控制是传入到队列尾部还是头部。如果 queueLifo=true 说明之前已经处理等待状态,则就会插入到队列的头部等待唤醒,否则就进入队列的尾部。
// 在这个 runtime_SemacquireMutex 方法中会不断尝试获取锁并进入休眠状态等待信号量释放。一旦获取锁立即返回执行后续的代码。
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 在插入队列之后就会根据标识 starving 以及 runtime_nanotime() - waitStartTime > 1ms 来判断当前等待锁模式是否进入饥饿模式 (这里表示为饥饿之后,会在下一轮循环中尝试将锁的状态更改为饥饿模式)
// 1. 如果当前goroutine已经饥饿(在上一次循环中更改了starving为true)
// 2. 如果当前goroutine已经等待了1ms以上
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
// 再次获取锁状态
old = m.state
// 走到这里,如果此时锁仍然是饥饿模式
// 因为在饥饿模式下,锁是直接交给唤醒的goroutine,所以,即把锁交给当前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 {
// 退出饥饿模式
// 在这里完成并考虑等待时间至关重要。 饥饿模式非常低效,以至于一旦将互斥锁切换到饥饿模式,两个 goroutine 就可以无限地进行锁步。
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
// 拿到锁退出
break
}
awoke = true
iter = 0
} else {
old = m.state
}
}
计算了新的互斥锁状态之后,会使用 CAS 函数 sync/atomic.CompareAndSwapInt32 更新状态并获取锁
运行到 SemacquireMutex 就证明当前goroutine在前面的过程中获取锁失败了,就需要sleep原语来阻塞当前goroutine,并通过信号量来排队获取锁:如果是新来的goroutine,就需要放在队尾;如果是被唤醒的等待锁的goroutine,就放在队头。
5.解锁
func (m *Mutex) Unlock() {
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
Unlock会先使用 atomic.AddInt32 快速解锁,返回的状态为0,则成功解锁(锁不处于饥饿模式,也没有等待的goroutine);返回不为0,开始慢速解锁。
锁空闲有两种情况:
第一种是完全空闲,它的状态就是锁的初始状态。 比如 0000 000
第二种空闲,是指的当前锁没被占有,但是会有等待拿锁的goroutine,只是还未被唤醒。 比如 0011 000,有3个等待者
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
}
// 这里是CAS失败的逻辑
// 因为在for循环中,锁的状态有可能已经被改变了,所以这里需要及时更新一下状态信息
// 以便下个循环里作判断处理
old = m.state
}
} else { // 饥饿模式
// 直接唤醒等待队列队头goroutine即可
runtime_Semrelease(&m.sema, true, 1)
}
}
正常模式下
1)如果互斥锁不存在等待者 或者 一个 goroutine 已经被唤醒或抢到了锁,就不需要唤醒任何人。
2)否则就会释放信号量唤醒等待者队列中的等待者获取锁。
饥饿模式下
将互斥锁所有权移交给下一个等待者,以便下一个等待者可以立即开始运行。
注意:mutexLocked 没有设置,waiter 会在唤醒后设置。
但是如果设置了 mutexStarving,互斥量仍然被认为是锁定的,所以新的 goroutines 不会获取它。
总结
在正常模式下,waiter按照先进先出的方式获取锁;在饥饿模式下,锁的所有权直接从解锁的goroutine转移到等待队列中的队头waiter。
模式切换:
如果当前 goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式。
如果当前 goroutine 是互斥锁最后一个waiter,或者等待的时间小于 1ms,互斥锁切换回正常模式。
加锁:
1.如果锁是完全空闲状态,则通过CAS直接加锁。
2.如果锁处于正常模式,则会尝试自旋,通过持有CPU等待锁的释放。
3.如果当前goroutine不再满足自旋条件,则会计算锁的期望状态,并尝试更新锁状态。
4.在更新锁状态成功后,会判断当前goroutine是否能获取到锁,能获取锁则直接退出。
5.当前goroutine不能获取到锁时,则会由sleep原语SemacquireMutex陷入睡眠,等待解锁的goroutine发出信号进行唤醒。
6.唤醒之后的goroutine发现锁处于饥饿模式,则能直接拿到锁,否则重置自旋迭代次数并标记唤醒位,重新进入步骤2中。
解锁: 1.如果通过原子操作AddInt32后,锁变为完全空闲状态,则直接解锁。
2.如果解锁一个没有上锁的锁,则直接抛出异常。
3.如果锁处于正常模式,且没有goroutine等待锁释放,或者锁被其他goroutine设置为了锁定状态、唤醒状态、饥饿模式中的任一种(非空闲状态),则会直接退出;否则,会通过wakeup原语Semrelease唤醒waiter。
4.如果锁处于饥饿模式,会直接将锁的所有权交给等待队列队头waiter,唤醒的waiter会负责设置Locked标志位。(delta字段)