我们使用 Mutex来保证读写共享资源的安全性。不管是读还是写,我们都通过 Mutex 来保证只有一个 goroutine 访问共享资源,这在某些情况下有点“浪费”。比如说,在写少读多的情况下,即使一段时间内没有写操作,大量并发的读访问也不得不在 Mutex 的保护下变成了串行访问,这个时候,使用Mutex,对性能的影响就比较大。
怎么办呢?你是不是已经有思路了,对,就是区分读写操作
我来具体解释一下。如果某个读操作的 goroutine 持有了锁,在这种情况下,其它读操作的goroutine 就不必一直傻傻地等待了,而是可以并发地访问共享变量,这样我们就可以将串行的读变成并行读,提高读操作的性能。当写操作的 goroutine 持有锁的时候,它就是一个排外锁,其它的写操作和读操作的 goroutine,需要阻塞等待持有这个锁的 goroutine 释放锁
这一类并发读写问题叫作@readers-writers 问题,意思就是,同时可能有多个读或者多个写,但是只要有一个线程在执行写操作,其它的线程都不能执行读写操作. Go标准库中的 RWMutex (读写锁)就是用来解决这类 readers-writers 问题的。
RWMutex?
我先简单解释一下读写锁RWMutex。标准库中的 RWMutex 是一个 reader/writer 互斥锁RWMutex在某一时刻只能由任意数量的 reader 持有,或者是只被单个的 writer 持有。 RWMutex 的方法也很少,总共有 5个
-
Lock/Unlock: 写操作时调用的方法。如果锁已经被 reader 或者 writer 持有,那么Lock 方法会一直阻塞,直到能获取到锁;Unlock 则是配对的释放锁的方法。
-
RLock/RUnlock: 读操作时调用的方法。如果锁已经被 writer 持有的话,RLock 方法会一直阻塞,直到能获取到锁,否则就直接返回;而 RUnlock 是 reader 释放锁的方法
-
RLocker: 这个方法的作用是为读操作返回一个Locker 接口的对象。它的 Lock 方法会调用RWMutex的 RLock 方法,它的 Unlock 方法会调用 RWMutex的 RUnlock 方法
RWMutex 的零值是未加锁的状态,所以,当你使用 RWMutex 的时候,无论是声明变量还是嵌入到其它 struct 中,都不必显式地初始化。
以计数器为例,来说明一下,如何使用 RWMutex 保护共享资源。计数器的 count++操作是写操作,而获取 count 的值是读操作,这个场景非常适合读写锁,因为读操作可以并行执行,写操作时只允许一个线程执行,这正是readers-writers 问题。
在这个例子中,使用10个 goroutine 进行读操作,每读取一次,sleep 1毫秒,同时,还有个gorotine 进行写操作,每一秒写一次,这是一个1 writer-n reader 的读写场景,而且写操作还不是很频繁(一秒一次)
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
}
可以看到,Incr 方法会修改计数器的值,是一个写操作,我们使用 Lock/Unlock 进行保护Count 方法会读取当前计数器的值,是一个读操作,我们使用 RLock/RUnlock 方法进行保护。
Incr 方法每秒才调用一次,所以,writer 竞争锁的频次是比较低的,而10个goroutine每毫秒都要执行一次查询,通过读写锁,可以极大提升计数器的性能,因为在读取的时候,可以并发进行。如果使用 Mutex,性能就不会像读写锁这么好。因为多个 reader 并发读的时候使用互斥锁导致了reader 要排队读的情况,没有RWMutex并发读的性能好。
如果你遇到可以明确区分 reader 和 writer goroutine 的场景,且有大量的并发读、少量的并发写,并且有强烈的性能需求你就可以考虑使用读写锁 RWMutex 替换Mutex。
在实际使用 RWMutex 的时候,如果我们在 struct 中使用 RWMutex 保护某个字段,一般会把它和这个字段放在一起,用来指示两个字段是一组字段。除此之外,我们还可以采用匿名字段的方式嵌入 struct,这样,在使用这个 struct 时,我们就可以直接调用 Lock/Unlock.RLock/RUnlock 方法了。
RWMutex 的实现原理
RWMutex 是很常见的并发原语,很多编程语言的库都提供了类似的并发类型。RWMutex -般都是基于互斥锁、条件变量 (condition variables)或者信号量 (semaphores) 等并发原语来实现。Go 标准库中的 RWMutex 是基于Mutex 实现的。
readers-writers 问题一般有三类,基于对读和写操作的优先级,读写锁的设计和实现也分成三类
-
Read-preferring:读优先的设计可以提供很高的并发性,但是,在竞争激烈的情况下可能会导致写饥饿。这是因为,如果有大量的读,这种设计会导致只有所有的读都释放了锁之后,写才可能获取到锁
-
Write-preferring: 写优先的设计意味着,如果已经有一个 writer 在等待请求锁的话,它会阻止新来的请求锁的 reader 获取到锁,所以优先保障 writer。当然,如果有一些reader 已经请求了锁的话,新请求的 writer 也会等待已经存在的reader 都释放锁之后才能获取。所以,写优先级设计中的优先权是针对新来的请求而言的。这种设计主要避免了writer 的饥饿问题。
-
不指定优先级: 这种设计比较简单,不区分 reader 和 writer 优先级,某些场景下这种不指定优先级的设计反而更有效,因为第一类优先级会导致写饥饿,第二类优先级可能会导致读饥饿,这种不指定优先级的访问不再区分读写,大家都是同一个优先级,解决了饥饿的问题。
Go 标准库中的 RWMutex 设计是 Write-preferring 方案。一个正在阻塞的 Lock 调用会排除新的 reader 请求到锁。 RWMutex 包含一个Mutex,以及四个辅助字段 writerSem、readerSem、readerCount和readerWait:
type RWMutex struct {
w Mutex // 互斥锁解决多个writer的竞争
writerSem uint32 // writer信号量
readerSem uint32 // reader信号量
readerCount int32 // reader的数量
readerWait int32 // writer等待完成的reader的数量
}
const rwmutexMaxReaders = 1 << 30
- 字段w:为 writer 的竞争锁而设计;
- 字段readerCount: 记录当前 reader 的数量(以及是否有 writer 竞争锁)
- readerWait:记录 writer 请求锁时需要等待 read 完成的 reader 的数量
- writerSem和readerSem: 都是为了阻塞设计的信号量
- 这里的常量rwmutexMaxReaders,定义了最大的 reader 数量
RLock/RUnlock 的实现
首先,我们看一下移除了 race 等无关紧要的代码后的 RLock 和 RUnock 方法
func (rw *RWMutex) RLock() {
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// rw.readerCount是负值的时候,意味着此时有writer等待请求锁,因为writer优先级
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)
}
}
第2行是对reader计数加1。你可能比较困惑的是,readerCount 怎么还可能为负数呢?其实,这是因为,readerCount 这个字段有双重含义:
-没有 writer 竞争或持有锁时,readerCount 和我们正常理解的 reader 的计数是一样的。 但是,如果有 writer 竞争锁或者持有锁时,那么,readerCount 不仅仅承担着 reader的计数功能,还能够标识当前是否有 writer 竞争或持有锁,在这种情况下,请求锁的 reader的处理进入第4行,阻塞等待锁的释放。
调用RUnlock 的时候,我们需要将 Reader 的计数减去1第8行),因为reader 的数量减少了一个。但是,第8行的 Addlnt32 的返回值还有另外一个含义。如果它是负值,就表示当前有 writer 竞争锁,在这种情况下,还会调用rUnlockSlow 方法,检查是不是 reader都释放读锁了,如果读锁都释放了,那么可以唤醒请求写锁的 writer 了。
当一个或者多个reader持有锁的时候,竞争锁的 writer 会等待这些 reader释放完,才可能持有这把锁。打个比方,在房地产行业中有条规矩叫做“买卖不破租赁”,意思是说,就算房东把房子卖了,新业主也不能把当前的租户赶走,而是要等到租约结束后,才能接管房子。这和RWMutex 的设计是一样的。当 writer 请求锁的时候,是无法改变既有的 reader 持有锁的现实的,也不会强制这些 reader 释放锁,它的优先权只是限定后来的 reader 不要和它抢。
Lock
RWMutex 是一个多 writer 多reader 的读写锁,所以同时可能有多个 writer和 reader。那么,为了避免 writer 之间的竞争,RWMutex 就会使用一个 Mutex 来保证 writer 的互斥
一旦一个 writer 获得了内部的互斥锁,就会反转readerCount 字段,把它从原来的正整数readerCount(>=0)修改为负数 (readerCount-rwmutexMaxReaders),让这个字段保持两个含义(既保存了 reader 的数量,又表示当前有 writer)。
我们来看下下面的代码。第5行,还会记录当前活跃的 reader 数量,所谓活跃的 reader就是指持有读锁还没有释放的那些 reader。
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)
}
}
如果readerCount 不是0,就说明当前有持有读锁的 reader,RWMutex 需要把这个当前readerCount 赋值给 readerWait 字段保存下来(第7行),同时,这个 writer 进入阻塞等待状态 (第8行)。
每当一个 reader 释放读锁的时候 (调用 RUnlock 方法时),readerWait 字段就减 1,直到所有的活跃的 reader 都释放了读锁,才会唤醒这个 writer。
Unlock
当一个 writer 释放锁的时候,它会再次反转 readerCount 字段。可以肯定的是,因为当前锁由 writer 持有,所以,readerCount 字段是反转过的,并且减去了 rwmutexMaxReaders这个常数,变成了负数。所以,这里的反转方法就是给它增加rwmutexMaxReaders 这个常数值。
既然 writer 要释放锁了,那么就需要唤醒之后新来的 reader,不必再阻塞它们了,让它们开开心心地继续执行就好了。 在RWMutex的Unlock 返回之前,需要把内部的互斥锁释放。释放完毕后,其他的 writer才可以继续竞争这把锁。
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()
}
在这段代码中,我删除了 rae 的处理和异常情况的检查,总体看来还是比较简单的。这里有几个重点,我要再提醒你一下。首先,你要理解 readerCount 这个字段的含义以及反转方式。其次,你还要注意字段的更改和内部互斥锁的顺序关系。在 Lock 方法中,是先获取内部互斥锁,才会修改的其他字段;而在 Unlock 方法中,是先修改的其他字段,才会释放内部互斥锁,这样才能保证字段的修改也受到互斥锁的保护。