Golang底层原理剖析之互斥锁sync.Mutex
互斥锁sync.Mutex
Mutex概括
Mutex(Mutual exclusion),Go中Mutex的数据结构是这样的,因为足够简单,所以不需要额外的初始化,零值就是一个有效的互斥锁,处于Unlocked状态。state存储的是互斥锁的状态,加锁和解锁,都是通过atomic包提供的函数原子性,操作该字段。sema用作一个信号量,主要用于等待队列。
Mutex有两种模式,在正常模式下,一个尝试加锁的goroutine会先自旋四次,自旋锁(如果不成功就一直尝试),尝试通过原子操作获得锁,若几次自旋之后仍不能获得锁,则通过信号量排队等待。
所有等待者会按照先入先出FIFO的顺序排队。
但是当锁被释放,第一个等待者被唤醒后并不会直接拥有锁,而是需要和后来者竞争,也就是那些处于自旋阶段,尚未排队等待的goroutine。这种情况下后来者更有优势,一方面,它们正在CPU上运行,自然比刚被唤醒的goroutine更有优势,另一方面处于自旋状态的goroutine可以有很多,而被唤醒的goroutine每次只有一个,所以被唤醒的goroutine有很大概率拿不到锁。这种情况下它会被重新插入到队列的头部,而不是尾部。
而当一个goroutine本次加锁等待时间超过了1ms后,它会把当前Mutex从正常模式切换至“饥饿模式”。
在饥饿模式下,Mutex的所有权从执行Unlock的goroutine,直接传递给等待队列头部的goroutine,后来者不会自旋,也不会尝试获得锁,即使Mutex处于Unlocked的状态。它们会直接到队列的尾部排队等待。
当一个等待者获得锁之后,它会在以下两种情况时,将Mutex由饥饿模式切换回正常模式。
- 第一种情况是它的等待时间小于1ms,也就是它刚来不久
- 第二种情况是它是最后一个等待者,等待队列已经空了,后面自然就没有饥饿的goroutine了
综上所述,在正常模式下自旋和排队是同时存在的,执行lock的goroutine会先一边自旋,尝试4次后如果还没拿到锁,就需要去排队等待了,这种排队之前先让大家来抢的模式,能够有更高的吞吐量,因为频繁的挂起,唤醒goroutine会带来较多的开销。但是又不能无限制的自旋,要把自旋的开销控制在较小的范围内,所以在正常模式下,Mutex有更好的性能。 但是可能会出现队列尾端的goroutine迟迟抢不到锁(尾端延迟)的情况。
而饥饿模式不再尝试自旋,所有goroutine都要排队,严格的FIFO,对于防止出现尾端延迟来讲特别重要。
Mutex.state状态标识
首先来看一下关于Mutex.state的几个常量定义,state是int32类型,其中第一个位用作锁状态标识,1表示已加锁,对应掩码常量为mutexLocked,第二位用于记录是否已有goroutine被唤醒了,1表示已唤醒,对应掩码常量为mutexWoken,第三位表示Mutex的工作模式,0代表正常模式,1代表饥饿模式,对应掩码常量为mutexStartving ,而常量mutexWaiterShift等于3,表示除了低三位以外,state的其它位用来记录有多少个等待者在排队。
来看一下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。lock和unlock的fast path就是这样,接下来展开slow path的主要逻辑。
Mutex源码剖析
当一个goroutine尝试给mutex加锁时,如果其他goroutine已经加了锁还没有释放,而且当前mutex工作在正常模式下,是不是就要开始自旋了呢?
不一定,因为如果当前是单核场景,自旋的goroutine在等待持有锁的goroutine释放锁,而持有锁的goroutine在等待自旋的goroutine让出CPU,这种情况下自旋是没有意义的。而且如果GOMAXPROCS=1,或者当前没有其它正在运行的P,也和单核场景类似,同样不需要自旋。除此之外,如果当前P的本地runq不为空,相较于自旋而言,切换到本地runq更有效率,所以为保障吞吐量也不会自旋。
最终,只有在多核场景下,且GOMAXPROCS>1,至少有一个其他的P处于running,当前P的本地runq为空的情况下,才可以自旋。
进入自旋的goroutine会先去争抢mutex的唤醒标识位(自旋G与等待队列第一个G就是在此竞争),设置mutexWoken标识位的目的是,在正常模式下,告知持有锁的goroutine,在unlock的时候不用再唤醒其他goroutine了,已经有goroutine在这里等待,以免唤醒太多的等待协程。mutex中的自旋,底层是通过procyield循环执行30次PAUSE,自旋次数上限为4,而且每自旋一次都要重新判断是否可以继续自旋。
如果锁被释放了,或者锁进入了饥饿模式,亦或者已经自旋了4次,都会结束自旋。结束自旋或者根本不用自旋的goroutine,就该尝试原子操作修改mutex的状态了。把此时mutex.state保存了old中,把要修改为的新state记为new。
- 如果old处于饥饿模式或者加锁状态,goroutine就得去排队,所以在这些情况下排队规模要加1.
- 如果是正常模式,就要尝试设置lock位,所以最后一位要置为1
- 如果当前goroutine等待的时间已经超过1ms,而且锁还没被释放,就要将mutex的状态切换为饥饿模式,这里之所有还要求锁没被释放,是因为如果锁已经释放了,那怎么都得去抢一次啊,要是直接进入饥饿模式就只能去排队了。
- 在执行原子操作修改state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置。
把排队规模和几个标志位都设置好以后,在执行原子操作修改state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置。因为接下来无论是要去抢锁,还是单纯的去排队,如果原子操作成功了,那么要么是成功抢到了锁,要么是成功进到了等待队列里。当前goroutine都不再是被唤醒的goroutine了,所以要释放唤醒标识。
而如果原子操作不成功,也就意味着其他goroutine在我们保存mutex.state到old以后,又修改了state的值,当前goroutine就要回过头去,继续从自旋检测这里开始再次尝试。所以也需要释放自己之前抢到的唤醒标识位,从头再来。
继续展开这个原子操作成功的分支,如果是抢锁操作成功了,那么加锁的slow path就可以宣告结束了。
如果是排队的规模设置成功了,还要决定是排在等待队列头部还是尾部,如果当前goroutine已经排过队,是在unlock中唤醒的,那就排在等待队列头部。
如果是第一次排队,就排到最后,并且从第一次排队开始记录当前goroutine的等待时间。
接下来就会让出,进到等待队列里面了(runtime_SemacquireMutex(&m.sema, queueLifo, 1)),队列里的goroutine被唤醒时(unlock),要从上次让出的地方(39行,runtime_SemacquireMutex的下面)开始继续执行,接下来会判断,如果mutex处在正常模式,那就接着从自旋开始抢锁,如果唤醒后的mutex处在饥饿模式,那就没有其他goroutine会和自己抢了,锁已经轮到自己这里,只需要把mutex.state中lock标识位设置为加锁,把等待队列规模减去1,再看看是不是要切换到正常模式,也就是自己的等待时间是不是小于1ms,或者等待队列已经空了,最后设置好mutex.state就一切ok了,这就是加锁操作的slow path。
接下来看unlock的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。