37DATA

Golang sync.RWMutex 深度剖析

之前在学习极客时间Go并发编程实战课时,有人在评论区中贴了几道面试题:

1. 读写锁用过吗,读写锁用在什么样的场景?

2. 说说RWMutex的实现原理?说说RWMutex与Mutex的区别?

3. RWMutex源码看过吗?如果使用Mutex来设计一个RWMutex你有什么思路?

4. 使用读写锁时如何规避死锁问题?

5. 如何监控读写锁的等待情况?你有什么思路?

我看后觉得甚是有趣,也确实是我们在实际开发应用过程中应该考虑的一些问题,便按照课程上的思路捋了一遍 Golang 的读写锁  sync.RWMutex,做了以下笔记,希望对大家有所帮助。

一、什么是读写锁

在这里,读写锁特指 Golang 标准库中的 sync.RWMutex。
我们知道,对于一段临界区的代码,如果我们使用互斥锁(sync.Mutex)进行保护的话,所有的读写操作都需要先行上锁(Lock),操作完成后再释放(Unlock),临界区内代码可以看做是串行化执行。在”读多写少“的场景中,互斥锁虽然能解决问题,但因为串行化的“读”不一定具有性能优势。
而读写锁的出现就是为了解决这类问题,这样我们就可以将串行的读变成并行读,提高“读”操作的性能;而“写”操作继续保持与所有其他操作互斥,保证只要有一个线程在执行“写”操作,其它的线程都不能执行“读写”操作。

二、基本组成与应用

sync.RWMutex 结构体主要由 5 个私有成员组成,包含了 1 个互斥锁、2 个 uint32(作为信号量)和两个int类型的计数器,如图:

Image

其中
  1. w 是排它锁,如果出现写操作则该锁会处于持有状态

  2. writerSem、readerSem 分别是写操作和读操作的信号量,用于协调多个“写-写”操作和“读-写”操作

  3. readerCount 这个计数器记录当前”读“操作的数量,并作为是否有”写“操作来竞争锁的标识

  4. readerWait 这个计数器记录”写“操作请求锁时,需要等待的”读操作“数量;

看到这里,是不是很多同学突然间就都懂了?(斜眼笑)读写锁实际上是对互斥锁(sync.Mutex)的二次包装,在“写”操作时的排它锁实际上便是之前所介绍过的互斥锁。
同时 sync.RWMutex 主要对外提供了以下几个方法:

Image

其中
  1. Lock/Unlock:”写“操作时调用的方法(也可以简单理解成互斥锁的调用)。如果锁已经被 reader 或者 writer 持有,那么,Lock 方法会一直阻塞,直到能获取到锁

  2. RLock/RUnlock:”读“操作时调用的方法。如果锁已经被 writer 持有的话,RLock 方法会一直阻塞,直到能获取到锁

  3. TryLock/TryRlock:与上述 Lock、RLock 功能一致,但是不会阻塞等待锁的获取,而是直接返回

  4. RLocker:这个方法的作用是为”读“操作返回一个 Locker 接口的对象。它的 Lock 方法会调用 RWMutex 的 RLock 方法,它的 Unlock 方法会调用 RWMutex 的 RUnlock 方法

