一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第19天,点击查看活动详情。
此前的文章中我们了解了 Mutex 的设计思路,今天来看看实际应用中更高频的读写锁:sync.RWMutex。
什么是 RWMutex
- A RWMutex is a reader/writer mutual exclusion lock.
- The lock can be held by an arbitrary number of readers or a single writer.
- The zero value for a RWMutex is an unlocked mutex.
翻译一下官方源码说明:
- RWMutex 是一个读写互斥锁;
- 这个锁可以被任意数量的 reader 或 一个 writer 所持有;
- RWMutex 的零值是一个未上锁的锁。
func (rw *RWMutex) Lock() // 写上锁
func (rw *RWMutex) Unlock() // 写解锁
func (rw *RWMutex) RLock() // 读上锁
func (rw *RWMutex) RUnlock() // 读解锁
为什么有了 Mutex 之后还要有 RWMutex?
问题的核心在于锁的粒度,回顾一下,Mutex 提供的能力是:
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
简言之,只有加锁和解锁两个能力。不区分读写。也就意味着,如果是两个读请求并发到达,也会阻塞住一个,只允许一个进行读操作,此时另一个goroutine 就会就如到 m.lockSlow() 的流程里面,开启它自旋,休眠,被唤醒的一系列流程,才可能拿到锁。
但这里只是【读请求】,并不会真正更改数据状态,为什么要阻塞呢?事实上你放过去也没问题的。关键在于不能出现【读】vs 【写】的并发。如果只是两个【读】,逻辑上讲应该是可以并行的。这就是潜在的性能优化点。
这个时候 RWMutex 就派上用场了。回顾一下上面的官方说明,其中最重要的一句在于:
The lock can be held by an arbitrary number of readers or a single writer.
锁的持有者只有两种情况:
- 任意数量的 reader(对应到读请求);
- 一个 writer(写请求)。
换句话说,reader 不需要互相等待,他们只需要等待 writer 释放锁就 ok。如果现在没有 writer 持有锁,大家可以一起上。
所以,对于【读多写少】的场景,使用 RWMutex 能够减少程序阻塞的时间,用细粒度的锁提升了性能。
用法示例
这里我们看一个使用 sync.RWMutex 的例子。逻辑很简单:针对一个 int 类型变量 n,启动三个协程:
- 协程1: 读操作:校验 n 是否为偶数;
- 协程2: 读操作:校验 n 是否为正数;
- 协程3: 写操作:将 n 自增 1。
读协程用 RLock() RUnLock() 写协程用 Lock() UnLock()
package main
import (
"fmt"
"sync"
"time"
)
func isEven(n int) bool {
return n%2 == 0
}
func main() {
n := 0
var m sync.RWMutex
// goroutine 1
// Since we are only reading data here, we can call the `RLock`
// method, which obtains a read-only lock
go func() {
m.RLock()
defer m.RUnlock()
nIsEven := isEven(n)
time.Sleep(5 * time.Millisecond)
if nIsEven {
fmt.Println(n, " is even")
return
}
fmt.Println(n, "is odd")
}()
// goroutine 2
go func() {
m.RLock()
defer m.RUnlock()
nIsPositive := n > 0
time.Sleep(5 * time.Millisecond)
if nIsPositive {
fmt.Println(n, " is positive")
return
}
fmt.Println(n, "is not positive")
}()
// goroutine 3
// Since we are writing into data here, we use the
// `Lock` method, like before
go func() {
m.Lock()
n++
m.Unlock()
}()
time.Sleep(time.Second)
}
执行上面的代码多次,你会发现,前两个读协程可以并发读取 n,但是和写协程一定是隔离开的。
实现解析
看源码之前先要想清楚一些根本的设定。实现读写锁要解决的问题是什么?归纳起来就是三点:
- 写锁需要同时阻塞写锁和读锁:一个协程拥有写锁时,其他协程的无论【读锁】还是【写锁】都要阻塞;
- 读锁需要阻塞写锁:一个协程拥有读锁时,其他协程的【写锁】需要阻塞;
- 读锁不阻塞读锁:一个协程拥有读锁是时,其他协程也可以拥有【读锁】。
RWMutex 结构体
type RWMutex struct {
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount int32 // number of pending readers
readerWait int32 // number of departing readers
}
这里可以看出 RWMutex 也是基于 Mutex 的能力进行的包装,我们记得 Mutex 只包含了一个 state 代表状态,一个 sem 代表信号量。这里读写锁为了进一步拆分粒度。增加了下面几个变量:
- writerSem: 写阻塞等待的信号量,最后一个读者释放锁时,会释放该信号量;
- readerSem: 读阻塞的协程等待的信号量,持有写锁的协程释放锁后,会释放该信号量;
- readerCount: 拿到锁的 readers 数量
- readerWait: 正在写阻塞时的的 readers 数量。
const rwmutexMaxReaders = 1 << 30
这里我们也能看到,读写锁支持的最大并发读协程的数量是 1 << 30,也就是 2 的 30次方个,这已经非常大了,几乎不会有日常的业务开发能触及到这个量级。
信号量
针对 writerSem 和 readerSem 两个信号量,可能有些同学不熟悉到底怎么用。
其实这里只需要复用 runtime 提供的信号量机制来控制即可。注意下面两个 runtime 提供的函数:
// Semacquire waits until *s > 0 and then atomically decrements it.
// It is intended as a simple sleep primitive for use by the synchronization
// library and should not be used directly.
func runtime_Semacquire(s *uint32)
// Semrelease atomically increments *s and notifies a waiting goroutine
// if one is blocked in Semacquire.
// It is intended as a simple wakeup primitive for use by the synchronization
// library and should not be used directly.
// If handoff is true, pass count directly to the first waiter.
// skipframes is the number of frames to omit during tracing, counting from
// runtime_Semrelease's caller.
func runtime_Semrelease(s *uint32, handoff bool, skipframes int)
这两个函数分别对应到 wakeup 和 sleep 原语。
- runtime_Semacquire: 持续等待,直到 *s 大于 0 为止,然后原子性地递减它。(sleep)
- runtime_Semrelease: 原子性地递增 *s 并通知等待的 goroutine。(wakeup)
如果不存在并发,所有锁都已经被释放,信号量 sem 应该保持的是等于 0 的状态。
那问题来了,如果是默认的零值,那我直接 acquire 岂不是永远会卡住?一般不都是加锁 acquire,解锁 release 么?但是我就一个 goroutine,上来就 acquire,会怎么样?
这个是一个错误的理解,信号量本身是一种睡眠唤醒机制,如果没有出现并发阻塞的场景,预期是不需要 acquire 的。通常的处理流程是这样:
- goroutine A 拿到锁;
- goroutine A 执行业务逻辑;
- goroutine B 也来尝试拿锁,发现这时候已经被占用,需要阻塞,此时才去 acquire;
- goroutine A 业务逻辑执行完毕,发现还有 goroutine 在等待,此时 release,完成解锁。
若第四步 goroutine A 发现没有阻塞的协程,就直接返回了,无需release。
那这个 goroutine B 阻塞的状态是存到了哪儿?以及 goroutine A 怎么发现还有 goroutine 在等待呢?
这就是个根据场景而定的问题了,这里我们结合 Mutex 的源码简要说明:
在 Mutex 中, sema 是和 state 变量配合,通过改变 state 的值来让锁感知到是否需要 acquire 和 release。假设没有遇到任何并发,直接的加锁和解锁其实很简单:
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
)
// 加锁
atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)
// 解锁
atomic.AddInt32(&m.state, -mutexLocked) // 此处 mutexLocked 为 1
可以看到,加锁只是一个很简单的 CAS 原子操作,更新到 m.state。解锁也只是 -1。这样的 fast path 下,其实完全用不到信号量 m.sema。只有在并发的场景下,处理才会变化。
加锁时,发现 CAS 没有 swap 成功,goroutine 会进入到 m.lockSlow() 的流程里,判断锁的模式(normal or starvation),判断自旋条件等,在这里面阻塞到 runtime_SemacquireMutex(&m.sema, queueLifo, 1),等待 sema release。
解锁时,发现 AddInt32 原子操作之后结果不为0(正常流程下 m.state 通过加锁变为 1,解锁变回0),此时意味着已经有一些 goroutine 阻塞了,类似的会进入到 m.unlockSlow() 的流程中,在这里触发 release 操作:runtime_Semrelease(&m.sema, false, 1)。
感兴趣的同学可以详细看一看 Mutex 源码在这部分的处理。
实现思路
在看具体的几个加锁解锁方法前,请思考一下,如果让我们来基于上面这样一个 RWMutex 结果,设计读写锁的方案,应该注意什么呢?
有了整体的思路以后,再看具体四个方法,你会豁然开朗。下面的部分,我们会一点点开始思考,最终看看关键的问题,Golang 是怎样解决的。
RWMutex 中的信号量实现
看到 RWMutex 定义了 readerSem 和 writerSem,分别代表读协程和写协程需要监听的信号量,可以想到,这是为了保证读写互斥而做的设计。这里的 acquire 和 release 应该是相互的。
- 读协程在加锁的时候去 acquire readerSem, 解锁的时候去 release writerSem;
- 写协程在加锁的时候去 acquire writerSem, 解锁的时候去 release readerSem。
naive 的思考
- RLock 既然读锁可以同时被多个协程同时持有(readerCount 就是用来记录这里的数量),我们并不需要单独维护一个变量,类似互斥锁里的 m.state。只需要先把 readerCount 加一,说明有一个 reader 过来了即可。
相比于 Mutex 的 Lock,我们的 RLock 的 fast path 从 CAS atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) 变成了简单的 atomic.AddInt32(&rw.readerCount, 1)。
在 CAS 的版本中,我们通过判断 swap 是否成功,来断定要不要走 slow path,也即上锁是否成功。那现在我们只是给 readerCount 加了 1,虽然读请求并发不成问题,但是怎么知道这时候有没有一个写协程在执行逻辑呢?这个关乎到要不要去 acquire 我们的 readerSem。
- RUnlock 先把 readerCount 减一,说明有一个 reader 要跑路。但注意,同时可能有很多个 reader,说不好自己是不是最后一个,如果是,显然这个时候不能直接减一就溜,因为你还得去判断有没有 writer 在等,如果有,要唤醒 writerSem 那个信号量。告诉人家:reader 都收工了,你来写吧。
问题来了,怎么判断自己是最后一个 reader 呢?以及怎样判断是否有 writer 在后面等呢?
- Lock
写操作加锁其实直接复用 Mutex 的能力即可,这也是 RWMutex 内置一个 Mutex 的原因。但是仅仅通过了 Mutex 的
Lock()还不够,前面提到过,如果现在读锁被 readers 持有,是不能给写操作加锁的。所以需要监听信号量,等 writerSem 被 release 了才算持有。
但是和上面的问题类似,我们怎样判断是否当前读锁已经被持有呢?
另外我们还有一个设计的要求:写操作一旦加锁,按照 RWMutex 的预期要【阻塞读锁】。这一步怎么做?
- UnLock
写操作解锁要做的事情也是明确的,首先,我们需要告知阻塞的读协程,你们可以继续了。然后调用 Mutex 的
UnLock()即可。
// 释放 readerSem,注意可能等待的 goroutine 很多,这里需要多次释放
// 每次释放本质上就是允许一个协程 acquire
for i := 0;i < xxx; i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
// 允许其他写操作来获取写锁
rw.w.Unlock()
问题:阻塞的读协程的数量 xxx 应该怎么计算?
官方解法
上面我们自己过了一遍各个接口可能的实现,看过源码后才会理解,其实官方对这些问题的解答,其精髓在于 readerCount 和 readerWait 两个变量。
我们来看看这些场景,官方到底是怎么解决的:
-
写操作互相阻塞 这一点和我们的预判相符,写操作之间的控制依赖于内置的 Mutex。
-
写操作阻塞读操作 这里是重点,一定要注意。
在不考虑写操作的前提下,我们可以认为每次 RLock 会将 readerCount + 1,每次 RUnLock 会将 readerCount - 1。加上我们此前提到的【最大reader数量】rwmutexMaxReaders = 1 << 30,即 2 的 30 次方。可以看出来,readerCount 的取值范围是[0, 1073741824]。
在执行写锁定,即 Lock() 时,会先将 readerCount 减去 1073741824,这样就小于0了。
再有读锁定到来的时候,检测 readerCount,发现为负数,就知道此时已经有写操作进行了,读需要阻塞。
而真实的【读操作的数量】,只需要 readerCount 再加上 1073741824 就拿到了。
一句话:写操作是将 readerCount 变成负值来阻止读操作的。此时,就会开始尝试 acquire readerSem,阻塞在这里。
由此可以看出,readerCount 语义上的确代表了【并发中所有读操作的数量】,只不过有可能被 writer 变成了减去 1073741824 之后的负数。如果小于0,记得加上 2的三十次方即可。
-
读操作阻塞写操作 写操作只要发现有正在进行的读操作,就应该停下来阻塞到这里。这个直接通过 readerCount 就可以判断。
-
如何保证写操作不会饿死 如果源源不断来【读操作】,会不会【写操作】一直无法执行?
答案是否定的,readerWait 就是为了解决这个问题才设计进去的。
原理:写操作上锁的 Lock() 函数中,会把 readerCount 的值复制到 readerWait,用来标记【排在写操作前面的 reader 个数】。
当排在前面的 reader 完事的时候,会递减 readerCount 的值,并同时递减 readerWait 的值。当 readerWait 等于 0 时会唤醒写操作。
一句话:写操作相当于把一段连续的读操作分成两个部分,前一部分完成的时候,通过 writerSem 唤醒写操作,实现插队的效果。
源码解析
好了,上面讨论了一大堆,我们来看看源码,看这些逻辑是怎样映射下来的。源码中涉及 race 的部分请先忽略,不影响我们对代码的理解。这次没有加注释,留下了官方源码,大家可以参考每段代码下面的分析。
Lock
// Lock locks rw for writing.
// If the lock is already locked for reading or writing,
// Lock blocks until the lock is available.
func (rw *RWMutex) Lock() {
if race.Enabled {
_ = rw.w.state
race.Disable()
}
// First, resolve competition with other writers.
rw.w.Lock()
// Announce to readers there is a pending writer.
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for active readers.
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem))
race.Acquire(unsafe.Pointer(&rw.writerSem))
}
}
- 先用 Mutex 的 Lock() 阻断其他写协程进入;
- 将 readerCount 转为负数(打个招呼,写协程准备拿锁了);
- 将 readerCount 真正的正数值加到 readerWait,记录一下排在写操作前的 reader 有多少;
- 若已经有读协程了,或排在前面的 reader 还没完,就老老实实待着,尝试 acquire writerSem,直到获取成功。
- 若没有读协程了(readerCount 为 0),或没有排在前面的 reader 了(readerWait 为 0),就直接返回了,无需等待信号量。
UnLock
// Unlock unlocks rw for writing. It is a run-time error if rw is
// not locked for writing on entry to Unlock.
//
// As with Mutexes, a locked RWMutex is not associated with a particular
// goroutine. One goroutine may RLock (Lock) a RWMutex and then
// arrange for another goroutine to RUnlock (Unlock) it.
func (rw *RWMutex) Unlock() {
if race.Enabled {
_ = rw.w.state
race.Release(unsafe.Pointer(&rw.readerSem))
race.Disable()
}
// Announce to readers there is no active writer.
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
race.Enable()
throw("sync: Unlock of unlocked RWMutex")
}
// Unblock blocked readers, if any.
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
// Allow other writers to proceed.
rw.w.Unlock()
if race.Enabled {
race.Enable()
}
}
- 给 readerCount 还原回去,变成正数(打个招呼:写协程这边完事了);
- 看现在 readerCount 有几个,就释放几次信号量,这边 release,reader 那边就能 acquire 到了;
- 调用 Mutex 的
UnLock()彻底解锁,其他写协程可以进来了。
RLock
// Happens-before relationships are indicated to the race detector via:
// - Unlock -> Lock: readerSem
// - Unlock -> RLock: readerSem
// - RUnlock -> Lock: writerSem
//
// The methods below temporarily disable handling of race synchronization
// events in order to provide the more precise model above to the race
// detector.
//
// For example, atomic.AddInt32 in RLock should not appear to provide
// acquire-release semantics, which would incorrectly synchronize racing
// readers, thus potentially missing races.
// RLock locks rw for reading.
//
// It should not be used for recursive read locking; a blocked Lock
// call excludes new readers from acquiring the lock. See the
// documentation on the RWMutex type.
func (rw *RWMutex) RLock() {
if race.Enabled {
_ = rw.w.state
race.Disable()
}
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem))
}
}
- 给 readerCount 加 1;
- 如果 readerCount 为负数(记得前面的结论么),说明此时已经有写协程在准备拿锁了,读这边等信号量吧;
- 如果 readerCount > 0,直接返回,说明此时没有写协程拿着锁,可以正常读。
RUnlock
// RUnlock undoes a single RLock call;
// it does not affect other simultaneous readers.
// It is a run-time error if rw is not locked for reading
// on entry to RUnlock.
func (rw *RWMutex) RUnlock() {
if race.Enabled {
_ = rw.w.state
race.ReleaseMerge(unsafe.Pointer(&rw.writerSem))
race.Disable()
}
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// Outlined slow-path to allow the fast-path to be inlined
rw.rUnlockSlow(r)
}
if race.Enabled {
race.Enable()
}
}
func (rw *RWMutex) rUnlockSlow(r int32) {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
race.Enable()
throw("sync: RUnlock of unlocked RWMutex")
}
// A writer is pending.
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// The last reader unblocks the writer.
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
- 给 readerCount -1;
- 如果 readerCount 为负数,说明有 writer 准备拿锁了,正在阻塞,走到 rUnlockSlow 路径上。
- 将 readerWait 也减去1,看看轮没轮到 writer,如果还没减到0,说明writer 排位前还有别的 reader,那就轮不到这次给他机会了,直接返回。如果减到 0了,说明到人家 writer 了,释放 writerSem 信号量后再退出。
- 如果 readerCount >= 0,说明一切正常,writer 没动静,解读锁成功,返回就行。
总结
可以看出来,RWMutex 的确是基于 Mutex 之上,通过信号量和计数来提供了对【读操作】额外拆出来一个锁粒度的效果。对于 readerCount 和 readerWait 的联动设计还是很巧妙的,建议大家好好看看。
因为 maxReaderCount 这个上限是一个固定的数,进而我们就可以基于 readerCount 的正负,额外传递出 writer 的状态,还是很厉害的。