Golang 读写锁RWMutex 互斥锁Mutex 源码详解

前言

Golang中有两种类型的锁,Mutex (互斥锁)和RWMutex(读写锁)对于这两种锁的使用这里就不多说了,本文主要侧重于从源码的角度分析这两种锁的具体实现。

引子问题

我一般喜欢带着问题去看源码。那么对于读写锁,你是否有这样的问题,为什么可以有多个读锁?有没有可能出现有协程一直无法获取到写锁的情况?带着你的疑问来往下看看,具体这个锁是如何实现的。

如果你自己想看,我给出阅读的一个思路,可以先看读写锁,因为读写锁的实现依赖于互斥锁,并且读写锁比较简单一些,然后整理思路之后再去想一下实际的应用场景,然后再去看互斥锁。

下面我就会按照这个思路一步步往下走。

基础知识点

  • 知识点1:信号量
    信号量是 Edsger Dijkstra 发明的数据结构(没错就是那个最短路径算法那个牛人),在解决多种同步问题时很有用。其本质是一个整数,并关联两个操作:

申请acquire(也称为 wait、decrement 或 P 操作)
释放release(也称 signal、increment 或 V 操作)

acquire操作将信号量减 1,如果结果值为负则线程阻塞,且直到其他线程进行了信号量累加为正数才能恢复。如结果为正数,线程则继续执行。
release操作将信号量加 1,如存在被阻塞的线程,此时他们中的一个线程将解除阻塞。

  • 知识点2:锁的定义
    Golang 读写锁RWMutex 互斥锁Mutex 源码详解

    在goalng中如果实现了Lock和Unlock方法,那么它就可以被称为锁。

  • 知识点3:锁的自旋:(详见百度)

  • 知识点4:cas算法:(最好有所了解,不知道问题也不大)

读写锁RWMutex

首先我们来看看RWMutex大体结构

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
看到结构发现读写锁内部包含了一个w Mutex互斥锁
注释也很明确,这个锁的目的就是控制多个写入操作的并发执行
writerSem是写入操作的信号量
readerSem是读操作的信号量
readerCount是当前读操作的个数
readerWait当前写入操作需要等待读操作解锁的个数
这几个现在看不懂没关系,后面等用到了你再回来看就好了。

然后我们看看方法

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
一共有5个方法,看起来就不复杂,我们一个个来看。

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
这个最简单,就是返回一个locker对象没啥好说的

问题的关键就在于锁和解锁的几个方法,因为我已经看过,所以推荐这几个方法的阅读顺序是RLock Lock RUnlock Unlock

RLock(获取读锁)

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
先不看竞态检测的部分,先重点看红色框中的部分
可以看到,其实很简单,每当有协程需要获取读锁的时候,就将readerCount + 1
但是需要注意的是,这里有一个条件,当readerCount + 1之后的值 < 0的时候,那么将会调用runtime_Semacquire方法
Golang 读写锁RWMutex 互斥锁Mutex 源码详解
这个方法是一个runtime的方法,会一直等待传入的s出现>0的时候
然后我们可以记得,这里有这样一个情况,当出先readerCount + 1为负数的情况那么就会被等待,看注释我们可以猜到,是当有写入操作出现的时候,那么读操作就会被等待。

Lock(获取写锁)

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
写锁稍微复杂一些,但是样子也差不多,我们还是先来看红色框中的部分。
首先操作最前面说的互斥锁,目的就是处理多个写锁并发的情况,因为我们知道写锁只有一把。这里不需要深入互斥锁,只需要知道,互斥锁只有一个人能拿到,所以写锁只有一个人能拿到。

然后重点来了,这里的这个操作细细体会一下,atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders)
是将当前的readerCount减去一个非常大的值rwmutexMaxReaders为1 << 30
大概是1073741823这么大吧

所以我们可以从源码中看出,readerCount由于每有一个协程获取读锁就+1,一直都是正数,而当有写锁过来的时候,就瞬间减为很大的负数。
然后做完上面的操作以后的r其实就是原来的readerCount。
后面进行判断,如果原来的readerCount不为0(原来有协程已经获取到了读锁)并且将readerWait加上readerCount(表示需要等待readerCount这么多个读锁进行解锁),如果满足上述条件证明原来有读锁,所以暂时没有办法获取到写锁,所以调用runtime_Semacquire进行等待,等待的信号量为writerSem

RUnlock(释放读锁)

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
如果是我们来写的话,可能就是将之前+1的readerCount,-1就完事了,但是其实还有一些操作需要注意。
如果-1之后+1==0是啥情况?没错就是我们常见的,新手程序员,没有获取读锁就想去释放读锁,于是异常了。当然+1之后刚好是rwmutexMaxReaders,就证获取了写锁而去释放了读锁,导致异常。
除去异常情况,剩下的就是r还是<0的情况,那么证明确实有协程正在想要获取写锁,那么就需要操作我们前面看到的readerWait,当readerWait减到0的时候就证明没有人正在持有写锁了,就通过信号量writerSem的变化告知刚才等待的协程(想要获取写锁的协程):你可以进行获取了。

