基础筑基
读写锁的特点
读写锁区别与互斥锁的主要区别就是读锁之间是共享的,多个goroutine可以同时加读锁,但是写锁与写锁、写锁与读锁之间则是互斥的
写锁饥饿问题
因为读锁是共享的,所以如果当前已经有读锁,那后续goroutine继续加读锁正常情况下是可以加锁成功,但是如果一直有读锁进行加锁,那尝试加写锁的goroutine则可能会长期获取不到锁,这就是因为读锁而导致的写锁饥饿问题
基于高低位与等待队列的实现
在说golang之前介绍一种JAVA里面的实现,在JAVA中ReentrantReadWriteLock实现采用一个state的高低位来进行读写锁的计数,其中高16位存储读的计数,低16位存储写的计数,并配合一个AQS来实现排队等待机制,同时AQS中的每个waiter都会有一个status,用来标识自己的状态
golang的读写锁的实现
=============
成员变量
结构体
1 | 复制代码type RWMutex struct { |
写锁计数
读写锁中允许加读锁的最大数量是4294967296,在go里面对写锁的计数采用了负值进行,通过递减最大允许加读锁的数量从而进行写锁对读锁的抢占
1 | 复制代码const rwmutexMaxReaders = 1 << 30 |
读锁实现
读锁加锁逻辑
1 | 复制代码func (rw *RWMutex) RLock() { |
读锁释放逻辑
1 | 复制代码func (rw *RWMutex) RUnlock() { |
写锁实现
加写锁实现
1 | 复制代码func (rw *RWMutex) Lock() { |
释放写锁
1 | 复制代码func (rw *RWMutex) Unlock() { |
关键核心机制
写锁对读锁的抢占
加写锁的抢占
1 | 复制代码 // 在加写锁的时候通过将readerCount递减最大允许加读锁的数量,来实现对加读锁的抢占 |
加读锁的抢占检测
1 | 复制代码// 如果没有写锁的情况下读锁的readerCount进行Add后一定是一个>0的数字,这里通过检测值为负数 |
写锁抢占读锁后后续的读锁就会加锁失败,但是如果想加写锁成功还要继续对已经加读锁成功的进行等待
1 | 复制代码 if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { |
写锁既然休眠了,则必定要有一种唤醒机制其实就是每次释放锁的时候,当检查到有加写锁的情况下,就递减readerWait,并由最后一个释放reader lock的goroutine来实现唤醒写锁
1 | 复制代码 if atomic.AddInt32(&rw.readerWait, -1) == 0 { |
写锁的公平性
在加写锁的时候必须先进行mutex的加锁,而mutex本身在普通模式下是非公平的,只有在饥饿模式下才是公平的
1 | 复制代码 rw.w.Lock() |
写锁与读锁的公平性
在加读锁和写锁的工程中都使用atomic.AddInt32来进行递增,而该指令在底层是会通过LOCK来进行CPU总线加锁的,因此多个CPU同时执行readerCount其实只会有一个成功,从这上面看其实是写锁与读锁之间是相对公平的,谁先达到谁先被CPU调度执行,进行LOCK锁cache line成功,谁就加成功锁
可见性与原子性问题
在并发场景中特别是JAVA中通常会提到并发里面的两个问题:可见性与内存屏障、原子性, 其中可见性通常是指在cpu多级缓存下如何保证缓存的一致性,即在一个CPU上修改了了某个数据在其他的CPU上不会继续读取旧的数据,内存屏障通常是为了CPU为了提高流水线性能,而对指令进行重排序而来,而原子性则是指的执行某个操作的过程的不可分割
底层实现的CPU指令
go里面并没有volatile这种关键字,那如何能保证上面的AddInt32这个操作可以满足上面的两个问题呢, 其实关键就在于底层的2条指令,通过LOCK指令配合CPU的MESI协议,实现可见性和内存屏障,同时通过XADDL则用来保证原子性,从而解决上面提到的可见性与原子性问题
1 | 复制代码 // atomic/asm_amd64.s TEXT runtime∕internal∕atomic·Xadd(SB) |
更多文章可以访问www.sreguide.com
本篇文章由一文多发平台ArtiPub自动发布
本文转载自: 掘金