sync.Mutex 互斥锁
一. 使用示例
- 使用流程
- 创建sync.Mutex变量
- 调用 Lock()方法加锁
- 调用Unlock()方法释放锁
func TestMutex() {
var mutex sync.Mutex
sum := 0
for i := 0; i < 10; i++ {
go func(t int) {
mutex.Lock()
defer mutex.Unlock()
sum += t
fmt.Println(t)
}(i)
}
time.Sleep(time.Second)
fmt.Printf("Sum: %v\n", sum)
}
二. 源码分析
- Mutex 互斥锁是对共享资源进行访问控制的主要手段,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁
- 先看一下Mutex结构体,内部包含两个属性:
- Mutex.state表示互斥锁的状态,比如是否被锁定等。
- Mutex.sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程
type Mutex struct {
//表示锁状态
state int32
//是用来控制锁状态的信号量
sema uint32
}
Mutex.state 详解
- 其中state是由32位组成的,其中前29用来记录等待当前互斥锁的 goroutine 个数,后3位表示真实的锁状态status
Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量
- 查看state后三位的锁状态,分为:
- Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
- Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
- Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
- 协程之间抢锁实际上是抢给Locked赋值的权利,能给Locked域置1,就说明抢锁成功。抢不到的话就阻塞等待Mutex.sema信号量,一旦持有锁的协程解锁,等待的协程会依次被唤醒
- Woken和Starving主要用于控制协程间的抢锁过程
state=Starving 锁模式
- 在加锁时分为正常模式与饥饿模式,通过state中1bit位表示,也就是上方state中的Starving,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
- 正常模式下:
- 当一个 goroutine 占有锁时,后面的 goroutine 会以先进先出的顺序在等待队列里排队,
- 当锁被释放时,队列中最前面的 goroutine 会被唤醒,但是唤醒后的 goroutine 并不会立刻拥有锁,需要和新到达的 goroutine 竞争锁,
- 注意新的 goroutine 有一个已经在 CPU 上运行了的优势,并且新的goroutine 可能有多个,所以在竞争过程中,刚被唤醒的 goroutine 大概率会竞争失败,这个原因可能会导致一些在排队的 goroutine 很长时间得不到执行被 “饿死”,
- 为了让锁竞争更加公平,Go 1.9 添加了饥饿模式,
- 什么是饥饿模式: Go 1.9添加的,如果一个等待的 goroutine 超过 1 ms (starvationThresholdNs) 没有得到锁,这个锁就会被转换为饥饿模式。在饥饿模式下,锁竞争时,会直接交给第一个 goroutine,新来的 goroutine 将不会尝试去获得该锁,而是会直接放在队列尾部,注意正常状态下的性能是高于饥饿模式的,所以在大部分情况下,还是应该回到正常模式去的。当队列中最后一个 goroutine 被执行或者它的等待时间低于 1 ms 时,会将该锁的状态切换回正常
Lock() 加锁
- 在Lock方法中,先通过CAS判断m.state是不是等于 0,如果是说明时无锁状态,直接设置为mutexLocked表示加锁成功,如果不等于0,说明被锁定中需要执行lockSlow()进行自旋或阻塞等待
- 在lockSlow()内部重点执行了以下逻辑
- 判断是否可以自旋
- 自旋时执行runtime_doSpin()尝试自旋
- 当不能自旋时重新计算锁的状态
- 执行atomic.CompareAndSwapInt32(&m.state, old, new)更新锁状态
- 更新锁状态成功后如果不是获取锁成功,将当前goroutine放入等待队列等待,并且判断是否要转换状态进入饥饿模式
- 如果cas更新锁状态失败,锁被其他goroutine占用了,还原状态继续for循环
func (m *Mutex) Lock() {
// 如果处于正常模式,且Mutex未上锁、没有等待获取锁的goroutine,则获取到锁并修改为已上锁状态,直接返回
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.判断是否可以自旋,如果可以执行if内
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
//有阻塞的goroutine且为非woken状态,设置为已唤醒
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
//2.尝试自旋
runtime_doSpin()
iter++ //更新迭代此处
old = m.state //更新锁信息
continue
}
//3.计算state锁状态
new := old
// 正常模式下,设置为已上锁状态,尝试CAS获取;而饥饿状态时不会设置,因为锁的所有权直接转给队列中的第一个goroutine
if old&mutexStarving == 0 {
new |= mutexLocked
}
// 饥饿状态或已上锁时,将当前goroutine添加到等待队列,等待者加一
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift
}
// 如果当前goroutine处于饥饿状态且锁已被加锁,则将锁的状态转为饥饿
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}
// // 如果当前goroutine为唤醒状态,则需重置woken位
if awoke {
// The goroutine has been woken from sleep,
// so we need to reset the flag in either case.
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
new &^= mutexWoken
}
//4.计算出锁的状态后尝试通过CAS更新锁状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//4.1获取锁成功: 若之前的状态既不是饥饿状态也不是被获取状态,则表明当前goroutine已获得锁
if old&(mutexLocked|mutexStarving) == 0 {
break
}
//4.2如果之前已经等待过了则需要放到队头
queueLifo := waitStartTime != 0
//如果 waitStartTime != 0 说明该 goroutine 在之前已经等待了
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
//4.3没有获得锁,阻塞,将当前goroutine放入等待队列
// 该方法使用一个 sleep 原语阻塞 goroutine
// 如果 queueLifo == true, 说明其之前已经等待过了,现在是被唤醒,这时会把它加入等待队列队首
// 反之说明是一个新来的 goroutine, 就把他加入队尾
// 该方法会不断调用尝试获取锁并休眠当前 Goroutine 等待信号量的释放,
//一旦当前 Goroutine 可以获取信号量,它就会立刻返回
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 若等待时长超出阈值则转为饥饿状态
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")
}
// 当前的goroutine获得了锁,等待队列-1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
// 若goroutine处于非饥饿状态或等待队列只有一个goroutine,则退出饥饿模式
delta -= mutexStarving
}
// 原子性更新锁的状态,并退出执行业务逻辑
atomic.AddInt32(&m.state, delta)
break
}
// 若锁不是饥饿模式,则将当前goroutine状态设置为已唤醒,并重置iter
awoke = true
iter = 0
} else {
//锁被其他goroutine占用了,还原状态继续for循环
old = m.state
}
}
}
1. 如何判断是否可以自旋
- 什么条件下会进入自旋: 加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,会执行lockSlow(),在该函数中,尝试加锁的协程并不是马上转入阻塞,而是会持续的探测Locked位是否变为0,这个过程即为自旋过程
//lockSlow()中是否进入自旋的判断
//正常模式下,并且锁是锁定状态,runtime_canSpin(iter)返回true时可以自旋进入if
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
//自旋
}
- 什么是自旋: 自旋对应于CPU的”PAUSE”指令,CPU对该指令什么都不做,相当于CPU空转,对程序而言相当于sleep了一小段时间,时间非常短,当前实现是30个时钟周期。自旋过程中会持续探测Locked是否变为0,连续两次探测间隔就是执行这些PAUSE指令,它不同于sleep,不需要将协程转为睡眠状态
- 进入自旋后,什么条件下允许继续自旋: runtime_canSpin()什么时候返回true:
- 迭代次数小于4
- 并且多核CPU时
- 并且当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空时
// sync.Mutex 的主动自旋
//go:linkname sync_runtime_canSpin sync.runtime_canSpin
//go:nosplit
func sync_runtime_canSpin(i int) bool {
// 若迭代次数超过active_spin(4),或 cpu核数为1,或 逻辑处理器>1,返回false
if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 {
return false
}
//若逻辑处理器的本地goroutine队里为空,返回false
if p := getg().m.p.ptr(); !runqempty(p) {
return false
}
return true
}
- 总结可以自旋的条件:
- 锁定状态
- 正常模式
- cpu核数大于1
- 迭代次数小于4
- p的数量要大于1,GOMAXPROCS()将处理器设置为1就不能启用自旋
- p中的可运行队列必须有1个为空,否则会延迟协程调度
2. runtime_doSpin()自旋更新
- 当运行自旋时,会执行if内的runtime_doSpin(), 该方法内会将循环次数设置为30次,自旋操作就是执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间,进行忙等待;
const active_spin_cnt = 30
func sync_runtime_doSpin() {
procyield(active_spin_cnt)
}
- 这就是整个自旋操作的逻辑,通过自旋优化阻塞唤醒的性能消耗
3. 计算锁状态
- 在获取锁时如果判断不可以自旋时,会计算锁的state状态,基于old状态声明到一个新状态,
- state计算过程
- 新状态处于非饥饿的条件下才可以加锁
- 如果old已经处于加锁或者饥饿状态,则等待者按照FIFO的顺序排队
- 如果当前锁处于饥饿模式,并且已被加锁,则将低3位的Starving状态位设置为1,表示饥饿
- …
4. 执行atomic.CompareAndSwapInt32(&m.state, old, new) 更新锁状态
- 计算state锁状态完成后,执行CompareAndSwapInt32()通过cas更新锁状态,有更新成功与失败两种情况
- 如果更新失败则重试,锁被其他goroutine占用了,还原状态继续for循环
- 如果更新锁状态成功,会获取锁状态进行指定操作
- 获取到锁,当前加锁成功直接break
- 如果没有获取到锁执行runtime_SemacquireMutex()通过sleep 原语阻塞 goroutine放入等待队列进行等待
- 并且在阻塞时会通过waitStartTime是否等于0判断这个被阻塞的goroutine是不是第一次加入等待队列,如果第一次会加入到队列尾部,如果不是会加入到队列头部
- 并且会计算当前锁是否要加入饥饿模式,如果是锁的所有权直接移交给当前 goroutine
Unlock()解锁
- 使用AddInt32方法快速进行解锁,将m.state的低1位置为0,然后判断新的m.state值,如果值为0,则代表当前锁已经完全空闲了,结束解锁,不等于0说明当前锁没有被占用,会有等待的goroutine还未被唤醒,需要进行一系列唤醒操作,这部分逻辑就在unlockSlow方法内
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
// 如果 m.state - mutexLocked == 0 说明没人等待该锁,同时该锁处于正常状态
// 这时可以快速解锁,即锁状态会直接赋成 0
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
// 否则则需要慢速解锁
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
// 如果锁没锁定,直接抛出异常
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 正常模式下
if new&mutexStarving == 0 {
old := new
for {
// 如果没有其他等待者或者锁不处于空闲状态,直接返回,不需要唤醒其他等待着
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 唤醒新的等待者
// 等待者减一,设置唤醒标志 woken
new = (old - 1<<mutexWaiterShift) | mutexWoken
// 设置 state, 唤醒一个阻塞着的 goroutine
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
// 设置失败,重新获取状态设置
old = m.state
}
} else {
// 饥饿模式下,直接唤醒队首的 goroutine,这时 mutexLocked 位依然是 0
// 但由于处在饥饿状态下,锁不会被其他新来的 goroutine 抢占
runtime_Semrelease(&m.sema, true, 1)
}
}
- 正常模式/饥饿模式都调用runtime_Semrelease(s *uint32, handoff bool, skipframes int)唤醒协程,只是这两种模式在第二个参数的传参上不同
- 为什么重复解锁要panic: Unlock过程分为将Locked置为0,然后判断Waiter值,如果值>0,则释放信号量,如果多次Unlock(),可能每次都释放一个信号量,会唤醒多个协程,多个协程唤醒后会继续在Lock()的逻辑里抢锁,增加Lock()实现的复杂度,引起不必要的协程切换
参考博客1
参考博客2
参考博客3
三. 总结
- Mutex 互斥锁是对共享资源进行访问控制的主要手段,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁, 查看Mutex源码,内部包含
- Mutex.state表示互斥锁的状态,比如是否被锁定等。
- Mutex.sema表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程
- 其中state可以理解为有四部分构成:
- 前29位Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量
- Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
- Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
- Starving:表示该Mutex是否处理饥饿状态, 0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
Lock 加锁总结
- 在Lock方法中,先通过CAS判断m.state是不是等于 0,如果是说明时无锁状态,直接设置为mutexLocked表示加锁成功,如果不等于0,说明被锁定中需要执行lockSlow()进行自旋或阻塞等待,在lockSlow()内部重点执行了以下逻辑
- 首先判断是否可自旋, 如果可以进入自旋状态
- 如果不可以进入自旋,则会获取锁模式,根据模式的不同重新计算锁状态
1. 判断是否可自旋
- 什么是自旋: 加锁时,如果当前Locked位为1,说明该锁当前由其他协程持有,会执行lockSlow(),尝试加锁的协程并不是马上转入阻塞,而是会持续的探测Locked位是否变为0,这个过程即为自旋过程
- lockSlow()中通过一个if语句,判断当前锁模式,锁状态,如果正常模式下,并且锁定状态,调用runtime_canSpin(iter)返回true时则进入if开始自旋
- 什么情况选允许自旋: runtime_canSpin(iter)是判断可自旋条件,在该函数中
- 迭代次数小于4
- 并且多核CPU时
- 并且当前机器上至少存在一个正在运行的处理器 P 并且处理的运行队列为空时
- 总结可以自旋的条件:
- 锁定状态
- 正常模式
- cpu核数大于1
- 迭代次数小于4
- p的数量要大于1,GOMAXPROCS()将处理器设置为1就不能启用自旋
- p中的可运行队列必须有1个为空,否则会延迟协程调度
- 当允许自旋时,会调用runtime_doSpin()自旋更新
2. 进入自旋状态
- 当允许自旋时,会调用runtime_doSpin()自旋更新,该方法内会将循环次数设置为30次,自旋操作就是执行30次PAUSE指令,通过该指令占用CPU并消费CPU时间
3. 重新计算锁状态
- 当不能进入自旋状态时,会根据锁模式,重新计算锁状态
- 如果是正常模式,设置为加锁状态
- 如果是饥饿模式,设置等待锁的协程+1
- 如果是饥饿模式,并且锁还是被其它协程持有,还是设置锁为饥饿状态
- 总结就是当不能自旋时,会获取到锁的状态old,判断是否是饥饿状态当前锁如果是饥饿状态,
- 如果是正常模式,设置为获取锁状态,后续会通过cas尝试更新获取
- 如果old已经处于加锁和饥饿状态,则等待者按照FIFO的顺序排队,将获取锁的优先权交给阻塞队列中的第一个goroutine
- 当重新计算拿到了新的锁状态后,通过cas尝试更新
4. 更新锁状态到阻塞
- 当重新计算拿到了新的锁状态后,通过cas尝试更新,执行atomic.CompareAndSwapInt32(&m.state, old, new)
- 如果更新失败,继续在for循环中迭代
- 如果更新成功, 并且是获取到了锁跳出, 如果没有获取到锁:
- 通过waitStartTime 判断当前协程是否在前面已经等待过, waitStartTime != 0 说明该 goroutine 在之前已经等待了
- 调用runtime_SemacquireMutex()将当前goroutine放入等待队列, 在该函数中,如果当前协程已经等待过了,现在是被唤醒,会把它加入等待队列队首,如果是新的 goroutine, 就把他加入队尾
- runtime_SemacquireMutex()会休眠当前 Goroutine 等待信号量的释放,一旦当前 Goroutine 可以获取信号量,它就会立刻返回
- 在是否锁时会调用runtime_Semrelease()唤醒队列中的阻塞协程,当调用了该函数,会通知到Goroutine 拿到信号量,runtime_SemacquireMutex()则向下执行,继续向下执行根据锁模式的不同再次尝试获取锁
Unlock 解锁总结
- 在解锁时会调用Unlock, 如果 m.state - mutexLocked == 0 说明没人等待该锁,同时该锁处于正常状态,直接更新锁状态为0,否则调用unlockSlow(), 在unlockSlow()解锁时也分正常模式与饥饿模式,
- 正常模式下,等待着减1,执行runtime_Semrelease(&m.sema, false, 1)唤醒一个阻塞协程
- 饥饿模式下,会直接唤醒等待队列头部的协程,执行runtime_Semrelease(&m.sema, true, 1)
state=Starving 锁模式总结
- 在加锁时分为正常模式与饥饿模式,通过state中1bit位表示,也就是上方state中的Starving,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms
- 正常模式下:
- 当一个 goroutine 占有锁时,后面的 goroutine 会以先进先出的顺序在等待队列里排队,
- 当锁被释放时,队列中最前面的 goroutine 会被唤醒,但是唤醒后的 goroutine 并不会立刻拥有锁,需要和新到达的 goroutine 竞争锁,
- 注意新的 goroutine 有一个已经在 CPU 上运行了的优势,并且新的goroutine 可能有多个,所以在竞争过程中,刚被唤醒的 goroutine 大概率会竞争失败,这个原因可能会导致一些在排队的 goroutine 很长时间得不到执行被 “饿死”,
- 为了让锁竞争更加公平,Go 1.9 添加了饥饿模式,
- 什么是饥饿模式: Go 1.9添加的,如果一个等待的 goroutine 超过 1 ms (starvationThresholdNs) 没有得到锁,这个锁就会被转换为饥饿模式。在饥饿模式下,锁竞争时,会直接交给第一个 goroutine,新来的 goroutine 将不会尝试去获得该锁,而是会直接放在队列尾部,注意正常状态下的性能是高于饥饿模式的,所以在大部分情况下,还是应该回到正常模式去的。当队列中最后一个 goroutine 被执行或者它的等待时间低于 1 ms 时,会将该锁的状态切换回正常