掘金 后端 ( ) • 2022-11-21 16:01

上篇文章中我们已经阐述了 Go 语言中的一个同步原语言 Mutex我们通常用在共享资源的时候使用其来保证安全性。上篇我们通过 Mutex 来保证有且只有一个 goroutine 访问我们的共享资源,在某些场景下有点“浪费”。在读多写少的场景下,会有很长一段时间没有进行写操作,使用 Mutex 对性能会有影响。

其实我们可以区分读写操作,今天我们介绍另外一个 RWMutex。

若一个 goroutine 在读操作中持有来锁,此时其他的 goroutine 就不需要在等待,可以并发的访问共享的资源(变量),从而使串行变成并行读操作,提高读的性能。当写操作的 goroutine 拿到锁时,其他的读、写操作的 goroutine,此时会阻塞,需要等待写的 goroutine 释放锁。

1、RWMutex (什么是 RWMutex)

在 Go 语言标准库中 RWMutex 是一个 reader/writer 互斥锁,RWMutex 它不限制资源的并发读,但是读写、写写操作无法并行执行。

读 写 读 Y N 写 N N

还是举一个计数器的例子聊一下,使用 10 个 goroutine 进行读操作,每次都 sleep 1ms,另一个 goroutine 进行写操作,每秒进行一次写操作。因为读操作可以并行执行,写操作时只允许一个线程执行,这正是 readers-writers 问题。

上面例子中,Incr 方法会修改计数器的值,在写操作时会使用 Lock/Unlock 进行保护。 Count 方法会读取当前计数器的值,在读操作时会使用 RLock/RUnlock 方法进行保护。

Incr 方法每秒调用一次,在竞争锁的过程中频率还是比较低的,10 个 goroutine 每毫秒会执行一次查找,通过读写锁,可以提高程序的性能,因为可以并发的执行读写。若过使用 Mutex,在多个 reader 并发读的时候需要排队进行获取锁,自然在性能上没有 RWMutex 并发读的性能好。

2、RWMutex 实现原理

在 Go 语言中,RWMutex 是基于互斥锁、变量、信号量等并发原语来实现的。Go 标准库中的 RWMutex 是基于 Mutex 实现的。

2.1 结构体

RWMutex 中总共包含以下 5 个字段。

其中:

  • 字段 w:复用互斥锁提供的能力;
  • 字段 readerCount:记录当前 reader 的数量(以及是否有 writer 竞争锁);
  • readerWait和:记录 writer 请求锁时需要等待 read 完成的 reader 的数量;
  • writerSem 和 readerSem:都是为了阻塞设计的信号量,分别用于写等待读和读等待写:

2.2 读锁

看下 RLock 和 RUnlock 方法。

  1. 如果该方法返回负数 — 其他 Goroutine 获得了写锁,当前 Goroutine 就会调用 runtime.sync_runtime_SemacquireMutex 陷入休眠等待锁的释放;
  2. 如果该方法的结果为非负数 — 没有 Goroutine 获得写锁,当前方法会成功返回;

当调用 RUnlock 时,我们需要将 Reader 的计数减去 1(第 8 行),根据 AddInt32 的返回值不同会分别进行处理:

  • 当返回值是负数,表示有一个或者多个正在执行写操作,此时会调用 rUnlockSlow 方法,检查 reader 是不是都释放读锁;
  • 当返回值是非负数,读锁直接解锁成功;

所以,rUnlockSlow 将持有锁的 reader 计数减少 1 的时候,会检查既有的 reader 是不是都已经释放了锁,如果都释放了锁,就会唤醒 writer,让 writer 持有锁。

2.3 写锁

当资源的使用者想要获取写锁时,需要调用 Lock方法。

当一个 writer 获得了内部的互斥锁,会反转 readerCount 字段,把它从原来的正整数 readerCount(>=0) 修改为负数(readerCount-rwmutexMaxReaders),让这个字段保持两个含义(既保存了 reader 的数量,又表示当前有 writer)。

  • 第 3 行,调用 Lock 阻塞后续的写操作;
  • 当 readerCount 不为 0,说明当前有其他的 reader 持有读锁,RWMutex 会把当前的 readerCount 赋值给 readerWait 字段保存下来(第 7 行),此时这个 wirter 会阻塞进入等待状态(第 8 行)。
  • 当 reader 释放读锁时,方法时),readerWait 字段就减 1,当所有活跃的 reader 都释放了读锁,才会唤醒这个 writer。

写锁的释放会调用 Unlock 方法。

当一个 writer 释放锁,会对 readerCount 字段进行反转,减去 rwmutexMaxReaders 变为负数,所以反转方法就是给它增加 rwmutexMaxReaders 这个常数值。

当 writer 要释放锁啦,之后新的 reader,不会再阻塞。

在 RWMutex 的 Unlock 返回之前,需要把内部的互斥锁释放。释放完毕后,其他的 writer 才可以继续竞争这把锁。

在 Lock 方法中,先获取内部的互斥锁接着修改其他字段,但在 Unlock 中,却是先修改其他字段接着才会释放内部的互斥锁,这样才能保证字段的修改的同时会受到互斥锁的保护。

到这里我们就完整学习了 RWMutex 的概念和实现原理。小结一下,读写互斥锁在互斥锁之上提供了额外的更细粒度的控制,能够在读操作远远多于写操作时提升性能。

3、RWMutex 的 2 个踩坑点

坑 1:不可复制

当读写锁正在使用时,它的字段会有一些状态,这个时候你去复制时,会把其字段对应的状态复制过来。当原来锁释放时,不会修改你复制出来的这个锁,会导致复制的锁用不会被释放。

坑 2:释放未加锁的 RWMutex

在 读写锁中,Lock 与 Unlock 的调用是成队出现的,RLock 和 RUnLock 也是。Lock 和 RLock 缺少的调用会导致锁未正确被释放,可能会出现死锁。但是 Unlock 和 RUnlock 会导致 panic 。在生产环境中是不允许的。切记一定要成对出现哦。