golang Mutex剖析

golang Mutex剖析



Mutex内存模型


在go语言中,Mutex的结构是这样的:

type Mutex struct {
	state int32
	sema uint32
}

因为简单所以不需要额外的初始化。此结构的零值就是一个有效的互斥锁,处于Unlock的状态。


在这里插入图片描述

state储存的是互斥锁的状态,加锁和解锁都是通过atomic包提供的函数原子性操作来操作该字段。

sema用作一个信号量,主要用作等待队列。


Mutex的两种模式


正常模式


在正常模式下,一个尝试加锁的goroutine会先自旋几次,尝试通过原子操作获得锁,若几次自旋之后仍不能获得锁,则通过信号量排队等待。所有的等待者会按照先入先出(FIFO)的顺序排队。

但是当锁被释放,第一个等待者并不会直接拥有锁,而是需要和后来者竞争。也就是那些处于自旋阶段,尚未排队等待的goroutine。这种情况下后来者更有优势。一方面,它们正在CPU上运行,自然比刚唤醒的goroutine更有优势(正在CPU上运行的协程不需要进行上下文切换,而刚唤醒的协程需要进行上下文切换),另一方面,处于自旋状态的goroutine可以有很多,而被唤醒的goroutine每次只有一个。所以被唤醒的goroutine有很大概率拿不到锁。这种情况下,它会被重新插入到队列的头部,而不是尾部。


饥饿模式


而当一个goroutine本次加锁等待的时间超过了1ms后,它会把当前Mutex从正常模式切换至饥饿模式

在饥饿模式下,Mutex的所有权从执行Unlock的goroutine直接传递给等待队列头部的goroutine。后来者不会自旋,也不会尝试获得锁。即使Mutex处于Unlock状态,它们会直接从队列的尾部排队等待。当一个等待者获得锁之后,它会在以下两种情况时,将Mutex由饥饿模式切换回正常模式:

  • 一种情况是:它的等待时间小于1ms,也就是它刚来不久。
  • 第二种情况是:它是最后一个等待者,等待队列已经空了,后面自然就没有饥饿的goroutine了。

综上所述,在正常模式下自旋和排队是同时存在的,执行Lock的goroutine会先一边自旋,尝试过几次后如果还没有拿到锁,就需要去排队等待了。这种在排队之前先让大家来抢的模式,能够有更高的吞吐量,因为频繁的挂起、唤醒goroutine会带来较多的开销。但是又不能无限制的自旋,要把自旋的开销控制在较小的范围内,所以在正常模式下,Mutex有更好的性能。但是可能会出现队列尾端的goroutine迟迟抢不到锁(尾端延迟)的情况。


在这里插入图片描述


而饥饿模式下不再自旋尝试,所有goroutine都要排队,严格的先来后到。对于防止尾端延迟来讲特别重要。


在这里插入图片描述

Lock和Unlock


首先来看一下关于Mutex.state的几个常量定义:

我们再来看一下Mutex的内存模型:

type Mutex struct {
	state int32
	sema uint32
}

在这里插入图片描述


state的类型是int32。

  • 其中第一位用作锁状态标识符,置为1就表示已加锁,对应的掩码常量为mutexLocked
  • 第二位用于记录是否已有goroutine被唤醒了,置为1表示已唤醒,对应掩码常量为mutexWoken
  • 第三位标识Mutex的工作模式,0代表正常模式,1代表饥饿模式,对应掩码常量为mutexStarving
  • 而常量mutexWaiterShift等于3,表示除了最低三位以外,state的其他位用来记录有多少个等待着在排队。

Lock和Unlock方法


再来看看Lock和Unlock方法:

在这里插入图片描述

精简掉了注释和部分race检测相关的代码,两个方法中主要通过atomic函数实现了Fast path,相应的Slow path被单独放在了lockSlow和unlockSlow方法中。根据源码注释的说法,这样是为了便于编译器对于Fast path进行内联优化。

Lock方法的Fast path期望Mutex处于Unlocked状态,没有goroutine在排队,更不会饥饿。理想状况下,一个CAS操作就可以获得锁。但是如果CAS操作没能获得锁,就需要进入Slow path,也就是lockSlow方法。

Unlock方法同理,首先通过原子操作从state中减去mutexLocked,也就是释放锁。然后根据state的新值来判断是否需要执行Slow path。如果新值为0,也就意味着没有其它goroutine在排队,所以不需要执行额外操作;如果新值不为0,那就需要进入slow path,看看是否需要唤醒某个goroutine。


slow path的主要逻辑


当一个goroutine尝试给mutex加锁时,如果其它goroutine已经加了锁还没有释放,而且当前mutex工作在正常模式下,是不是就要开始自旋了呢?

答案是不一定。因为如果当前是单核场景,自旋的goroutine在等待持有锁的goroutine释放锁,而持有锁的goroutine在等待自旋的goroutine让出CPU。这种情况下自旋是没有意义的。


