MongoDB 自研读写互斥锁深度解析:WriteRarelyRWMutex 与 RWMutex 的实现原理与选型指南
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
MongoDB 在 docs/rwmutex.md 中正式介绍了两种自研的读写互斥锁类型——WriteRarelyRWMutex与RWMutex。它们是专为 MongoDB 服务器并发场景量身定制的低开销同步原语,通过利用特定用例的并发语义换取极致的性能。本文将以该文档为核心骨架,结合 rwmutex.h、rwmutex.cpp 等源码实现与测试、基准代码,深入剖析这两种锁的内部机制、适用场景与使用约束,帮助开发者在自己的并发代码中做出正确的选型决策。
设计背景:为什么 MongoDB 需要自研读写锁
MongoDB 服务器是典型的高并发多线程程序,大量工作线程会同时访问共享配置、统计信息等数据。标准库提供的std::mutex与std::shared_mutex虽然通用,但未必能在特定访问模式下达到最优性能。文档明确指出:这两种锁是"专业化的内部共享互斥类型"(specialized in-house shared mutex types),目的是利用用例特定的并发语义提供低开销同步。
因此,文档给出了明确的采纳纪律:
Make sure to adopt these primitives only if your use-case exactly matches the requirements listed below, or consult with the Server Programmability team.
也就是说,只有在使用场景与文档列出的要求完全匹配时才应采纳;否则应优先使用标准库替代方案。这也是阅读本文时需要牢记的第一原则:性能原语必须服务于真实场景,而非为了炫技。
WriteRarelyRWMutex:为"频繁读、几乎不写"设计的锁
WriteRarelyRWMutex是一种假设读操作极其频繁、写操作几乎不发生的读写互斥锁。写者可以独占锁定该互斥锁,但这被视为非常罕见的例外情况。
底层机制:类 hazard pointer 设计
文档描述其底层原理"与 hazard pointer 非常相似",具体包含两个核心要素:
- 每线程列表(per-thread lists):每个线程维护一个列表,用于记录它当前持有的共享(读)锁;
- 写者扫描阻塞(writer scanning):写者需要遍历这些每线程列表,并阻塞等待,直到互斥锁不再被任何列表引用。
这种设计的直接收益体现在 rwmutex.h 的类注释中:
The primary advantage of this over existing synchronization types is that in absence of writes, the cost of acquiring shared locks is constant, regardless of the number of CPU cores/sockets.
在无写者竞争的常态下,读锁获取成本是常量,与 CPU 核心/插槽数量无关——这正是它能够线性扩展的核心原因。
源码级实现剖析
让我们深入 rwmutex.cpp 看它的具体实现。
1. 全局锁条目注册表(LockEntryRegistry)
全局唯一的 LockEntryRegistry 负责分配与管理锁条目(LockEntry)。它的设计要点:
LockEntry使用alignas(64)对齐,恰好占据一个缓存行,避免伪共享(false sharing)——见 LockEntry 定义;- 每个
LockEntry内部是一个WaitableAtomic<WriteRarelyRWMutex*>,指向线程当前持有的读锁; - 条目按 4096 字节的
MemoryBlock批量分配(kMemBlockSize = 4096),通过_blockHoldersHead单链表持有,按需分配、永不释放(除测试场景),保证内存地址稳定; checkout()/release()通过一个空闲链表(_freeList)管理条目的借用与归还,并带有_checkedOut调试计数器。
2. 线程本地锁句柄
每个线程通过thread_local LockEntryHandle myLockHandle与thread_local LockEntry* myLockEntry维护自己的锁条目,首次需要时由 setupThreadLockEntry() 惰性初始化。注释还提到,当前简化设计每个线程最多同时持有一个读锁,后续可以通过单个WaitableAtomic扩展支持任意数量的读锁。
3. 读锁获取(_lock_shared)
见 rwmutex.cpp 的 _lock_shared 实现:
- 取出线程本地锁条目,将当前互斥锁指针写入
entry(entry.store(this)); - 检查写标志
_writeFlag——若没有写者,读锁即成功,整个路径不涉及任何原子扫描; - 若发现写标志已设置(说明有写者在等待),则调用
_releaseSharedLockAndWaitForWriter()先释放共享锁,再阻塞在_writeFlag.wait(kRaisedWriteFlag)上等待写者完成,随后重试。
这里的store建立获取(acquire)序,_writeFlag的读取则保证了"当前线程加入本地列表后,任何未来的写者都会观察到这次共享锁获取"。
4. 写锁获取(_lock)
见 rwmutex.cpp 的 _lock 实现:
- 先获取
_writeMutex(一个普通std::mutex),保证同一时刻只有一个写者; - 设置
_writeFlag = kRaisedWriteFlag(值 1),此后新读者都会看到写意图并放弃读锁; - 通过
globalLockRegistry().visitLocks(...)遍历全局所有线程的锁条目,若某条目正指向当前互斥锁(entry.load() == this),则在该条目上wait(this)阻塞,等待对应读者释放并唤醒。
写锁代价随线程数线性增长的根源就在这里:每增加一个线程,写者就要多扫描一个(可能)持有读锁的条目。文档给出的数量级参考是:读锁仅需数十纳秒,而写锁可能高达数百微秒。
5. 释放路径
_unlock_shared(见 rwmutex.cpp#L226-L237):将条目置空,若此时_writeFlag已设置,则调用entry.notifyAll()唤醒正在等待该条目的写者;_unlock(见 rwmutex.cpp#L188-L193):清除写标志并notifyAll(),最后释放_writeMutex。
使用姿势:RAII 风格的 ScopedLock
WriteRarelyRWMutex没有暴露裸的lock()/unlock(),而是通过模板化的 ScopedLock 提供 RAII 封装,并预定义了两种别名:
using ReadLock = ScopedLock<false>; using WriteLock = ScopedLock<true>;典型用法:
WriteRarelyRWMutex rwMutex; // 读路径:极廉价,数十纳秒级 { auto readLock = rwMutex.readLock(); // 读取共享数据... } // 写路径:昂贵但罕见 { auto writeLock = rwMutex.writeLock(); // 修改共享数据... }ScopedLock支持移动语义(ScopedLock(ScopedLock&&))、owns_lock()查询,并禁止拷贝。它的析构会自动释放锁,异常安全。
实测验证:测试与基准
单元测试rwmutex_test.cpp 覆盖了核心语义:
ReadersDoNotBlockOnOtherReaders(rwmutex_test.cpp#L41-L56):验证读者之间完全互不阻塞——8 个读者同时持锁,若任何一个读者被其他读者阻塞,测试将因屏障超时而挂起;WriterWaitsForReaders(rwmutex_test.cpp#L58-L83):验证写者必须等待活动读者退出;NewReadersWaitForWrite(rwmutex_test.cpp#L85-L112):验证写标志一旦设置,新读者会阻塞等待;MultiReaderAndSingleWriter(rwmutex_test.cpp#L114-L168):以斐波那契数列生产/消费模型模拟多读者单写者场景,读者校验序列合法性;MultiWriter(rwmutex_test.cpp#L170-L205):验证任意时刻最多一个写者进入临界区。
基准测试rwmutex_bm.cpp 直接对比了四种同步原语在纯读场景下的吞吐(rwmutex_bm.cpp#L31-L101):WriteRarelyRWMutex、std::shared_mutex、std::mutex、RWMutex,线程范围从 1 到逻辑核心数的两倍(kMaxThreads = ProcessInfo::getNumLogicalCores() * 2),并提供了专门的写锁压力测试RWMutexStressBm,其线程数最高可达 20000、读者数最高 64(rwmutex_bm.cpp#L276-L298)。这套基准为"无写时读锁开销恒定"的设计目标提供了量化验证手段。
典型生产用例:复制集配置
文档特别举例"replication configuration"(复制配置)正是WriteRarelyRWMutex的理想场景。在真实代码中可以找到印证——replication_coordinator_impl.h 中复制协调器用其保护复制集配置:
mutable VersionedValue<ReplSetConfig, WriteRarelyRWMutex> _rsConfig; // (S)复制集配置在运行期几乎从不修改(仅在 reconfig、主节点选举等少数路径写入),但每个心跳、每个复制相关操作都会高频读取——这正是"频繁读、几乎不写"的教科书式场景。
适用边界
文档给出明确警告:当写操作不是例外、可能频繁发生时,不要使用WriteRarelyRWMutex。因为写锁成本随线程数线性增长,如果写频繁,累加开销会迅速吞噬读锁节省的性能。此外,从实现看它还有两个隐含约束:
- 同一线程当前最多持有一个读锁(
_lock_shared中invariant(entry.loadRelaxed() == nullptr, ...)会直接断言拒绝重入); - 该类对齐到 64 字节(
class alignas(64) WriteRarelyRWMutex,见 rwmutex.h#L117),意在让互斥锁自身独占缓存行,避免与邻近数据互相污染。
RWMutex:面向"频繁读、偶尔写"的计数器式读写锁
RWMutex是另一种读写互斥锁,针对频繁读取、偶尔写入的场景优化。相比WriteRarelyRWMutex,它的读操作更昂贵、扩展性稍差,但换来了"偶尔写"场景下更低的开销。
底层机制:写意图位 + 读者计数器
文档描述其底层"模拟了一个记录活动读者数量的计数器":
- 写者(writer)等待所有读者退出,并通过设置**写意图(write intent)**来阻止新读者进入;
- 读者(reader)递增计数器进入,递减计数器退出;当看到写意图位被设置时,撤回自己的读意图并等待。
源码 rwmutex.h#L24-L108 中的状态字设计非常精巧,单个uint32_t同时编码三部分信息:
| 位域 | 含义 |
|---|---|
| Bits [0..29] | 读者数量,最多支持 2^30 − 1 个并发读者 |
| Bit 30 | 必须保持为零,作为读者溢出保护(kReadersOverflowMask) |
| Bit 31 | 写意图标志(kWriteIntentMask) |
源码级实现剖析
写锁获取lock()(rwmutex.h#L31-L39):
void lock() { _writeMutex.lock(); // 同一时刻仅一个写者 auto state = _state.fetchAndBitOr(kWriteIntentMask) | kWriteIntentMask; while (state & kReadersCountMask) { // 等待所有读者退出 state = _state.wait(state); } }先通过_writeMutex(普通std::mutex)串行化写者,再原子地置位写意图,然后循环等待读者计数归零。期间任何新读者都会注意到写意图并撤回。
读锁获取lock_shared()(rwmutex.h#L47-L53):
void lock_shared() { if (auto state = _state.addAndFetch(1); MONGO_unlikely(_hasPendingWriterOrTooManyReaders(state))) { _waitAndThenLock(state); // 有写者等待,或读者溢出 } }正常路径只是一个原子自增——这正是它比WriteRarelyRWMutex读路径更重的少数操作之一。只有当state命中写意图位或读者溢出位时才走慢路径 _waitAndThenLock:先撤回读意图,等待写意图清除,再重试递增。
读锁释放unlock_shared()(rwmutex.h#L55-L60):
void unlock_shared() { if (MONGO_unlikely(_state.subtractAndFetch(1) == kWriteIntentMask)) { _state.notifyAll(); // 这是最后一个读者,且有写者等待,唤醒之 } }写锁释放unlock()(rwmutex.h#L41-L45):清除写意图并notifyAll()唤醒所有等待者,最后释放_writeMutex。
公平性缺陷:读者可能被饿死
头文件注释(rwmutex.h#L15-L23)明确指出一个关键特性:
This type is not fair towards readers, as back-to-back writes may starve reads.
该类型对读者不公平——连续的写者可能饿死读者。因此它不适合在紧循环中以独占模式反复获取互斥锁的场景。同时注释强调:RWMutex不可中断(not interruptible),语义上与std::shared_mutex类似,采用前必须仔细审查代码,确认同步模式确实匹配。
测试验证
rwmutex_test.cpp 对RWMutex的验证同样完备:
OneWriterAtAnyTime(rwmutex_test.cpp#L207-L228):任何时刻只有一个写者;WriterWaitsForReader(rwmutex_test.cpp#L230-L251):写者等待活动读者退出后才进入;NewReaderWaitsForWriter(rwmutex_test.cpp#L253-L272):新读者在写意图存在时阻塞;TooManyReaders(rwmutex_test.cpp#L274-L279):用 death test 验证读者数量超出上限会触发 invariant 断言;MultipleReadersAndWriters(rwmutex_test.cpp#L295-L340):8 个工作线程、500 万次迭代的压力测试,读写按全局序交错,验证读写互斥不变量。
测试还提供了setWriteIntent_forTest、isWriteIntentSet_forTest、addReaders_forTest、hasWaiters_forTest、getReadersCount_forTest等友元测试钩子(rwmutex.h#L62-L81),用于白盒验证内部状态。
两种锁的对比与选型决策
| 维度 | WriteRarelyRWMutex | RWMutex |
|---|---|---|
| 目标场景 | 频繁读、几乎不写 | 频繁读、偶尔写 |
| 底层机制 | 类 hazard pointer:每线程读锁列表 + 写者扫描 | 计数器:读者计数 + 写意图位 |
| 读锁成本 | 极低(数十纳秒),无写时恒定,与核心数无关 | 较低,原子自增,可线性扩展 |
| 写锁成本 | 高(数百微秒),随线程数线性增长 | 中等,等待读者计数归零 |
| 公平性 | — | 对读者不公平,连续写可能饿死读者 |
| 额外约束 | 每线程同时最多一个读锁 | 最多 2^30 − 1 读者,不可中断 |
选型决策树:
- 几乎全是读、写是例外(如复制集配置)→ 优先
WriteRarelyRWMutex; - 读多写少但写会规律性发生→ 考虑
RWMutex; - 其他情况→ 文档明确建议:除非有必须达成的性能预算,并有强证据证明使用
RWMutex能帮助达标,否则优先使用标准库的std::shared_mutex与std::mutex。原话如下:
This type could outperform
std::shared_mutexandstd::mutexfor specific use cases, therefore, prefer using the alternatives from standard library unless there is a required performance budget to meet, as well as strong evidence that usingRWMutexhelps with meeting those performance requirements.
与 WithLock 的类型安全协作
MongoDB 还提供了一个与锁协作的类型安全机制WithLock(with_lock.h)。它本身不提供同步,而是一种**"必须持锁调用"的编译期证明**:将必须持锁才能调用的函数参数从裸指针/注释约定,升级为WithLock类型,从而把"必须持锁"这一约束从约定变成类型系统的一部分。
值得注意的细节是,WithLock的构造函数专门为WriteRarelyRWMutex::ScopedLock提供了重载(with_lock.h#L55-L58),并断言锁确实被持有(invariant(lock.owns_lock()))。这意味着WriteRarelyRWMutex的 RAII 锁可以直接作为WithLock参数传递,享受同样的编译期安全检查。这也解释了为何 rwmutex.cpp 内部的_refill(WithLock)等函数能安全使用这种证明模式。
结语:何时动手,何时止步
MongoDB 的WriteRarelyRWMutex与RWMutex是深入理解"按需定制同步原语"的绝佳范例:前者用每线程列表把读锁开销压到极限、把代价转移给罕见的写者;后者用单个原子字编码读者计数与写意图,在读写之间取得平衡。二者的源码(rwmutex.h、rwmutex.cpp)短小精悍,测试(rwmutex_test.cpp)与基准(rwmutex_bm.cpp)完备,非常适合作为学习无锁/低锁竞争并发编程的参考读物。
但正如文档反复强调的:只有当使用场景与设计假设精确匹配时才采纳这些原语,否则标准库方案永远是最稳妥的默认选择。性能优化的第一课,是知道什么时候不需要优化。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考