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

前言

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

 

引子问题

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

若是你本身想看,我给出阅读的一个思路,能够先看读写锁,由于读写锁的实现依赖于互斥锁,而且读写锁比较简单一些,而后整理思路以后再去想一下实际的应用场景,而后再去看互斥锁。golang

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

 

基础知识点

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

申请acquire(也称为 wait、decrement 或 P 操做)
释放release(也称 signal、increment 或 V 操做)数据结构

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

  • 知识点2:锁的定义


    在goalng中若是实现了Lock和Unlock方法,那么它就能够被称为锁。app

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

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

读写锁RWMutex

首先咱们来看看RWMutex大致结构


看到结构发现读写锁内部包含了一个w Mutex互斥锁
注释也很明确,这个锁的目的就是控制多个写入操做的并发执行
writerSem是写入操做的信号量
readerSem是读操做的信号量
readerCount是当前读操做的个数
readerWait当前写入操做须要等待读操做解锁的个数
这几个如今看不懂不要紧,后面等用到了你再回来看就行了。atom

 

而后咱们看看方法


一共有5个方法,看起来就不复杂,咱们一个个来看。


这个最简单,就是返回一个locker对象没啥好说的

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

 

RLock(获取读锁)


先不看竞态检测的部分,先重点看红色框中的部分
能够看到,其实很简单,每当有协程须要获取读锁的时候,就将readerCount + 1
可是须要注意的是,这里有一个条件,当readerCount + 1以后的值 < 0的时候,那么将会调用runtime_Semacquire方法

这个方法是一个runtime的方法,会一直等待传入的s出现>0的时候
而后咱们能够记得,这里有这样一个状况,当出先readerCount + 1为负数的状况那么就会被等待,看注释咱们能够猜到,是当有写入操做出现的时候,那么读操做就会被等待。

 

Lock(获取写锁)


写锁稍微复杂一些,可是样子也差很少,咱们仍是先来看红色框中的部分。
首先操做最前面说的互斥锁,目的就是处理多个写锁并发的状况,由于咱们知道写锁只有一把。这里不须要深刻互斥锁,只须要知道,互斥锁只有一我的能拿到,因此写锁只有一我的能拿到。

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

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

 

RUnlock(释放读锁)


若是是咱们来写的话,可能就是将以前+1的readerCount,-1就完事了,可是其实还有一些操做须要注意。
若是-1以后+1==0是啥状况?没错就是咱们常见的,新手程序员,没有获取读锁就想去释放读锁,因而异常了。固然+1以后恰好是rwmutexMaxReaders,就证获取了写锁而去释放了读锁,致使异常。
除去异常状况,剩下的就是r仍是<0的状况,那么证实确实有协程正在想要获取写锁,那么就须要操做咱们前面看到的readerWait,当readerWait减到0的时候就证实没有人正在持有写锁了,就经过信号量writerSem的变化告知刚才等待的协程(想要获取写锁的协程):你能够进行获取了。

到这里你能够把思路大体串起来了,而后懂了再往下看。

 

Unlock(释放写锁)


写锁释放须要恢复readerCount,还记得上锁的时候减了一个很大的数,这个时候要加回来了。
固然加完以后若是>=rwmutexMaxReaders自己,那么仍是新手程序员的问题,当没有获取写锁的时候就开始想着释放写锁了。
而后for循环就是为了通知全部在咱们RLock方法中看到的,当有由于持有写锁因此等待的那些协程,经过信号量readerSem告诉他们能够动了。
最后别忘记还有一个互斥锁须要释放,让别的协程也能够开始抢写锁了。

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

 

 

互斥锁Mutex

互斥锁比读写锁复杂,可是好在golang给的注释很详细,因此也不困难(注释真的很重要)。
咱们先来看看里面的一段注释:

很长的一段英文,我用英语四级的翻译能力给你翻译一下,能够将就看看,若是能够建议你仔细看英文看懂它,由于这对于后面的源码阅读很是重要。


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

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

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

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

正常模式下,即便有不少阻塞的等待者,有更好的表现,由于一轮能屡次得到锁的机会。饥饿模式是为了不那些一直在队尾的倒霉蛋。
///

 

 

个人话简单总结就是,互斥锁有两种工做模式,竞争模式和队列模式,竞争就是你们一块儿抢,队列就是老老实实排队,这两种工做模式会经过一些状况进行切换。

 

首先仍是来看看大致结构


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

sema:信号量


方法也很简单,就是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方法是作什么的,很简单,就是要抢锁。看不懂的时候想一想这个目标。

第一步,判断state状态是否为0,若是为0,证实没有协程持有锁,那么就很简单了,直接获取到锁,将mutexLocked(为1)赋值到state就能够了。

看后面的方法时,告诉须要告诉大家一个小技巧,当遇到这种位操做不少的状况,有两个方法挺好用,对于你看源码会有帮助:
第一个是将全部定值先计算,而后判断非定值的状况;
第二个是将全部的计算写下来,本身用笔去计算,不要执着于打字。

而后咱们如下面这个段举例:

首先,看注释应该能明白这一段大体意思是,若是不是饥饿模式,就会进行自旋操做,而后不断循环。

而后根据上面的技巧,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的。


而后进行判断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
}


若是获取锁成功

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

中间这一段我就很少解释了,就是最前面注释说的,知足什么条件转换什么模式,很少说了。而后从队列中,也就是前29位-1。
须要注意其中有一个runtime_SemacquireMutex和以前看的的runtime_Semacquire是一个意思,只是多了一个参数。

这个就是这个方法的注释。能够看到,就是多了个队列去排队。


若是获取锁失败,old刷新状态再次循环,继续cas

 

UnLock释放锁

Unlock就相对简单一些,竞态分析不看。
其实咱们本身想也能想到,unlock就是将标识位改回来嘛。
而后由于咱们已经看过读写锁了,也是一样的道理,若是没有上锁就直接解锁,那确定报错嘛。


而后若是是正常模式,若是没有等待的goroutine或goroutine已经解锁完成的状况就直接返回了。若是有等待的goroutine那就经过信号量去唤醒runtime_Semrelease(注意这里是false),同时操做一下队列-1


若是是饥饿模式就直接唤醒(注意这里是true),反正有队列嘛。

 

总结

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

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

 写的着急,不免有疏漏,若是有任何问题请评论,立刻修改,以避免误导。

 

 

 

 

做者:LinkinStar
未经容许,不得转载
出处:http://www.javashuo.com/article/p-eapjpdem-md.html

相关文章
相关标签/搜索