前言
Mutex互斥锁是并发中最常见的锁,本篇分析下Mutex的运行机制,并通过测试解读源码。
锁结构
Mutex锁源码结构如下:
type Mutex struct {
state int32
sema uint32
}
Mutex结构体简单得令人惊讶,仅用两个字段就能实现互斥锁?其实里面暗藏玄机,图解一下:
-
state
是int32类型,即有32位。- 第1位比特用于Locked,1表示Mutex被锁上了,0表示没被锁上。
- 第2位比特用于Woken,1表示有协程从等待队列中被唤醒了,0表示没有协程被唤醒。
- 第3位比特用于Starving,1表示饥饿模式,0表示正常模式,后面讲两者不同处。
- 剩下的29位都用于WaiterShift计数,表示等待协程的数量有多少。
-
sema
是一个信号量,将没拿到锁的协程放入等待队列。具体实现可看Go 底层锁:原子操作和sema信号量。
运行机制
Mutex分两种模式,正常模式和饥饿模式。一般情况下都是正常模式,只有某个协程等待时间超过1ms了,不公平了,才会变为饥饿模式,直接让该饥饿协程获得锁。
正常模式
Mutex.Lock 加锁
- 协程间互相竞争锁
- 某协程得不到锁后,可能会自旋几次,类似加载中转圈圈。目的是小等一会,期间其它协程释放锁了就趁机获得锁
- 多次尝试失败,就进入sema队列休眠
图解一下:
首先有多个协程竞争锁
某个协程得到锁了,其余协程自旋
自旋期间,有可能第一个协程释放锁,随之第二个协程竞争得到锁,而第三个协程自旋几次得不到锁,就放入sema等待队列休眠
Mutex.Unlock 解锁
- 先解锁,将state的
Locked位
赋值为0 - 从等待队列队头中唤醒一个协程,唤醒的协程还得竞争锁,如果竞争不到继续放入等待队列,该协程很可能会饥饿。
饥饿模式
有协程饥饿了,为了兼顾公平,优先处理等待队列的协程。
Mutex.Lock 加锁
- 当有协程等待时间超过1ms,等太久不公平了,进入饥饿模式
- 饥饿模式下,新来的协程不需要自旋了,直接进入sema队列队尾
Mutex.Unlock
唤醒等待队列中唤醒的协程后,该协程将直接获得锁- 直到等待队列中没有协程了,才切换回正常模式
图解一下:
首先等待队列中有协程,同时有个新协程打算竞争锁。
将等待队列出队,把队头的sudog
封装的协程唤醒,该协程直接获得锁。新进入的协程直接加入等待队列队尾。
直到sema等待队列出队完了,Mutex才切换回正常模式。
Mutex.Unlock 解锁
- 先解锁,将state的Locked置为0
- 从等待队列队头中唤醒一个协程,该协程在唤醒后会直接加锁
开始实操
调试是阅读源码的好方法,以下通过调试测试来解读源码。
测试环境 -- Go版本:1.20,cpu核数:6。
const (
mutexLocked = 1 << iota // 值为1
mutexWoken // 值为2
mutexStarving // 值为4
mutexWaiterShift = iota // 值为3
)
以上常量将在源码中使用。
单线程竞争锁
最简单情况,只有一个线程在竞争锁。
package main
import (
"sync"
"time"
)
func easyMode() {
m := new(sync.Mutex)
m.Lock()
m.Unlock()
}
func main() {
easyMode()
time.Sleep(time.Minute)
}
easyMode
函数将Mutex加解锁,在m.Lock()
这一行打上断点,调试查看运行流程:
先加锁
还记得state各个位含义吧
没有等待者,没有加锁,因此state赋值为1,即第一位Locked为1,代表加锁,然后返回。
后解锁
正常模式
锁开始被占用,后来解锁
测试代码如下:
package main
import (
"sync"
"time"
)
func normalMode() {
m := new(sync.Mutex)
go func() {
m.Lock()
time.Sleep(time.Second * 15)
m.Unlock()
}()
time.Sleep(time.Second)
m.Lock()
m.Unlock()
}
func main() {
normalMode()
time.Sleep(time.Minute)
}
normalMode
函数中,先让协程获得锁,并持有15s再解锁。对主线程来说,锁一开始被占用,后来解锁了,这会有什么反应呢?我们给m.Lock()
处加上断点,开始运行。
主线程进入m.Lock()
。
此时state被协程加锁,值为1。而该线程就无法加锁,进入慢加锁阶段。
进入m.lockSlow
函数后,初始化一些变量,如old = m.state = 1
,然后开始for循环。
old&(mutexLocked|mutexStarving) == mutexLocked
这段是什么意思呢?
目的是判断old是否处于不饥饿且加锁状态。old是一个32位数值(回想下state每个位意义),第三位为0就不饥饿,第一位为1就加锁。显然m = 1
处于不饥饿且加锁状态。
- 但
runtime_canSpin(iter)
返回false,因此主线程不自旋。
判断不自旋后,新建一个变量new = old,new会根据情况改变值,后面可能用new覆盖state的值。继续执行: 该截图操作如下:
- 判断old不处于饥饿状态,
new |= mutexLocked
,new加锁了 - 判断old处于加锁或饥饿状态(old=1处于加锁状态),让
new += 1 << mutexWaiterShift
,即线程即将休眠,等待者数量加一。
注意之前加锁的协程已经解锁了,state=0
了。
改变完new状态后继续往下看。
atomic.CompareAndSwapInt32(&m.state, old, new)
返回false,因为state状态改变了,和old不相等了。
于是赶紧将old重新赋值为state。
同时state的改变意味着new失效了。于是进入下一轮for循环。
新一轮循环
重新声明new,并给new上锁。
new := old
if old&mutexStarving == 0 {
new |= mutexLocked
}
此时state=old
,将state赋值为new,于是该线程获得了锁。
之后发现old不处于加锁或者饥饿状态,break跳出循环。
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
...
} else {
old = m.state
}
小结
如果锁在某时刻被解锁了,那么竞争锁的协程在下一轮循环中获得锁。
锁一直被其它协程占用
测试代码如下:
package main
import (
"sync"
"time"
)
func normalMode() {
m := new(sync.Mutex)
go func() {
m.Lock()
time.Sleep(time.Minute)
m.Unlock()
}()
time.Sleep(time.Second)
m.Lock()
m.Unlock()
}
func main() {
normalMode()
time.Sleep(time.Minute)
}
此时协程先获得锁,然后休眠一小时。在m.Lock()
处打断点,开始调试。
具体流程为:
Lock() -> lockSlow() -> 进入for循环 -> runtime_SemacquireMutex()
最后进入runtime_SemacquireMutex
休眠等待。直到被唤醒后,假设处于正常模式,就会进入下一轮循环,继续去竞争锁。
当然,如果仍竞争不到锁,又会陷入休眠。如此反复,就出现协程饥饿问题。怎么解决呢?那就从正常模式切换为饥饿模式,饥饿模式下该协程被唤醒后直接获得锁。
小结
如果锁一直被占用,那么竞争的协程就会进入sema队列休眠等待。正常模式下被唤醒会进入下一轮循环竞争锁。
饥饿模式
测试代码如下:
package main
import (
"sync"
"time"
)
func hungryMode() {
m := new(sync.Mutex)
go func() {
m.Lock()
time.Sleep(time.Hour)
m.Unlock()
}()
for i := 0; i < 10; i++ {
go func() {
m.Lock()
time.Sleep(time.Minute)
m.Unlock()
}()
}
time.Sleep(time.Second)
m.Lock()
m.Unlock()
}
func main() {
hungryMode()
time.Sleep(time.Minute)
}
首先让一协程获得锁,然后开多个协程去竞争锁,让锁处于饥饿模式。再通过主线程竞争锁查看运行过程。
具体流程为:
Lock() -> lockSlow() -> 进入for循环 -> runtime_SemacquireMutex()
最后进入runtime_SemacquireMutex
休眠等待。直到唤醒后,此时处于饥饿模式,会执行后续代码:
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
}
-
该协程等待时间超过1ms,将
starving
置为true -
如果old处于饥饿模式,则计算一下state的变化值
delta
,然后将state加上delta,看下这一过程有什么意义:delta := int32(mutexLocked - 1<<mutexWaiterShift)
,将delta加锁,并将等待者数量减一。delta -= mutexStarving
,如果不饥饿或者等待者数量只剩一个了,则delta减去mutexStarving,切换回正常模式。- state加上delta,意味着state加锁了,等待者数量减一了,可能切换回正常模式了。
- 获得了锁,break退出循环。
小结
饥饿模式下,新协程直接进入休眠等待。被唤醒后直接获得锁。
扩展
为什么正常模式下测试代码没进入自旋流程?
进入自旋还需要runtime_canSpin(iter)
函数返回true才行。
runtime_canSpin
链接到sync_runtime_canSpin
函数。源码不再展开,通过注释分析。
如果有很多空闲处理器P的话,协程就不会进入自旋,因为会浪费cpu资源。还不如休眠等待,在唤醒时被空闲的处理器P调度。
如果处理器P很忙的话,协程休眠被唤醒后,会来不及调度,所以这时协程自旋不休眠好些。
因此为了进入自旋流程,我们对代码改动下:
package main
import (
"sync"
"time"
)
func normalMode() {
m := new(sync.Mutex)
go func() {
m.Lock()
time.Sleep(time.Hour)
m.Unlock()
}()
for i := 0; i < 100; i++ {
go func(i int) {
c := time.Tick(time.Millisecond)
for next := range c {
fmt.Printf("%v\n", next)
}
}(i)
}
time.Sleep(time.Second)
m.Lock()
m.Unlock()
}
func main() {
normalMode()
time.Sleep(time.Minute)
}
我们开一百个协程,每个协程都有个定时任务,以此让处理器P忙碌起来。
再次调试即可进入自旋流程中。
总结
互斥锁有两种操作模式:正常模式和饥饿模式。
在正常模式下,等待者按照先进先出的顺序排队,但是被唤醒的等待者不拥有互斥锁,与新到达的goroutine竞争所有权。新到达的goroutine有优势——它们已经在CPU上运行,并且可能有很多,因此被唤醒的等待者很有可能失败。在这种情况下,它将排在等待队列的前面。如果等待者未能在1毫秒内获取互斥锁,则它将切换到饥饿模式。
在饥饿模式下,互斥锁的所有权直接从解锁的goroutine移交给队列前面的等待者。即使互斥锁看起来已经解锁,新到达的goroutine也不会尝试获取互斥锁,也不会尝试自旋。相反,它们会将自己排队在等待队列的尾部。
如果等待者获得互斥锁的所有权并发现:
- 它是队列中的最后一个等待者
- 它等待时间少于1毫秒
则等待把互斥锁切换回正常操作模式。
正常模式具有相当好的性能,因为goroutine即使有被阻塞的等待者,也可以连续多次获取互斥锁。饥饿模式对于防止尾延迟的病态情况非常重要。
结语
千言万语不如自己动手实践。觉得文章写得不错的,请点个赞,谢谢!