在Go语言的高并发编程中,sync.RWMutex(读写互斥锁)是sync.Mutex的一个重要改进。它在某些特定场景下能提供更精细、更高效的并发控制能力,尤其适用于读取数据频率远远大于写入数据频率的场景。
1. 读写锁要解决的并发问题
读写锁的设计旨在协调对共享资源的读写访问,遵循以下四条核心规则:
- 写锁需要阻塞写锁:当一个协程持有写锁时,其他任何尝试获取写锁的协程都必须阻塞等待。
- 写锁需要阻塞读锁:当一个协程持有写锁时,其他任何尝试获取读锁的协程也必须阻塞等待。
- 读锁需要阻塞写锁:当一个或多个协程持有读锁时,任何尝试获取写锁的协程必须阻塞等待,直到所有读锁被释放。
- 读锁不能阻塞读锁:当一个协程持有读锁时,其他协程可以继续获取读锁,实现并行读取。
2. 读写锁的数据结构
让我们先看一下 sync.RWMutex 在Go标准库中的数据结构定义:
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 atomic.Int32 // number of pending readers
readerWait atomic.Int32 // number of departing readers
}
各字段的作用如下:
w: 一个基础的互斥锁,用于在多个写操作之间实现互斥。要获得写锁,首先必须获取此锁。
writerSem: 写操作阻塞等待的信号量。当最后一个持有读锁的协程释放锁时,会释放此信号量来唤醒等待的写协程。
readerSem: 读操作阻塞等待的信号量。当持有写锁的协程释放锁时,会释放此信号量来唤醒所有因等待写操作而阻塞的读协程。
readerCount: 记录当前正在进行或等待的读者数量。
readerWait: 记录在写操作被阻塞时,尚未完成的读者数量。
从结构可以看出,RWMutex 内部依然包含一个基础的Mutex,用于隔离多个写操作;其他字段则协同工作,用于高效地隔离读操作和写操作。理解这些底层结构是掌握Go并发编程高级特性的关键。
3. 接口定义
RWMutex 提供了以下主要方法:
RLock(): 获取读锁。
RUnlock(): 释放读锁。
Lock(): 获取写锁,其语义与 Mutex.Lock() 完全一致。
Unlock(): 释放写锁,其语义与 Mutex.Unlock() 完全一致。
TryLock(): 尝试以非阻塞方式获取写锁(Go 1.18 引入)。
TryRLock(): 尝试以非阻塞方式获取读锁(Go 1.18 引入)。
4. Lock() 实现逻辑:获取写锁
写锁(Lock)的获取过程分为两步:
- 获取内部的互斥锁
w,以排斥其他写者。
- 阻塞等待所有已存在的读操作完成(如果有的话)。
其核心源码如下:
// 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 {
race.Read(unsafe.Pointer(&rw.w))
race.Disable()
}
// First, resolve competition with other writers.
rw.w.Lock()
// Announce to readers there is a pending writer.
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for active readers.
if r != 0 && rw.readerWait.Add(r) != 0 {
runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem))
race.Acquire(unsafe.Pointer(&rw.writerSem))
}
}
获取写锁的核心在于,它通过将 readerCount 减去一个极大值(rwmutexMaxReaders,即 1 << 30),使其变为负值,从而向后续的读操作宣告“有写者在等待”。如果此时存在活跃读者(r != 0),写者会将自己记录在 readerWait 中,并在 writerSem 信号量上休眠,等待被最后一个读者唤醒。