在这里插入图片描述


而且,如果GOMAXPROCS=1,或者当前没有其它P在运行,也和单核场景类似,同样不需要自旋。
除此之外,如果当前P的本地runq不为空,相较于自旋而言,切换到本地goroutine更有效率。所以为保障吞吐量,也不会自旋。


在这里插入图片描述


最终,只有在多核场景下,且GOMAXPROCS大于1,且至少有一个其它的P正在running,且当前P的本地runq为空的情况下,才可以自旋。


在这里插入图片描述


进入自旋的goroutine会先去争抢mutex的唤醒标识位。设置mutexWoken标识位的目的是:在正常模式下,告知持有锁的goroutine,在unlock的时候不用再唤醒其它goroutine了,已经有goroutine在这里等待。以免唤醒太多的等待协程。

mutex中的自旋,底层是通过procyield循环执行30次PAUSE,自旋次数上限为4。而且每自旋一次都要重新判断是否可以继续自旋。


在这里插入图片描述

如果锁被释放了,或者锁进入饥饿模式,亦或者已经自旋了4次,都会结束自旋。结束自旋或者根本不用的goroutine,就该尝试原子操作修改mutex的状态了。把此时mutex.state保存到old中,把要修改为的新state标记为new。


在这里插入图片描述


如果old处于饥饿模式或者加锁状态,goroutine就得去排队,所以上述情况下排队规模要加1。


在这里插入图片描述


如果是正常模式,就要尝试设置lock位。所以第三行的new位要置为1。


在这里插入图片描述


如果当前goroutine等待的时间已经超过1ms,而且锁还没有被释放。就要将mutex的状态切换为饥饿模式。注意,这里之所以还要求锁没被释放,是因为如果锁已经释放了,那怎么都得去抢一次,要是直接进入饥饿模式就只能去排队了。


在这里插入图片描述


把排队规模和几个标识位都设置好以后,在执行原子操作修改state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置。
因为接下来无论是要去抢锁,还是单纯的要去排队,如果原子操作成功了,要么是成功抢到了锁,要么是成功进到了等待队列里。当前goroutine都不再是被唤醒的goroutine了,所以要释放唤醒标识。
而如果原子操作不成功,也就意味着其它goroutine在我们保存mutex.state到old中以后,又修改了state的值。当前goroutine就要回过头去,继续从自旋检查这里开始再次尝试。所以也需要释放自己之前抢到的唤醒标识位,从头再来。


在这里插入图片描述


加锁的slow path


继续展开这个原子操作成功的分支,如果是抢锁操作成功了,那么加锁的slow path就可以宣告结束了。如果是排队规模设置成功了,还要决定是排在等待队列头部还是尾部。如果当前goroutine已经排过队了,是在unlock时从等待队列中唤醒的,那就要排到等待队列头部。
如果是第一次排队,就得排到等待队列尾部,并且从第一次排队开始记录当前goroutine的等待时间。接下来就会让出,进到等待队列里了。队列里的goroutine被唤醒时,要从上次让出的地方开始继续执行。
接下来会判断,如果mutex处在正常模式,那就接着从自旋开始抢锁,如果唤醒后处在饥饿模式,那就没有goroutine会和自己抢了。锁已经轮到自己这里,只需要把mutex.state中lock标识位设置为加锁,把等待队列规模减去1,再看看是不是要切换到正常模式,也就是自己的等待时间是不是小于1ms,或者等待队列已经空了,最后设置好mutex.state就一切ok了。

这就是加锁操作的slow path。


解锁的slow path


在这里插入图片描述


进到unlock的slow path,说明除去lock标识位以外。剩下的位不全为0。
如果处在正常模式,若等待队列为空。或者已经有goroutine被唤醒或获得了锁,或者锁进入了饥饿模式,那就不需要唤醒某个goroutine,直接返回即可,否则就要尝试抢占mutexWoken标识位,获取唤醒一个goroutine的权利。抢占成功后,就会通过runtime_Semrelease函数唤醒一个goroutine。
如果抢占不成功就进行循环尝试,直到等待队列为空,或者已经有一个goroutine被唤醒或获得了锁,或者锁进入了饥饿模式,则退出循环。
而在饥饿模式下,后来的goroutine不会抢占锁,而是直接排队,锁的所有权是直接从执行Unlock的goroutine,传递给等待队列中首个等待者的,所以不用抢占mutexWoken标识位。

第一个等待者唤醒后,会继承当前goroutine的时间片立刻开始运行,也就是继续lockSlow中这里,goroutine被唤醒以后的逻辑。

这就是unlock的slow path。

接下来再看看关于信号量的相关操作。


参考资料:

https://www.bilibili.com/video/BV1hv411x7we?p=23&spm_id_from=pageDriver

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值