在Go中,使用Mutex
同步原语来保证共享资源的只能由一个goroutine
访问,但某些场景下这种只能由一个goroutine
访问的情况事实上效率是比较低的,例如,在读多写少的情况下,即使没有读操作,大量的并发读访问也会因为Mutex
而导致串行访问,实际上对性能的影响其实算比较大的。
面对这种读多写少的情况,一种有效的办法就是区分读写操作。
具体来说:
- 如果某个读操作的
goroutine
持有了锁,此时若其他读操作的goroutine
也想要获取这把锁不会被阻塞,可以并发访问共享资源,从而将串行读变成并发读,提高读操作的性能。 - 当写操作的
goroutine
持有锁时,这个锁相当于是排他锁,其他读写操作的goroutine
想要获取锁时会被阻塞并等待直到锁被释放。 - 这一类并发读写问题叫作
readers-writers
问题。
在Go标准库中,RWMutex
(读写锁)便是解决这类 readers-writers
问题的神兵利器。
一、RWMutex介绍及使用场景
读写锁RWMutex
是一个读写互斥锁,即reader/writer
互斥锁,RWMutex
锁在某一时刻只能由任意数量的reader
持有,或者是被单个writer
持有。
RWMutex
的零值为未加锁状态 ,当使用RWMutex
的时候,无论是声明该类型的变量,还是将其嵌入到struct
中,都无需显示地初始化它。
RWMutex
提供了如下几个方法:
方法 | 说明 |
---|---|
Lock/Unlock | 写操作调用的方法 。如果锁已经被reader 或者 writer 持有,则调用Lock 方法会一直阻塞;Unlock 则是对应释放锁的方法 |
RLock/RUnlock | 读操作调用的方法 。如果锁已经被writer 持有,则RLock 方法会一直阻塞,直到能获取到锁,否则就直接返回;而 RUnlock 是 reader 释放锁的方法 |
RLocker | 为读操作返回一个Locker 接口的对象。该对象的Lock 方法会调用RWMutex 的RLock 方法;该对象的Unlock 方法会调用RWMutex 的RUnlock 方法 |
计数器就是一个很好使用RWMutex
读写互斥锁的例子。
- 读操作可以并行执行;
- 写操作只允许一个线程执行;
- 计数器的count++操作是写操作,而获取count的值为读操作;
- 这种场景下非常适合使用读写锁,即
readers-writers
问题。
如何使用RWMutex
保护共享资源?
go
// Counter 线程安全的计数器结构体
type Counter struct {
mu sync.RWMutex
count uint64
}
// Incr 写锁保护写操作
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// Count 读锁保护读操作
func (c *Counter) Count() uint64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.count
}
func main() {
var counter Counter
// 开启10个reader进行读操作
for i := 0; i < 1000; i++ {
go func() {
for {
count := counter.Count() // 计数器读操作
fmt.Println(count)
time.Sleep(time.Millisecond)
}
}()
}
// writer
for {
counter.Incr()
time.Sleep(time.Second)
}
}
上述代码中,使用for循环开启了十个进行读操作的goroutine
,每个goroutine
循环读取count
的值,每读取一次,sleep 1 毫秒。同时,以main goroutine
作为写操作的执行goroutine
,循环执行计数器的写操作,每一秒写一次。
Incr
方法会修改计数器的值,是一个写操作方法,使用Lock/Unlock
进行保护。Incr
方法每秒才调用一次,所以writer
竞争锁的频次比较的低;Count
方法读取计数器的值,是一个读操作方法,使用RLock/RUnlock
方法进行保护。十个goroutine
每毫秒都在执行一次查询;
通过读写锁,可以看到能够极大的提升计数器的性能,在读取计数器的值时,能够并发进行读操作,避免了串行读。
如果使用Mutex
,性能就不会像读写锁这么好。因为多个reader 并发读时,使用互斥锁导致了reader
要排队读的情况,即串行读,没有RWMutex
并发读的性能好。
可以通过修改一下Count
方法,对比一下串行读与并发读的执行过程。
go
// Count 读锁保护读操作
func (c *Counter) Count() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
time.Sleep(time.Millisecond * 100)
return c.count
}
// Count 读锁保护读操作
func (c *Counter) Count() uint64 {
c.mu.RLock()
defer c.mu.RUnlock()
time.Sleep(time.Millisecond * 100)
return c.count
}
如果在实际场景中,遇到可以明确区分 reader 和 writer goroutine 的场景,并且存在大量的并发读,少量的并发写,并且有性能上的需求,则可以考虑使用读写锁 RWMutex
替换 Mutex
。
二、RWMutex实现原理
readers-writers
问题一般有三类,基于对读和写操作的优先级,读写锁的设计和实现也分成三类。
Read-preferring
:读操作优先可以提供很高的并发性,但在竞争激烈的情况下,很有可能会导致写操作饥饿,即写操作goroutine
无法获取到锁,只有所有的读操作结束后,没有读操作获取锁,写操作才能够获取到锁。Write-preferring
:写操作优先,如果有一个writer goroutine
在等待请求锁时,其他的新来的reader
goroutine请求锁时会被阻止,保证writer goroutine
先获取到锁,当然,如果有一些reader
已经请求了锁的话,新请求的writer
也会等待已经存在的reader
都释放锁之后才能获取。写操作优先主要避免了writer
的饥饿问题。- 不指定优先级:不区分
reader
和writer
的优先级,在某些场景下这种设计反而更加高效,避免了上述两种方法导致的读饥饿问题与写饥饿问题。
在Go标准库中,RWMutex
的设计思想采用了Write-preferring
方案,一个正在阻塞的Lock
调用会排除新的reader
请求到锁。
1、RWMutex结构
在Go中,RWMutex
是基于基于 Mutex 实现的,结合条件变量 (condition variables)、信号量(semaphores)等并发原语来实现。
RWMutex结构体中,包含一个 Mutex,以及四个辅助字段 writerSem、readerSem、readerCount 和 readerWait:
go
type RWMutex struct {
w Mutex // 互斥锁,解决多个writer的竞争
writerSem uint32 // writer等待reader完成的信号量
readerSem uint32 // reader等待writer完成的信号量
readerCount int32 // reader的数量
readerWait int32 // writer等待完成的reader的数量
}
const rwmutexMaxReaders = 1 << 30
上述结构体中:
w
:解决writer之间竞争锁的互斥锁;writerSem
:阻塞writer
的信号量;readerSem
:阻塞reader
的信号量;readerCount
:记录当前reader
的数量,以及标识是否有writer
竞争锁;readerWait
:记录writer
请求锁时,需要等待read
完成的reader
数量;rwmutexMaxReaders
:定义了最大的reader
数量;
2、RLock/RUnlock 的实现
移出race等无关紧要,RLock
方法如下:
go
func (rw *RWMutex) RLock() {
// rw.readerCount是负值的时候,意味着此时有writer等待请求锁,因为writer优先级高,所以把后来的reader阻塞休眠
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}
上述RLock
方法中,对reader
的计数器readerCount
加1,当readerCount
为负数时,则会将当前请求锁的reader
进行阻塞睡眠。
为什么readerCount
会是负数呢?其实readerCount
有双重含义:
- 若当前
RWMutex
没有writer
竞争或持有锁时,readerCount
为正数,为正常的reader
的计数; - 若当前
RWMutex
有writer 竞争锁或者持有锁时,readerCount
为负数,此时readerCount
不仅为reader
的计数,而且负数标识为当前是否有writer
竞争或持有锁,此时请求锁的reader
会进入阻塞,等待锁的释放(runtime_SemacquireMutex(&rw.readerSem, false, 0)
);
RUnlock
方法:
go
func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// 检查是否所有的reader都释放读锁,唤醒请求写锁的writer
rw.rUnlockSlow(r)
}
}
当调用RUnlock
时,会将reader
的计数器readerCount
的计数值减去1,标识当前reader
释放读锁。之后判断当前减去后的返回值,如果返回值为负数,说明当前有writer
正在获取RWMutex
锁,此时调用rw.rUnlockSlow(r)
方法来检查是否所有的reader
都释放读锁,若所有的reader
都释放了锁,则唤醒请求写锁的writer
。
rUnlockSlow
方法如下:
go
func (rw *RWMutex) rUnlockSlow(r int32) {
// A writer is pending.
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// The last reader unblocks the writer.
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
在代码第三行,判断readerWait
需要等待的reader
数量是否为0
,若为0
则通过writerSem
信号量唤醒阻塞的writer
。当一个或者多个reader
持有锁的时候,竞争锁的writer
会等待这些reader
释放完,才可能持有这把锁。
rUnlockSlow
方法将持有锁的reader
计数减少1
(等待完成的reader
的数量readerWait
减1),会检查既有的 reader
是不是都已经释放了锁(readerWait
是否为0),如果都释放了锁,就会唤醒 writer
(runtime_Semrelease
方法),让 writer
持有锁。
3、Lock/Unlock 的实现
Lock
方法:
为了避免多个writer
竞争共享资源,RWMutex
内部使用Mutex
来实现writer
之间的互斥,保证并发安全。writer
通过调用Lock
方法来获取RWMutex
读写锁。
go
func (rw *RWMutex) Lock() {
// 首先解决其他writer竞争问题
rw.w.Lock()
// 反转readerCount,告诉reader有writer竞争锁
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 如果当前有reader持有锁,那么需要等待活跃的reader释放锁
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}
上述代码中:
- 在第3行代码中,
writer
调用RWMutex
中成员变量Mutex
的Lock
方法获取互斥锁。 writer
获取到互斥锁后,会第5行代码将readerCount
反转为负值,将原来的正整数readerCount(>=0)
修改为负数(readerCount - rwmutexMaxReaders
),让readerCount
保持两个含义,即保存了持有读锁的reader
个数,又表示当前有writer
获取到锁,之后将拿到的返回值在增加rwmutexMaxReaders
得到r
,用于后续的判断(相当于恢复readerCount
为正数,记录当前活跃的reader,用于后续的判断,但是实际readerCount
已反转为负数)。- 在第7行代码中,若当前已有活跃的
reader
是否全部都释放了锁(r != 0),以及判断目前是否还有正在等待锁的reader
(atomic.AddInt32(&rw.readerWait, r) != 0),若存在,则阻塞当前writer
。即如果readerCount
不是 0,就说明当前有持有读锁的reader
,RWMutex
需要把这个当前readerCount
赋值给readerWait
字段保存下来(第 7 行), 同时这个writer
进入阻塞等待状态(第 8 行)。 - 每当一个
reader
释放读锁的时候(调用RUnlock
方法时,具体可参考上述的rUnlockSlow
方法),readerWait
字段就减 1,直到所有的活跃的reader
都释放了读锁,才会唤醒这个writer
。
所谓活跃的reader
,就是指持有读锁还没有释放的那些reader
。
UnLock
方法:
go
func (rw *RWMutex) Unlock() {
// 将readerCount转回正数,即告知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()
}
当持有锁的writer
需要释放锁时,调用Unlock
方法。
- 在第3行代码中,将
readerCount
再次反转为正数,即告知reader
们已经没有writer
获取锁了。因为在writer
获取锁时,readerCount
通过减去常量rwmutexMaxReaders
反转为负数,因此再次反转为正数的方法为增加rwmutexMaxReaders
这个常数值。 - 在第6行中,通过循环调用
runtime_Semrelease
函数,使用信号量readerSem
唤醒阻塞的reader
- 唤醒
reader
后,释放内部的互斥锁Mutex
。
在Lock/Unlock
方法中,需要重点理解readerCount
这个字段的含义以及反转的方式,其次是字段的更改和内部互斥锁的顺序。在 Lock
方法中,是先获取内部互斥锁,才会修改的其他字段;在 Unlock
方法中,是先修改的其他字段,才会释放内部互斥锁,保证字段的修改也受到互斥锁的保护。
三、RWMutex使用注意事项
RWMutex
在使用上与Mutex
一样暴露的API简单便利,但是在实际使用时,容易犯一些错误。
1、RWMutex不可复制
RWMutex
与Mutex
类似,是由互斥锁和通过一系列记录状态的参数构成的结构体,包括信号量
、readerCount
等,本身互斥锁就不可复制,再加上一系列状态字段,RWMutex
就更不可能赋值使用。
原因在于一但读写锁被使用,它的字段就会记录当前的一些锁状态,此时复制该锁会将其当前的锁状态也一同赋值,而赋值出来的状态是难以预估的,并且原来的锁释放了,并不会修改复制出来的读写锁,从而导致状态不统一,可能锁永远无法释放。
可以借助 vet
工具,在变量赋值、函数传参、函数返回值、遍历数据、struct 初始化等时,检查是否有读写锁隐式复制的情景。
2、重入导致死锁
读写锁在发生重入或者递归调用的情况下,容易导致死锁。
第一种情况:
因为读写锁内部是基于互斥锁Mutex实现控制writer
的并发访问,而Mutex
本身有重入问题,因此在调用writer
重入调用 Lock
时,容易出现死锁现象。
go
func lockUser1(l *sync.RWMutex) {
fmt.Println("lock user1")
l.Lock()
lockUser2(l)
l.Unlock()
}
func lockUser2(l *sync.RWMutex) {
l.Lock()
fmt.Println("lock user2")
l.Unlock()
}
func main() {
lock := &sync.RWMutex{}
lockUser1(lock)
}
执行上述代码,会得到死锁的错误输出。
第二种情况:
当有reader
获取到读写锁时,writer会进行阻塞等待,如果此时reader
的读操作调用writer
的写操作,即调用Lock
方法,那么reader
和writer
会形成互相依赖的死锁状态。reader
会等待writer
完成后再释放锁,而writer
需要等待这个 reader
执行结束释放锁之后,才能不阻塞地继续执行。这是一个读写锁常见的死锁场景。
第三种情况:
当当一个 writer
请求锁时,如果已经有一部分活跃的reader
,此时writer
会等待这些活跃的reader
完成后才能获取到锁,但之后活跃的reader
如果在依赖新的reader
的话,这些新的reader
就会等待writer
释放锁后才能继续执行,从而就会形成一个环形依赖:writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer。
3、释放未加锁的RWMutex
与互斥锁相同,Lock
和Unlock
的调用总是成对出现的,RLock
和RUnlock
的调用也必须成对出现。
Lock
和RLock
多余的调用会导致锁没有被释放,可能会出现死锁,而Unlock
和RUnlock
多余的调用会导致panic
。
四、总结
在Go语言中,sync.RWMutex
(读写互斥锁)是一个用于并发控制的同步原语,它允许多个goroutine
同时读取共享资源,但在同一时间只允许一个goroutine
写入共享资源。这种锁的设计是为了在读取操作远多于写入操作的场景中提高性能。
如果实际业务场景需要解决的问题是一个 readers-writers
问题,那么就可以毫不犹豫地选择 RWMutex
,不用考虑其它选择。
在使用RWMutex
,需要尽量注意避免重入,重入带来的死锁非常隐蔽,而且难以诊断。
参考文章