图:写锁获取流程。先竞争互斥锁w,然后判断是否有活跃读者。若有,则等待;若无,则直接获得写锁。
5. Unlock() 实现逻辑:释放写锁
释放写锁(Unlock)的过程是获取的逆过程:
- 唤醒所有因为等待本写锁而阻塞的读协程(如果有)。
- 释放内部的互斥锁
w,允许其他写者竞争。
func (rw *RWMutex) Unlock() {
if race.Enabled {
race.Read(unsafe.Pointer(&rw.w))
race.Release(unsafe.Pointer(&rw.readerSem))
race.Disable()
}
// Announce to readers there is no active writer.
r := rw.readerCount.Add(rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
race.Enable()
fatal("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 恢复为正数(加上 rwmutexMaxReaders),这向新来的读操作宣告“写者已离开”。恢复后的 r 代表了在写锁持有期间累积的、等待的读者数量。写锁通过循环释放 readerSem 信号量,一次性唤醒所有这些等待的读者。最后,释放互斥锁 w。

图:写锁释放流程。先恢复readerCount为正,然后唤醒所有等待的读者,最后释放互斥锁。
6. RLock() 实现逻辑:获取读锁
获取读锁(RLock)的逻辑相对直接:
- 增加读者计数
readerCount。
- 如果增加后
readerCount 为负(说明有写者在等待或持有锁),则当前读者在 readerSem 信号量上阻塞,等待写锁释放。
func (rw *RWMutex) RLock() {
if race.Enabled {
race.Read(unsafe.Pointer(&rw.w))
race.Disable()
}
if rw.readerCount.Add(1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem))
}
}

图:读锁获取流程。增加读者计数,并检查是否有写锁(已持有或正在等待)。若有,则阻塞;若无,则成功获得读锁。
7. RUnlock() 实现逻辑:释放读锁
释放读锁(RUnlock)时:
- 减少读者计数
readerCount。
- 如果当前读者是最后一个离开的读者(在存在写者等待的情况下),则负责唤醒那个等待的写者。
func (rw *RWMutex) RUnlock() {
if race.Enabled {
race.Read(unsafe.Pointer(&rw.w))
race.ReleaseMerge(unsafe.Pointer(&rw.writerSem))
race.Disable()
}
if r := rw.readerCount.Add(-1); r < 0 {
// Outlined slow-path to allow the fast-path to be inlined
rw.rUnlockSlow(r)
}
if race.Enabled {
race.Enable()
}
}
慢路径 rUnlockSlow 负责处理有写者等待的情况。关键点在于:即使有写协程在等待,也并非每个读锁释放都会去唤醒它。只有最后一个释放读锁的协程(即 readerWait 递减到0时)才会释放 writerSem 信号量来唤醒等待的写协程。因为写锁必须等待所有读操作都结束后才能安全执行。

图:读锁释放流程。减少读者计数,并判断当前读者是否为最后一个且存在写等待。若是,则唤醒写者;若否,则直接结束。
8. 核心机制场景分析
理解了上述接口实现后,我们可以更深入地分析其机制。
1. 写操作如何阻止其他写操作?
RWMutex 内嵌了一个 Mutex (w)。任何写操作想要获得锁,都必须先获取这个互斥锁。如果它已被协程A获取(或者A正因等待读者而阻塞),那么协程B在尝试调用 Lock() 时,会在 rw.w.Lock() 这一步被阻塞。因此,写操作通过底层的互斥锁来实现彼此之间的互斥。
2. 写操作如何阻止读操作?
readerCount 是一个整数原子变量。没有写操作时,它表示活跃的读者数量。
当写操作调用 Lock() 时,会执行 rw.readerCount.Add(-rwmutexMaxReaders),将 readerCount 减去一个非常大的数(1<<30),使其变为负值。后续的读操作在 RLock() 中执行 rw.readerCount.Add(1) 后,会发现结果仍然小于0,从而知道自己需要等待(有写者存在),于是进入休眠。因此,写操作是通过将 readerCount 置为负值来阻止后续读操作的。真实的读者数量信息并未丢失,可以通过加上 rwmutexMaxReaders 还原。
3. 读操作如何阻止写操作?
读操作在 RLock() 中会将 readerCount 加1。写操作在获取了内部互斥锁 w 后,会检查 readerCount 在调整为负数前所代表的活跃读者数量(即代码中的 r)。如果 r != 0,说明存在活跃读者,写操作就会将自己记录到 readerWait 中并开始等待。因此,读操作是通过维持一个正的 readerCount 来阻止写操作的。
4. 为什么写锁定不会被饿死?
这是一个关键设计。如果写操作必须等待所有现有的和未来可能到来的读操作结束,那么在读多写少的场景下,写操作可能会被无限期推迟(饿死)。RWMutex 通过 readerWait 字段完美解决了这个问题。
- 当写操作到来时,它会将此刻的
readerCount 值(即当前活跃读者数)拷贝到 readerWait 中。这标记了排在写操作前面的读者个数。
- 之后,每个前面持有读锁的协程在释放锁 (
RUnlock) 时,除了减少 readerCount,还会减少 readerWait。
- 只有当
readerWait 减少到 0 时(意味着所有在写操作之前就开始的读操作都结束了),最后一个读协程才会唤醒等待的写操作。
- 在此期间新到来的读操作(在写操作之后调用
RLock)会发现 readerCount 为负,从而被阻塞,必须等待这个写操作完成。这就保证了写操作不会被源源不断的新读请求饿死。
通过对 sync.RWMutex 从接口到源码,再到场景的层层剖析,我们可以清晰地看到它是如何精巧地平衡读写的并发性,并在保证数据一致性的前提下,极大提升读多写少场景的性能。希望这份解析能帮助你在实际的Go并发项目中更自信地运用这把利器。如果你想与其他开发者交流更多关于后端架构与高并发的设计经验,欢迎来云栈社区参与讨论。