观前提示,有能力可以直接尝试看源码,更清晰~
环境:Darwin Kernel, arm64
go version go1.16.3 darwin/arm64
正文同步原语 mutex 也是在 Go 并发控制中耳熟能详的存在了。小小的 mutex 让我产生了大大的疑惑。让我们进入源码一探究竟~
Mutex data structure
首先是 mutex 的数据结构,在 $GOROOT/src/sync/mutex.go 文件中:
type Mutex struct {
state int32 // 互斥锁的状态:被g持有,空闲等
sema uint32 // 信号量,用于阻塞/唤醒 goroutine(协程)
}
这里的 state 字段是 int32 类型,但是它被分为了4 部分操作,为了使用更少的内存去表达锁的各个状态。
— 晁岳攀
锁的 state 是分为 4 部分使用的(通过位操作符做到的)
- waiter(29 bit): 尝试获取当前锁而陷入阻塞的等待者们
- starving(1 bit): 当前锁是 饥饿模式
- woken(1 bit): 当前锁是 Woken 状态,有该锁的等待这被唤醒
- locked(1 bit): 0 表示互斥锁是空闲的,1 表示互斥锁是被持有的
Mutex.Lock()
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
// 走这条路,就可以将 Lock()方法内联到调用者函数的代码中去(因为此时Lock()函数的代码少),减少了函数栈的开辟和释放,提高了性能
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)
这个是原子操作
所谓原子操作就是: 虽然有 cache 的存在,但是单个核上的单个指令进行原子操作的时候,能确保其它处理器或者核不访问此原子操作的地址,或者是确保其它处理器或者核总是访问原子操作之后的最新的值。
— 晁岳攀
其次由于缓存的存在,在多核情况下,会出现各个 cpu 读取数据不一致的问题(因为总是先从缓存中读取数据),为了解决这个问题,使用了一种叫做内存屏障的方法。一个写内存屏障会告诉 cpu,必须要要等到它(内存屏障)管道中的未完成数据都刷新到内存中,再进行操作。而且此操作会让相关的 cpu 的缓存行失效(cpu 缓存一致性协议)。
Golang atomic包中实现了内存屏障的功能。所以 atomic 包中的方法,1.实现了原子操作,2.有内存屏障的功能。两者确保了 cpu 使用该方法操作的值,其他 cpu 总是能读到最新值。当然这个不能常用,毕竟废掉了缓存,以我的见解,也只有并发的时候才用到。
回到上述代码,atomic.CompareAndSwapInt32
方法判断这一个互斥锁的状态,若是 0,则表示未被 g 持有。那么将其状态置为 mutexLocked,是一个常量,值为1。 race.enable
默认为false,所以接下来直接返回。此时 mutex 被 g 所持有。
刚刚是锁状态刚好为 0 的时候,若是锁被其他 g 所持有,那么将进入 lcokSlow
方法。
为什么拿不到锁不直接进行睡眠呢?????以前的版本的确是这样设计的:每次都把拿不到锁的 g 加入队列,然后持有锁的 g 释放后会唤醒队列中的 g。但是出于性能的考虑,释放锁时将 g 交给正在占用 cpu 时间的 g 将能提高性能,这个 g 也就是刚好来请求锁的 g。但是想要直接交给这个 g 的条件也是十分苛刻的。
同时锁因为允许新来的 g 与醒来的 g 进行竞争,可能导致醒来的 g 获取不到锁,而导致阻塞的 g 可能长时间阻塞。这就是锁饥饿问题,毕竟一个刚醒来的 g 与 多个请求锁的 g 斗争 ,1 v n,胜率不大,所以引入锁的饥饿模式,即醒来的 g 让新来的 g 都乖乖加入阻塞队列去,不要与自己竞争。直到阻塞队列中的最后一个 g 发现自己是最后使用锁的 g 了,就解除锁的饥饿模式。
// 该方法可能被并发执行
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false // 当前 g 是否从队列中醒来过,主要用来清除锁的 Woken 状态
iter := 0 // 标记自旋次数
old := m.state // 先获取当前锁的状态,且 old 是局部变量
for {
// 检测当前 g 是否可自旋,自旋主要查看锁是否被释放了,没释放我就要自旋,看多等一小会儿,能否看到锁释放而在接下来的逻辑中请求到锁
// 如果当前锁为被持有状态且不处于饥饿模式 同时 允许自旋
// runtime_canSpin(iter),iter 是传入自旋次数进行检测,同时还会检测其他条件
// 比如 cpu 核数大于 1、逻辑处理器 P 大于 1、当前运行队列为空
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 主动自旋是有意义的,有可能抢到锁而提升 mutex 锁性能
// 尝试设置 mutexWaken 标志,通知 UnLock 方法不要唤醒其他已经被阻塞的 goroutine,我马上就拿到锁了,这就算是抢锁成功了
// 如果释放锁的 g 一直咩有调用 UnLock 方法,那么同样在接下来的逻辑中陷入阻塞
// 当前锁不是 Woken 状态,awoke 是 false,锁有等待者,cas 成功的话 -> 将当前锁置为 Woken模式,且 awoke 置为 ture
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
// 在自旋状态下,把自己设置为刚从队列中醒来的状态,且让锁为 Woken 状态
awoke = true
}
// cpu 自旋,对应 cpu的pause 指令,即cpu 单纯在空转什么都没干
runtime_doSpin()
iter++
old = m.state // 自旋后再次获取锁的状态
continue
}
new := old // 准备设置新锁的状态
// 不要尝试获取饥饿模式下的 mutex,
// 尝试获取此模式下的 mutex,都直接加入等待队列
// 判断此时锁是否是饥饿模式
if old&mutexStarving == 0 {
// 不是饥饿模式,新状态加锁(此前 old 当前锁状态可能是释放或者未释放都没关系)
new |= mutexLocked
}
// 如果获得的当前锁状态处于有锁状态或者饥饿模式,让 准备设置的新锁等待者 +1
if old&(mutexLocked|mutexStarving) != 0 {
// 锁的等待者 + 1
new += 1 << mutexWaiterShift
}
// 当前 goroutine 将 mutex 切换到饥饿模式
// 但是如果互斥锁当前未被持有,不进行切换
// unlock 方法期望 饥饿模式的 mutex 的等待队列有 g,但在此情况下不是这样
// 如果当前 g 是睡眠超过 1 ms 且当前锁被持有
if starving && old&mutexLocked != 0 {
// 准备设置的新锁设置为饥饿模式
new |= mutexStarving
}
// 如果该 g 是在被队列中唤醒的
if awoke {
// awoke = true 但是 new 的 mutexWoken 为 fales 出错了
// 所以一个在该锁中活跃的 g 的 awoke 状态与当前锁的 woken 状态一致
// 判断锁的状态是否正确
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
// g 被唤醒,准备设置的新状态锁应当清除 锁的 Woken 状态
// 锁的 Woken 状态表示释放锁的 g 不要唤醒其他的阻塞者,自己都醒来表示 Woken 也算是失效了
// a &^= b 叫 清位操作,b 中所有为1的位置,对应到 a 中的位置都将被置为 0
// 也就是清零a中,ab都为1的位
new &^= mutexWoken
}
// 再次尝试 cas 设置为 new 值(更新当前 g 状态在锁上,基于内存中锁的最新值更新,失败则继续重复上述的所有步骤,上面的设置都是假设 g 自己能基于内存中最新值更新)
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 如果当前锁的状态是 空闲且不是饥饿模式,算抢锁成功
if old&(mutexLocked|mutexStarving) == 0 {
break // 使用 cas 持有锁
}
// 处理锁饥饿状态下的情况
// waiStratTime != 0 -> 如果该协程之前已经等待过了,此次排在队伍首位
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
// 阻塞,根据 queueLifo 排阻塞队列的队头(True)还是队尾
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 从阻塞队列中被唤醒,醒来后判断自己休眠时候是否超过 1 ms,只要超过 1 ms,准备将锁置为饥饿模式(不允许自旋)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state // 获取当前锁的状态
// 如果当前锁是饥饿模式
if old&mutexStarving != 0 {
// 如果当前锁被持有且是 Woken 状态 或者 锁无等待者
// 判断锁的状态是否正确 - 饥饿模式 与 Woken 状态不会同时出现
if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
throw("sync: inconsistent mutex state")
}
// 当前 g 获得锁且等待者 - 1
delta := int32(mutexLocked - 1<<mutexWaiterShift)
// 当前 g 不是饥饿状态 or 当前锁的等待者只有一个 g 了也就是自己(饥饿模式下,锁释放的时候,不会将等待者数量 - 1)
if !starving || old>>mutexWaiterShift == 1 {
// 切换锁的饥饿模式为普通模式
delta -= mutexStarving
}
// 更新互斥锁最新状态,因为新来的 g 在饥饿模式下只能加入阻塞队列,不会抢锁
// 所以这里直接更新锁状态是没有问题的
atomic.AddInt32(&m.state, delta)
break
}
awoke = true // 代表当前 g 已经从阻塞到醒来
iter = 0 // g 醒来后,自旋次数清 0
} else {
old = m.state
}
}
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
}
经过上面的翻译(也就是我在代码上打的注释)
我们能得出以下的结论:
- normal 模式下:
- 加入了竞争的情况,被唤醒的 g 可能会与刚到来的 g 一起竞争锁,但是很被唤醒的 g 很可能失败。因为 g 被唤醒就说明锁已经被释放了,那么自旋的很可能已经获得锁了
- 睡眠时间超过 1ms 的 g,被唤醒后想要将 mutex 切换为 starving 模式,切换后也会再次进入阻塞队列且排在队列头部,等待锁的释放别唤醒
- starving 模式下:
- 只有被唤醒的等待者才能加锁,其他的 g 全都进入 FIFO 阻塞队列
谨记:
在原子操作处,所有的 g 都会是串行化的
Mutex.UnLock()
解锁逻辑相对于简单一些:
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
// 先将锁给释放掉
new := atomic.AddInt32(&m.state, -mutexLocked)
// 如果锁释放后还有其他状态
if new != 0 {
// Outlined slow path to allow inlining the fast path.
// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
// 如果锁的 locked 部分为负数
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 如果当前锁不是饥饿模式
if new&mutexStarving == 0 {
old := new // 得到当前的锁
for {
// 如果锁没有等待者 或者 此时锁的状态是被持有 or 处于 Woken 状态(有 g 被唤醒 - g 自旋的效果) or 处于 饥饿模式
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
// 不继续唤醒等待者,直接返回
return
}
// 等待者 - 1 且 将锁置为 Woken 状态
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 唤醒阻塞队列的其中一个等待者
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
// 饥饿模式下,直接将锁移交给阻塞队列的第一个等待者
// 注意:锁的 locked 部分是 0,由被唤醒的等待者设置,而且也没有更新阻塞的等待者数量
// 如果锁的饥饿模式被设置,且 mutex 又是被持有的,那么新到来的 g 无法得到锁
runtime_Semrelease(&m.sema, true, 1)
}
}
注意:
Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的 goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今。
— 晁岳攀
所以尽量做到谁 Lock 谁 UnLock,做到谁申请,就由谁释放。一般都是在同一个方法中使用。
还有 runtime_SemacquireMutex
这个请求信号量函数底层是用 gopark
函数实现的,而 gopark
函数:
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
if reason != waitReasonSleep {
checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
}
// 请求获取当前协程的 M
mp := acquirem()
// 获取 M 关联的 G
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waittraceev = traceEv
mp.waittraceskip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)
}
该函数主要作用有三大点:
- 调用
acquirem
函数:- 获取当前 goroutine 所绑定的 m,设置各类所需数据。
- 调用
releasem
函数将当前 goroutine 和其 m 的绑定关系解除。
- 调用
park_m
函数:- 将当前 goroutine 的状态从
_Grunning
切换为_Gwaiting
,也就是等待状态。 - 删除 m 和当前 goroutine m->curg(简称gp)之间的关联。
- 将当前 goroutine 的状态从
- 调用
mcall
函数,仅会在需要进行 goroutiine 切换时会被调用:- 切换当前线程的堆栈,从 g 的堆栈切换到 g0 的堆栈并调用 fn(g) 函数。
- 将 g 的当前 PC/SP 保存在 g->sched 中,以便后续调用 goready 函数时可以恢复运行现场。
-- 脑子进煎鱼了
mcall
函数的实现是在 arm_arm.s 汇编文件中的:
// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall(SB),NOSPLIT|NOFRAME,$0-4
// Save caller state in g->sched.
MOVW R13, (g_sched+gobuf_sp)(g)
MOVW LR, (g_sched+gobuf_pc)(g)
MOVW $0, R11
MOVW R11, (g_sched+gobuf_lr)(g)
MOVW g, (g_sched+gobuf_g)(g)
// Switch to m->g0 & its stack, call fn.
MOVW g, R1
MOVW g_m(g), R8
MOVW m_g0(R8), R0
BL setg<>(SB)
CMP g, R1
B.NE 2(PC)
B runtime·badmcall(SB)
MOVB runtime·iscgo(SB), R11
CMP $0, R11
BL.NE runtime·save_g(SB)
MOVW fn+0(FP), R0
MOVW (g_sched+gobuf_sp)(g), R13
SUB $8, R13
MOVW R1, 4(R13)
MOVW R0, R7
MOVW 0(R0), R0
BL (R0)
B runtime·badmcall2(SB)
RET
其中的 race 包是通过 go -race 开启检测数据竞争,信号量机制具体实现也还没弄懂,但不碍事儿。
Mutex 互斥锁在同步中是复用最多的锁了,channel,RWMutex,Once等
收获满满~ 加油~
参考资料- 《Go 专家编程》作者:任洪彩 - 第五章 并发控制 - 书籍
- 《Go 并发编程实战课》作者:晁岳攀 - 基本并发原语 - 极客时间