sync.RWMutex 读写锁源码:写优先与无锁并发计数
sync.RWMutex 读写锁源码:写优先与无锁并发计数
一、核心概念与架构设计
读写锁的卖点是"读读共享、读写互斥",但它的实现里藏着一个更容易被忽视的承诺:写者不能被读者无限拖延。如果没有这个承诺,读请求源源不断时写锁可能永远拿不到(写饥饿)。sync.RWMutex 用一个非常巧妙的编码技巧同时实现这两件事:把 readerCount 减去一个大常数,用一个 int32 同时表达"当前有多少读者"和"是否有写者在排队"。
先给一个可能颠覆直觉的实测结论:读多写少的场景里,RWMutex 并不总是比 Mutex 快。本文第三节的可运行示例在 8 核机器上跑出过 Mutex 94ms vs RWMutex 110ms 的结果。原因在 2.3 节展开:RWMutex 的每次读锁都是一次原子 RMW(Read-Modify-Write)操作,8 个核同时自增同一个 readerCount 时,缓存行的所有权在 MESI 协议下来回弹跳,代价可能超过 Mutex 的一次 CAS 快速路径。RWMutex 的正确使用姿势是"读临界区足够长、写占比足够低、读者并发度可控",满足不了就老实用 Mutex 或分片。
二、深度原理与底层剖析
2.1 结构体:两个信号量加两个计数器
// 位于 sync/rwmutex.go(节选,Go 1.20+ 已改用 atomic 类型)
type RWMutex struct {
writerSem uint32 // 写者等待的信号量
readerSem uint32 // 读者等待的信号量
readerCount atomic.Int32 // 当前读者数;被写者减去 maxReaders 后变负
readerWait atomic.Int32 // 写者还需等待释放的读者数量
}
const rwmutexMaxReaders = 1 << 30 // 约 10.7 亿,读者计数的"哨兵基数"
readerCount 是全篇的灵魂。它的取值含义:
- 正值 r:当前有 r 个活跃读者,无人排队写。
- 负值 r - rwmutexMaxReaders:写者已就位。此刻的真实读者数等于
readerCount + rwmutexMaxReaders。
一次加 1<<30 的减法,就把"写者到场"这个事件广播给了所有后续读者。
2.2 读锁:快速路径只有一次原子自增
func (rw *RWMutex) RLock() {
// readerCount 自增后若为负,说明有写者已就位或正在等待,
// 新读者必须挂到 readerSem 上排队。
if rw.readerCount.Add(1) < 0 {
runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
}
}
func (rw *RWMutex) RUnlock() {
if r := rw.readerCount.Add(-1); r < 0 {
rw.rUnlockSlow(r) // 释放后发现是负值区间,走慢速路径
}
}
func (rw *RWMutex) rUnlockSlow(r int32) {
// r+1 == -rwmutexMaxReaders:本读者是"写者之前"的最后一批,
// 它的离开让 readerWait 归零,写者可以获准执行。
if rw.readerWait.Add(-1) == 0 {
runtime_Semrelease(&rw.writerSem, false, 0)
}
}
没有写者竞争时,RLock/RUnlock 的全部成本就是两次 atomic.Int32.Add。这两个 RMW 指令在单核上是廉价的,但在多核争抢下(x86 上是 LOCK XADD),每一次都要求持有缓存行的独占权,这正是 2.3 节性能反转的来源。
2.3 写锁:先挡住新读者,再等存量读者退场
func (rw *RWMutex) Lock() {
// 第一步:readerCount 减去哨兵基数。
// 减完后为负 => 后续所有 RLock 自增结果都 < 0,新读者全部被阻塞。
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
// r != 0 说明还有存量读者未退场。
// 把需要等待的读者数记入 readerWait,然后挂起等 writerSem。
if r != 0 && rw.readerWait.Add(r) != 0 {
runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
}
}
func (rw *RWMutex) Unlock() {
// 加回哨兵基数,恢复"读计数"语义。
// 返回值 r:写锁持锁期间被挡住、已在 readerSem 上排队的读者数量
r := rw.readerCount.Add(rwmutexMaxReaders)
if r != 0 {
// 有积压读者:先在 readerWait 账目中冲销这批数量,
// 随后释放 readerSem 唤醒积压读者。
// 真实实现通过 readerWait 的原子结算决定释放时机,
// 这里保留语义主干,完整代码见 sync/rwmutex.go。
rw.readerWait.Add(-r)
runtime_Semrelease(&rw.readerSem, false, 0)
}
}
写锁的两阶段设计值得细品。第一阶段只做一件事:把 readerCount 打成负数。这一步完成后的瞬间,"读写互斥"的新增部分已经成立,但存量读者还在跑,写者挂在 writerSem 上等待。第二阶段等的是 readerWait 归零,而 readerWait 的扣减发生在每个存量读者的 RUnlock 里(rUnlockSlow)。
写者排队期间新读者被挡、存量读者退场后写者立即执行,这两件事合起来就是写优先语义。它防的是写饥饿,代价是读吞吐:写者就位后哪怕只慢一步,后面排队的读者也会积压。
Unlock 释放 readerSem 的时机由 readerWait 的原子结算决定:只有当积压读者的账目全部冲销干净,才发信号放行。Semrelease 的 handoff 参数为 false,被唤醒的读者醒来后还要自己完成信号量账目结算,这是吞吐与公平的又一次取舍。
2.4 性能反转:为什么 RWMutex 可能比 Mutex 慢
把两把锁的读路径成本放在一起对比:
| 操作 | Mutex 快速路径 | RWMutex 快速路径 |
|---|---|---|
| 读临界区进入 | CAS state(多数成功) |
LOCK XADD readerCount+1 |
| 读临界区退出 | 无(Unlock 才有代价) | LOCK XADD readerCount-1 |
| 多核争抢代价 | 缓存行在竞争者间弹跳 | 同样弹跳,且两次 RMW |
Mutex 的 Lock/Unlock 在无竞争时一共约 15ns;RWMutex 的读路径是两次总线级原子操作,8 核同时自增时,readerCount 所在缓存行在 MESI 的 M/E/S 状态间高频切换,每次切换都要跨核同步。临界区越短,这部分固定开销占比越高。实测数据见第三节:8 并发、20 万次短临界区读、1% 写的负载下,RWMutex 耗时反而多 17%。
所以 RWMutex 的适用判据不是"读多不多",而是"读临界区做的事是否值回两次原子 RMW 的票价"。锁内拷贝一个大结构、查一个复杂索引、拼一个响应体,值得;自增一个计数器,不值得。
三、完整可运行示例
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// 演示一:写锁就位后,新读锁必须排队(防止写饥饿)。
func writePriority() {
var rw sync.RWMutex
rw.RLock() // 读者 A 先持读锁
writerDone := make(chan struct{})
go func() {
rw.Lock() // 写锁尝试获锁:readerCount 被减为负数,此后新读锁全部被挡住
fmt.Println(" writer 获得写锁")
time.Sleep(50 * time.Millisecond)
rw.Unlock()
close(writerDone)
}()
newReaderDone := make(chan struct{})
go func() {
time.Sleep(10 * time.Millisecond) // 确保写锁先就位再入队
start := time.Now()
rw.RLock() // 这个新读锁会被 readerWait 阻断,直到写锁释放
fmt.Printf(" 新读锁等待了 %v 才获锁(写优先语义生效)\n", time.Since(start).Round(time.Millisecond))
rw.RUnlock()
close(newReaderDone)
}()
time.Sleep(10 * time.Millisecond)
rw.RUnlock() // 读者 A 释放,写锁随即获准执行
<-writerDone
<-newReaderDone
}
// 演示二:读多写少(99:1)场景下的吞吐对比。
func bench(ratio int, useRWMutex bool) int64 {
var (
mu sync.Mutex
rw sync.RWMutex
cnt int64
)
lockRead := func() {
if useRWMutex {
rw.RLock()
} else {
mu.Lock()
}
}
unlockRead := func() {
if useRWMutex {
rw.RUnlock()
} else {
mu.Unlock()
}
}
const workers = 8
const iters = 200000
var wg sync.WaitGroup
start := time.Now()
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < iters; i++ {
if i%100 == 0 { // 1% 写操作
if useRWMutex {
rw.Lock()
} else {
mu.Lock()
}
cnt++
if useRWMutex {
rw.Unlock()
} else {
mu.Unlock()
}
} else {
lockRead()
_ = atomic.LoadInt64(&cnt) // 模拟读临界区
unlockRead()
}
}
}()
}
wg.Wait()
return time.Since(start).Milliseconds()
}
func main() {
fmt.Println("== 演示一:写优先语义 ==")
writePriority()
fmt.Println("== 演示二:读多写少吞吐(8 并发 x 200k 迭代,1% 写) ==")
fmt.Printf(" sync.Mutex 耗时: %d ms\n", bench(99, false))
fmt.Printf(" sync.RWMutex 耗时: %d ms\n", bench(99, true))
}
跑一次的典型输出:
== 演示一:写优先语义 ==
writer 获得写锁
新读锁等待了 51ms 才获锁(写优先语义生效)
== 演示二:读多写少吞吐(8 并发 x 200k 迭代,1% 写) ==
sync.Mutex 耗时: 94 ms
sync.RWMutex 耗时: 110 ms
三个观察点:
- 演示一里新读锁等待了约 51ms,等于写者的持锁时长 50ms 加调度余量。这期间读者队列在积压,如果写者持锁 500ms,积压会线性放大,这正是"写持锁时间要短"在 RWMutex 语境下的具体含义。
- 演示二的反转结果因机器而异,核数越多、读临界区越短,反转越明显。把
_ = atomic.LoadInt64(&cnt)换成time.Sleep(10 * time.Microsecond)模拟长临界区再跑,RWMutex 的优势就会显现。 ratio参数当前写死为 1% 写,可以改成 5%、20% 观察 crossover 点在哪里,比背结论有效。
四、生产踩坑与调优建议
1. 递归 RLock 是死锁高发区。 经典事故代码:外层函数 RLock,内部调用另一个也 RLock 的函数。单看每处都合法,但当写者在两者之间就位时,内层 RLock 挂起、外层 RUnlock 永远执行不到,写者也在等外层读者退场,三方死锁,runtime 直接报 all goroutines are asleep - deadlock!。团队规范上建议:RWMutex 的读锁只在叶子函数出现,调用链上禁止叠加。
2. 写锁就位后读锁积压,监控上表现为读延迟尖刺。 写优先保护了写者,牺牲的是"写者就位瞬间"的那批读者。写持锁 100ms 的接口,在 p99 上常表现为周期性的 +100ms 尖刺。定位手段:go tool trace 里看 G 的阻塞段是否与某写者的持锁区间重合。缓解:缩短写临界区,或把热点读数据换成 atomic.Pointer 快照(Copy-on-Write),彻底绕开读锁。
3. RLock/RUnlock 不配对会永久泄漏读写计数。 RUnlock 比 Unlock 更容易漏写,因为它不报错,只是让 readerCount 多 1。后果是后续写者永远等不到 readerWait 归零,整个锁实际上被一个幽灵读者冻结。写法上同样强制 defer rw.RUnlock() 配对。
4. 写者之间的接力是有序的。 Unlock 会优先唤醒等待中的下一个写者,所以写者不会互相饥饿。但如果你的负载是"偶发写 + 洪峰读",写者唤醒的瞬间会挡住整个读洪峰,这一段就是可观测的读延迟毛刺。若业务能容忍毫秒级数据陈旧,把写路径改成后台单写者定期重建快照,比现场加写锁平滑得多。
5. 更细粒度的替代方案要先看争用模式。 分片锁(按 key 哈希拆成 N 把 RWMutex)适合 key 均匀、无跨片操作的负载;atomic.Pointer[T] 快照适合读极多、写极少、数据整体可重建的配置类数据;sync.Map 适合写后只读的缓存。RWMutex 本身没有错,错的是拿它硬扛"短临界区 + 高核数 + 写不稀少"的负载。
五、总结
RWMutex 用一个带哨兵基数的 readerCount 同时编码了读者计数与写者到场事件:写锁先置负数挡住新读者,再用 readerWait 精确等待存量读者退场,实现读读共享与写优先的统一。它的读路径是两次原子 RMW,多核短临界区下未必跑得赢 Mutex,选型依据是临界区长度与写占比,而不是读写的表面比例。

浙公网安备 33010602011771号