到这里你可以把思路大致串起来了,然后懂了再往下看。

Unlock(释放写锁)

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
写锁释放需要恢复readerCount,还记得上锁的时候减了一个很大的数,这个时候要加回来了。
当然加完之后如果>=rwmutexMaxReaders本身,那么还是新手程序员的问题,当没有获取写锁的时候就开始想着释放写锁了。
然后for循环就是为了通知所有在我们RLock方法中看到的,当有因为持有写锁所以等待的那些协程,通过信号量readerSem告诉他们可以动了。
最后别忘记还有一个互斥锁需要释放,让别的协程也可以开始抢写锁了。

至此,读写锁的分析基本上告一段落了。
针对于其中关于竞态分析的代码,有兴趣的小伙伴可以去了解一下。

互斥锁Mutex

互斥锁比读写锁复杂,但是好在golang给的注释很详细,所以也不困难(注释真的很重要)。
我们先来看看里面的一段注释:
Golang 读写锁RWMutex 互斥锁Mutex 源码详解
很长的一段英文,我用英语四级的翻译能力给你翻译一下,可以将就看看,如果可以建议你仔细看英文看懂它,因为这对于后面的源码阅读非常重要。

///
这个互斥锁是公平锁

互斥锁有两种操作模式:正常模式和饥饿模式。
在正常模式下等待获取锁的goroutine会以一个先进先出的方式进行排队,但是被唤醒的等待者并不能代表它已经拥有了这个mutex锁,它需要与新到达的goroutine争夺mutex锁。新来的goroutine有一个优势 —— 他们已经在CPU上运行了并且他们,所以抢到的可能性大一些,所以一个被唤醒的等待者有很大可能抢不过。在这样的情况下,被唤醒的等待者在队列的头部。如果一个等待者抢锁超过1ms失败了,就会切换为饥饿模式。

在饥饿模式下,mutex锁会直接由解锁的goroutine交给队列头部的等待者。
新来的goroutine不能尝试去获取锁,即使可能根本就没goroutine在持有锁,并且不能尝试自旋。取而代之的是他们只能排到队伍尾巴上乖乖等着。

如果一个等待者获取到了锁,并且遇到了下面两种情况之一,就恢复成正常工作模式。
情况1:它是最后一个队列中的等待者。
情况2:它等待的时间小于1ms

正常模式下,即使有很多阻塞的等待者,有更好的表现,因为一轮能多次获得锁的机会。饥饿模式是为了避免那些一直在队尾的倒霉蛋。
///

我的话简单总结就是,互斥锁有两种工作模式,竞争模式和队列模式,竞争就是大家一起抢,队列就是老老实实排队,这两种工作模式会通过一些情况进行切换。

首先还是来看看大体结构

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
可以看到,相对读写锁,结构上面很简单,只有两个值,但是千万不要小瞧它,减少了字段就增加了理解难度。
state:将一个32位整数拆分为:
当前阻塞的goroutine数(29位)
饥饿状态(1位,0为正常模式;1为饥饿模式)
唤醒状态(1位,0未唤醒;1已唤醒)
锁状态(1位,0可用;1占用)

sema:信号量

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
方法也很简单,就是Lock和Unlock两个方法,一个上锁,一个解锁,没啥好说的。

一个方法

我们先来看一个的要用到的方法

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
这个函数,会先判断参数addr指向的被操作值与参数old的值是否相等,如果相等会将参数new替换参数addr所指向的值,不然的话就啥也不做。
需要特别说明的是,这个方法并不会阻塞。

几个常量

这是定义的几个常量,我们在一开始的注释周围可以看到,后面需要用到,暂时记住它们的初始值就好。

mutexLocked = 1 << iota // 1左移0位,是1,二进制是1,(1表示已经上锁)
mutexWoken // 1左移1位,是2,二进制是10
mutexStarving // 1左移2位,是4,二进制是100
mutexWaiterShift = iota // 就是3, 二进制是11

starvationThresholdNs = 1e6 // 这个就是我们一开始在注释里面看到的1ms,一定超过这个门限值就会更换模式

Lock获取锁

因为Lock方法比较长,所以我切分一段段看,需要完整的请自己翻看源码。要注意的一点是,一定要时刻记住,Lock方法是做什么的,很简单,就是要抢锁。看不懂的时候想想这个目标。
Golang 读写锁RWMutex 互斥锁Mutex 源码详解
第一步,判断state状态是否为0,如果为0,证明没有协程持有锁,那么就很简单了,直接获取到锁,将mutexLocked(为1)赋值到state就可以了。

看后面的方法时,告诉需要告诉你们一个小技巧,当遇到这种位操作很多的情况,有两个方法挺好用,对于你看源码会有帮助:
第一个是将所有定值先计算,然后判断非定值的情况;
第二个是将所有的计算写下来,自己用笔去计算,不要执着于打字。