这里摘录一个比较经典的 sync.RWMutex 应用 –- 计数器的实现,各位可以参考:
func main() {    var counter Counter    for i := 0; i < 10; i++ { // 10个reader        go func() {            for {                counter.Count() // 计数器读操作                time.Sleep(time.Millisecond)            }        }()    }    for { // 一个writer        counter.Incr() // 计数器写操作        time.Sleep(time.Second)    }}// 一个线程安全的计数器type Counter struct {    mu    sync.RWMutex    count uint64}// 使用写锁保护func (c *Counter) Incr() {    c.mu.Lock()    c.count++    c.mu.Unlock()}// 使用读锁保护func (c *Counter) Count() uint64 {    c.mu.RLock()    defer c.mu.RUnlock()    return c.count}

三、读写锁是怎么实现的

这里分成读/写两部分展开。

读操作:

func (rw *RWMutex) RLock() {    if atomic.AddInt32(&rw.readerCount, 1) < 0 {        // rw.readerCount是负值的时候,意味着此时有writer等待请求锁,因为writer优先级高,所以把后来的reader阻塞休眠        runtime_SemacquireMutex(&rw.readerSem, false, 0)    }}func (rw *RWMutex) RUnlock() {    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {        rw.rUnlockSlow(r) // 有等待的writer    }}func (rw *RWMutex) rUnlockSlow(r int32) {    if atomic.AddInt32(&rw.readerWait, -1) == 0 {        // 最后一个reader了,writer终于有机会获得锁了        runtime_Semrelease(&rw.writerSem, false, 1)    }}
我们可以看到,当”读“操作上锁时,大致流程如下:
  1. 对 readerCount 这个计数器的值+1  (表示来了一个读操作)

  2. 如果 +1 之后,readerCount 的值是负数,则代表有写操作在等待锁,那么阻塞当前的操作;否则直接退出

当”读“操作解锁时,大致流程如下:
  1. 对 readerCount 这个计数器的值-1(表示送走了一个读操作)

  2. 如果 -1 之后,readerCount 的值是负数,则代表有写操作在等待锁,那么会进入到  rUnlockSlow 这个分支的操作(继续执行);否则直接退出

  3. 对 readerWait 这个计数器的值-1 (写操作需要等当前所有的读操作都结束后才会进行写;表示送走了一个读操作之后,写操作需要等待的读操作少了一个)

  4. 如果 -1 之后,readerWait 的值是 0,则表示没有进行中的读请求了,可以释放信号量唤起写操作啦!

写操作:

func (rw *RWMutex) Lock() {    // 首先解决其他writer竞争问题    rw.w.Lock()    // 反转readerCount,告诉reader有writer竞争锁    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders    // 如果当前有reader持有锁,那么需要等待    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {        runtime_SemacquireMutex(&rw.writerSem, false, 0)    }}func (rw *RWMutex) Unlock() {    // 告诉reader没有活跃的writer了    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)    // 唤醒阻塞的reader们    for i := 0; i < int(r); i++ {        runtime_Semrelease(&rw.readerSem, false, 0)    }    // 释放内部的互斥锁    rw.w.Unlock()}
当”写“操作上锁时,大致流程如下:
  1. 调用内部的互斥锁上锁,解决写操作之间竞争的问题

  2. 把 readerCount 的值取反,让它小于0;(从而给读操作一个标识,告诉”读“操作有”写“操作要来啦,你们新来的”读“操作统统给我阻塞!!)

  3. 把当前 readerCount 的值赋予 readerWait,记一下有多少个读操作需要等待的

  4. 如果恰好这个时候 readerWait 为 0,那就没有读操作要等了,直接返回;否则阻塞等待 writerSem 信号量来唤起 (对应上文读操作解锁时的最后一个步骤)

当”写“操作解锁时,大致流程如下:
  1. 把 readerCount 的值取反,让它恢复大于0的正常状态;这个计数器的值-1(告诉新来的读操作,写操作已经完成啦)

  2. 唤醒阻塞的reader们(告诉被阻塞的读操作,写操作已经完成啦)

  3. 内部互斥锁释放(这时候其他的写操作又可以正常竞争了)

关于”写“操作之间的排他竞争,主要通过2个地方来解决:
  1. ”写-写“操作:通过内部的互斥锁(sync.Mutex),阻塞写操作的互斥锁上锁

  2. ”读-写“操作:readerCount 计数器以及信号量,阻塞写操作的互斥锁上锁成功之后的退出

四、有没有什么坑点

不可重入

sync.RWMutex  和 sync.Mutex 一样,是不可重入锁;针对 sync.RWMutex  而言,在“写”操作时,如果重新调用 Lock() 进行上锁,会因为互斥锁的缘故,导致死锁;如果重新调用“读”操作的 RLock() 进行上锁,则会因为 readerSem 信号量而阻塞,导致死锁。除此之外,在“读”操作时,在调用了 RLock 之后再次调用 Lock 也是会造成死锁的。因此在做业务开发的时候,还是得秉持着 读-读 兼容,读-写、写-写操作互斥的大原则进行使用。

不可复制

sync.RWMutex  和 sync.Mutex 一样,一旦互斥锁被使用,它的字段就会记录它当前的一些状态。这个时候你去复制这把锁,就会把它的状态也给复制过来。但是,原来的锁在释放的时候,并不会修改你复制出来的这个读写锁,这就会导致复制出来的读写锁的状态不对,可能永久死锁。
当然啦,这也包括在传值的时候,如果把 sync.RWMutex 作为参数之一传到新的函数上,那么也有可能出现类似的问题。

五、总结

  1. 上面便是 sync.RWMutex 的介绍。站在使用者的角度上讲,RWMutex 在某一时刻只能由任意数量的”读”操作持有,或者是只被单个的“写”操作持有,适合读多写少的场景
  2. 不知道有没有人产生过这样子的疑问,为什么”写“操作在给”读“操作标识时,要直接取反,而不是加多一个内部成员变量呢?(这里我也不太清楚开发背景,只能分析出是在高并发的情况下可以节约内存空间)
  3. 回顾最早提出的5道面试题,行文至此各位同学心中前4道应该都有了答案(我这里就一一不明说了,如果有疑问可以再看一遍第二、三、四个章节的结构体定义和操作流程);至于第 5 道题则需要结合 sync.Mutex 深度解析中的第二篇(可以通过 unsafe 指针读取 sync.Mutex 中的 state 字段,和 sync.RWMutex 中的 readerWait 计数器),这里挖一个坑,看看以后有没有机会详细讲解一下关于 sync.Mutex 和 sync.RWMutex 的监控。

参考资料

  1. Go 1.18 源码
  2. 极客时间《Go 并发编程实战课》基本并发原语