然后我们以下面这个段举例:
Golang 读写锁RWMutex 互斥锁Mutex 源码详解
首先,看注释应该能明白这一段大致意思是,如果不是饥饿模式,就会进行自旋操作,然后不断循环。

然后根据上面的技巧,old&(mutexLocked|mutexStarving) == mutexLocked
(下面均为二进制)
mutexLocked = 1
mutexStarving = 11
mutexLocked = 1
这三个是定值,所以我们容易得到,满足情况的结果为,当old为xxxx0xx(二进制第三位为0)等式成立。
也就是我们一开始说的,state的第三位是表示这个锁当前的模式,0为正常模式,1为饥饿模式。

那么第一个if就表示,如果当前模式为正常模式,且可以自旋,就进入if条件内部。

if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&

同样的分析,awoke表示是否唤醒,old&mutexWoken是取第二位,0表示当前协程未被唤醒,old>>mutexWaiterShift表示右移3位,也就是前29位,不为0证明有协程在等待,并且尝试去对比当前m.state与取出时的old状态,尝试去唤醒自己。然后自旋,并且增加自旋次数表示iter,然后重新赋值old。再循环下一次。

(你自己理一理,确实有点绕,仔细想想就想通了就对了。)

以上是使用自旋的情况,就是canSpin的。

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
然后进行判断old&mutexStarving == 0就是第三位为0的情况,还是所说的正常模式。new就马上拿到锁了,new |= mutexLocked,表示或1,就是第一位无论是啥都赋值为1

old&(mutexLocked|mutexStarving),也就是old & 0101
必须当old的1和3两个位置为1的时候才是true,也就是说当前处于饥饿模式,并且锁已经被占用的情况,那么就需要排队去。
排队也很精妙,new += 1 << mutexWaiterShift
这边注意是先计算1 << mutexWaiterShift也就是将new的前29位+1,就是表示有一个协程在等待了。

好了到这里你的位操作应该就习惯的差不多了,之后我就直接说结论,不仔细的帮你01表示了,你已经长大了,要学会自己动手了。

如果当前已经标记为饥饿模式,并且没有锁住,那么设置new为饥饿模式
if starving && old&mutexLocked != 0 {
new |= mutexStarving
}

如果唤醒,需要在两种情况下重设标志
if awoke {
如果唤醒标志为与awoke不相协调就panic
if new&mutexWoken == 0 {
throw("sync: inconsistent mutex state")
}
设置唤醒状态位0,被唤醒
new &= mutexWoken
}

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
如果获取锁成功

old&(mutexLocked|mutexStarving) == 0成立表示已经获取锁,就直接退出CAS

中间这一段我就不多解释了,就是最前面注释说的,满足什么条件转换什么模式,不多说了。然后从队列中,也就是前29位-1。
需要注意其中有一个runtime_SemacquireMutex和之前看的的runtime_Semacquire是一个意思,只是多了一个参数。
Golang 读写锁RWMutex 互斥锁Mutex 源码详解
这个就是这个方法的注释。可以看到,就是多了个队列去排队。

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
如果获取锁失败,old刷新状态再次循环,继续cas

UnLock释放锁

Golang 读写锁RWMutex 互斥锁Mutex 源码详解

Unlock就相对简单一些,竞态分析不看。
其实我们自己想也能想到,unlock就是将标识位改回来嘛。
然后因为我们已经看过读写锁了,也是同样的道理,如果没有上锁就直接解锁,那肯定报错嘛。

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
然后如果是正常模式,如果没有等待的goroutine或goroutine已经解锁完成的情况就直接返回了。如果有等待的goroutine那就通过信号量去唤醒runtime_Semrelease(注意这里是false),同时操作一下队列-1

Golang 读写锁RWMutex 互斥锁Mutex 源码详解
如果是饥饿模式就直接唤醒(注意这里是true),反正有队列嘛。

总结

其实话说回来,我们其实看起来也简单,没有冲突的情况下,能拿就拿呗,如果出现冲突了就尝试自旋解决(自旋一般都能解决)如果解决不了就通过信号量解决,同时如果正常模式就是我们说的抢占式,非公平,如果是饥饿模式,就是我们说的排队,公平,防止有一些倒霉蛋一直抢不到。

整体总结一下,看完源码我们发现,其实锁的设计并不复杂,主要设计我们要学到cas和处理读写状态的信号量通知,对于那些位操作,能看懂,学可能一时半会学不会,因为很难在一开始就设计的那么巧妙,你也体会到了只用一个变量就维护了整个体系是一种艺术。

写的着急,难免有疏漏,如果有任何问题请评论,马上修改,以免误导。

作者:LinkinStar
未经允许,不得转载
出处:https://www.cnblogs.com/linkstar/p/10913502.html

上一篇:豹哥嵌入式讲堂:ARM开发之文件详解(3)- project文件


下一篇:20155311《Java程序设计》实验五(网络编程与安全)实验报告