☰
oneTBB queuing_rw_mutex 全面解析:公平排队读写锁的实现原理与实战用法
2026/10/10 1:39:42 网站建设 项目流程
  • 并发编程
  • 高性能计算

【免费下载链接】oneTBB

oneAPI Threading Building Blocks (oneTBB)

项目地址:https://gitcode.com/gh_mirrors/on/oneTBB
点击查看免费下载

导读:queuing_rw_mutex是 oneAPI Threading Building Blocks(oneTBB)提供的一种公平(fair)读写锁(reader-writer mutex),线程严格按照请求顺序获取锁,且支持读锁共享、写锁独占、读锁升级与写锁降级。本文以官方参考文档 queuing_rw_mutex_cls.rst 为骨架,结合 头文件 与 核心实现 的源码证据,带你理解它的接口契约、公平性来源、排队算法以及如何在多线程程序中正确选用。读完你将掌握其完整 API、scoped_lock 用法、升级/降级语义,并能判断它与spin_rw_mutex、rw_mutex的适用场景差异。

一、什么是 queuing_rw_mutex

queuing_rw_mutex是 oneTBB 同步原语家族中的一员,声明于 include/oneapi/tbb/queuing_rw_mutex.h,定义在命名空间oneapi::tbb中。按官方参考文档的定义,它是一个模型化ReaderWriterMutex 需求(requirement)概念的类,也就是说它遵循 oneTBB 为读写锁定义的一套统一接口契约,该契约的完整描述见 named_requirements/mutexes/rw_mutex.rst。

官方文档对它的核心定性只有两点,但这两点决定了它的全部行为特征:

  • 非递归(not recursive):同一个线程不能对同一把queuing_rw_mutex重复加锁,否则会死锁或触发未定义行为。
  • 公平(fair):线程按照请求锁的先后顺序获取锁。这是它区别于 oneTBB 中其他读写锁(如spin_rw_mutex、rw_mutex)最关键的属性——等待线程不会因为后来者抢占而饿死。

在源码层面,这些语义通过三个编译期常量(traits)固化在类中,见 include/oneapi/tbb/queuing_rw_mutex.h:

// Mutex traits static constexpr bool is_rw_mutex = true; // 是读写锁 static constexpr bool is_recursive_mutex = false; // 不可重入 static constexpr bool is_fair_mutex = true; // 公平

这三个 traits 并非装饰品。oneTBB 的泛型算法与测试会通过它们静态地选择加锁策略、判断锁的性质。例如在 test/tbb/test_mutex.cpp 中,queuing_rw_mutex与spin_rw_mutex、rw_mutex、null_rw_mutex等一起被纳入读写锁的通用测试套件,正是依赖这些 traits 保证接口一致。

二、ReaderWriterMutex 需求:queuing_rw_mutex 的接口契约

要真正用好queuing_rw_mutex,先要理解它所满足的ReaderWriterMutex需求。该需求在 rw_mutex.rst 中定义,它扩展了一般的 Mutex 需求,引入一个布尔参数write来区分锁的类型:

  • write = true:请求写锁(writer lock),写锁与所有其他锁互斥,持锁期间其他线程无法持有任何锁;
  • write = false:请求读锁(reader lock),只要当前没有写锁,多个线程可以同时持有读锁。

需求中定义的scoped_lock接口原型如下(节选自 rw_mutex.rst):

class RWM { class scoped_lock { public: constexpr scoped_lock() noexcept; scoped_lock(RWM& m, bool write = true); ~scoped_lock(); scoped_lock(const scoped_lock&) = delete; scoped_lock& operator=(const scoped_lock&) = delete; void acquire(RWM& m, bool write = true); bool try_acquire(RWM& m, bool write = true); void release(); bool upgrade_to_writer(); bool downgrade_to_reader(); }; };

对照 queuing_rw_mutex.h 中scoped_lock的实际成员,可以确认它完整实现了这份契约:acquire(m, write)、try_acquire(m, write)、release()、upgrade_to_writer()、downgrade_to_reader(),另外还额外提供一个is_writer()查询当前是否持有写锁。

关于这些成员函数的语义,官方需求文档给出了精确的规定:

成员函数语义
scoped_lock()构造一个未持有任何锁的锁对象
scoped_lock(RWM&, bool write = true)构造并获取锁,write为 true 取写锁,否则取读锁
~scoped_lock()释放持有的锁(若已持有)
acquire(RWM&, bool write = true)获取锁,write决定是写锁还是读锁
try_acquire(RWM&, bool write = true)非阻塞尝试获取锁,成功返回 true,失败返回 false
release()释放锁;若未持有锁则行为未定义
upgrade_to_writer()将读锁升级为写锁;若锁被释放后重新获取则返回 false,否则返回 true(已是写锁时也返回 true)
downgrade_to_reader()将写锁降级为读锁;若锁被释放后重新获取则返回 false,否则返回 true(已是读锁时也返回 true)

需求文档还特别提示了一个实现层面的细节:对于 oneTBB 当前提供的所有读写锁,downgrade_to_reader()总是返回true,但需求本身并不强制其他实现必须如此。

2.1 各读写锁的公平性与可重入性对比

rw_mutex.rst 给出一张对照表,是选择锁类型的第一手依据:

锁类型Fair(公平)Reentrant(可重入)
rw_mutexNoNo
spin_rw_mutexNoNo
speculative_spin_rw_mutexNoNo
queuing_rw_mutexYesNo
null_rw_mutexYesYes

可以看到,在 oneTBB 的读写锁家族中,queuing_rw_mutex是唯一“公平且不可重入”的实用读写锁(null_rw_mutex虽然也标记为公平且可重入,但它不提供真正的互斥语义)。这张表也解释了本文标题中的“公平”二字从何而来:它是需求契约层面明确保证的性质,而不是实现巧合。

三、类接口与源码印证

queuing_rw_mutex的公开接口非常精简(类声明见 include/oneapi/tbb/queuing_rw_mutex.h):

// Defined in header <oneapi/tbb/queuing_rw_mutex.h> namespace oneapi { namespace tbb { class queuing_rw_mutex { public: queuing_rw_mutex() noexcept; ~queuing_rw_mutex(); queuing_rw_mutex(const queuing_rw_mutex&) = delete; queuing_rw_mutex& operator=(const queuing_rw_mutex&) = delete; class scoped_lock; static constexpr bool is_rw_mutex = true; static constexpr bool is_recursive_mutex = false; static constexpr bool is_fair_mutex = true; }; } // namespace tbb } // namespace oneapi

3.1 构造与析构

  • queuing_rw_mutex() noexcept:构造一个处于未锁定状态的互斥量。在 实现源码 中,构造函数同时会调用create_itt_sync为 Intel ITT(Instrumentation and Tracing Technology)分析工具登记同步对象,方便性能剖析。
  • ~queuing_rw_mutex():析构一个未锁定的互斥量。源码中的断言(include/oneapi/tbb/queuing_rw_mutex.h#L50-L52)明确指出:若在互斥量仍被持有(内部队尾指针q_tail非空)时析构,会触发断言失败。这提醒我们:必须在所有持锁线程退出后再销毁互斥量。
  • 拷贝与赋值:两者均被= delete显式删除,因为互斥量的语义不允许被复制或移动。这条规则同样适用于scoped_lock,需求文档 明确要求“Mutex 类型与 M::scoped_lock 类型既不可拷贝也不可移动”。

3.2 内部结构:为什么 scoped_lock 是“队列节点”

一个值得注意的实现事实是:scoped_lock不仅仅是锁的持有凭证,它本身就是公平排队算法中的队列节点。在 queuing_rw_mutex.h 的私有成员中可以看到:

private: //! The pointer to the mutex owned, or nullptr if not holding a mutex. queuing_rw_mutex* my_mutex; //! The 'pointer' to the previous and next competitors for a mutex std::atomic<uintptr_t> my_prev; std::atomic<uintptr_t> my_next; //! State of the request: reader, writer, active reader, other service states std::atomic<state_t> my_state; //! The local spin-wait variable std::atomic<unsigned char> my_going; //! A tiny internal lock std::atomic<unsigned char> my_internal_lock;

而互斥量对象本身只保存一个原子指针q_tail(队列尾部)。等待线程通过scoped_lock节点组成一个隐式的等待队列,这正是它公平性的物理基础——后到的线程总是排在先到者之后。

四、公平性的实现原理:从论文到源码

源码头部的注释(src/tbb/queuing_rw_mutex.cpp)明确说明该实现改编自 Krieger、Stumm 等人的公平可扩展读写锁论文("A Fair, Fast, Scalable Reader-Writer Lock")。头文件中的类注释也写明了同样的出处(include/oneapi/tbb/queuing_rw_mutex.h),并标注其特性为 “local-only spinning”——即只在本地自旋等待,线程自旋的是自己节点上的标志位,而非共享的全局状态,因此不会造成单一缓存行上的争用热点。

src/tbb/queuing_rw_mutex.cpp开头的提示还强调:修改此实现前必须先用 SPIN 工具模拟验证(对应仓库中的tools/spin_models/ReaderWriterMutex.pml),因为“有些代码看起来可以重构,但它的结构至关重要”——这从侧面说明了该算法的微妙性。

4.1 入队:acquire 的关键路径

以写锁获取为例,核心流程在 acquire 中:

  1. 先完成scoped_lock节点所有字段的初始化(设置my_mutex、清空my_prev/my_next/my_going、按write参数写入STATE_WRITER或STATE_READER状态);
  2. 通过m.q_tail.exchange(&s, std::memory_order_acq_rel)把自己原子地交换到队尾,并取回前驱节点;
  3. 若存在前驱,则把前驱的my_next指向自己,然后在自己的my_going上自旋等待——只有当真正轮到自己时,前驱才会把my_going置 1 唤醒自己。

这段逻辑对应源码注释(src/tbb/queuing_rw_mutex.cpp#L173-L186):交换操作需要 release 语义(把已初始化的字段“发送”给后继者可见)和 acquire 语义(获取前驱的同步关系)。“在本地节点上自旋”正是公平排队锁的标志性设计:每个线程等待的是专属于自己的缓存行,队列长度增加不会加剧缓存竞争。

4.2 状态机:读写锁协议的核心

公平的读写锁远比互斥锁复杂,因为读锁需要“共享”,写锁需要“独占”。实现中用 8 个状态位描述一个锁请求所处的阶段(src/tbb/queuing_rw_mutex.cpp#L88-L101):

状态位含义
STATE_WRITER写锁持有者/请求者
STATE_READER等待中的读锁请求
STATE_READER_UNBLOCKNEXT读者已获锁并负责唤醒后继读者
STATE_ACTIVEREADER已成为活跃读者(已持有读锁)
STATE_UPGRADE_REQUESTED读者请求升级为写者
STATE_UPGRADE_WAITING升级请求正在等待
STATE_UPGRADE_LOSER升级竞争中失败(锁曾被释放并重新获取)

读锁获取的路径比写锁复杂:读者入队后会检查前驱状态。若前驱是STATE_ACTIVEREADER(活跃读者),说明当前已经有多读者持有锁,新读者可以直接把自己置为活跃读者并进入临界区;若前驱是普通STATE_READER,则把前驱改为STATE_READER_UNBLOCKNEXT,由前驱负责在轮到它时唤醒自己。这种“读者链”机制保证了多个读者可以在不破坏队列顺序的前提下同时持有锁,同时仍保持总体公平。

4.3 升级与降级:upgrade_to_writer 与 downgrade_to_reader

这两个操作是读写锁特有能力的实现(src/tbb/queuing_rw_mutex.cpp#L419-L582):

  • downgrade_to_reader:若已是读者则直接返回 true;若持有写锁,则把状态改为读者,并尝试让后继者接管。实现结尾无条件返回 true(src/tbb/queuing_rw_mutex.cpp#L458),印证了需求文档“当前 oneTBB 读写锁的 downgrade 恒返回 true”的说明。
  • upgrade_to_writer:把状态从STATE_ACTIVEREADER依次推进到STATE_UPGRADE_REQUESTED、STATE_UPGRADE_WAITING,期间可能与其他升级请求者竞争,最终若变成STATE_UPGRADE_LOSER则返回 false,表示“锁曾被释放并重新获取”,调用方需要重新检查共享数据的稳定性;否则置为STATE_WRITER并返回 true。

在 test/tbb/test_mutex.cpp 中,test_rwm_upgrade_downgrade<tbb::queuing_rw_mutex>()专门覆盖了升级/降级路径的测试,说明这两个操作是被官方测试保障过的稳定语义。

五、使用示例:scoped_lock 实战

queuing_rw_mutex的使用遵循 oneTBB 的scoped locking pattern(作用域锁模式)。该模式的优势在 Mutex 需求文档 中有明确说明:不需要手动记得释放锁,且当临界区抛出异常时,锁会随作用域退出自动释放,不会出现死锁或锁泄漏。

一个完整的读写锁使用示例:

#include <oneapi/tbb/queuing_rw_mutex.h> #include <vector> class ProtectedCache { std::vector<int> data; // 互斥量本身不带“锁状态”,状态存放在 scoped_lock 节点中 oneapi::tbb::queuing_rw_mutex mutex; public: // 写操作:独占锁 void append(int value) { // 默认 write = true,即写锁 oneapi::tbb::queuing_rw_mutex::scoped_lock lock(mutex); data.push_back(value); // 离开作用域时自动释放写锁 } // 读操作:共享锁,多个读者可并发 int size() const { // write = false,即读锁 oneapi::tbb::queuing_rw_mutex::scoped_lock lock(mutex, false); return static_cast<int>(data.size()); } // 先读后写:读锁升级为写锁 void conditional_append(int value) { oneapi::tbb::queuing_rw_mutex::scoped_lock lock(mutex, false); if (!data.empty() && data.back() == value) return; // 无需写 lock.upgrade_to_writer(); // 升级为写锁 data.push_back(value); } };

注意代码中的几个关键点:

  • scoped_lock lock(mutex)等价于scoped_lock lock(mutex, true),取写锁;
  • scoped_lock lock(mutex, false)取读锁,多个读者线程可同时进入size();
  • 升级返回false时(说明锁曾被释放并重新获取),必须重新评估共享数据的当前状态,不能假设临界区内的数据仍然“与升级前一致”;
  • 不要用同一线程对同一把锁二次加锁,因为该锁不可重入,重入会死锁。

另外,scoped_lock还支持“延迟获取”的写法:先scoped_lock lock;构造空锁,稍后再lock.acquire(mutex, false)获取;或在无法立即获取时用lock.try_acquire(mutex, true)非阻塞尝试。try_acquire 失败返回 false,此时my_mutex不会被设置,调用方应避免继续执行需要持锁的临界区代码。is_writer()可用来在持锁期间判断当前锁的类型,例如在升级流程中确认自己是否已是写者。

六、性能特征与选型建议

6.1 何时选 queuing_rw_mutex

根据官方文档与源码证据,queuing_rw_mutex的适用场景可以总结为:

  • 需要严格的 FIFO 公平性:例如多线程读写共享数据时,不希望某个写者因持续被新读者插队而饿死;或在实时/批处理混合负载中要求请求顺序可预期。
  • 读多写少且读者持锁时间较长:多个读者可并发持有读锁,提高读吞吐。
  • 需要读→写升级或写→读降级:避免“释放读锁再抢写锁”中间窗口导致的竞态与额外阻塞。

它的代价是:相比spin_rw_mutex(基于自旋、无公平性、不适合持锁时间较长)和rw_mutex(自适应等待、写者优先但不公平),queuing_rw_mutex的每个锁请求都需要维护队列节点与状态切换,临界区应当足够短,避免持锁时间过长。对于临界区极短、锁竞争激烈且能接受不公平的场景,应优先考虑spin_rw_mutex等自旋锁。

6.2 对比表:读写锁家族的定位

综合 rw_mutex.rst 与各锁的类文档:

  • spin_rw_mutex(spin_rw_mutex_cls.rst):不公平、不可重入,纯自旋,适合极短临界区、线程数不超过硬件并发度。
  • rw_mutex(rw_mutex_cls.rst):自适应(先自旋后阻塞)、写者优先、不公平;实现满足 ISO C++ 标准的共享互斥要求。
  • queuing_rw_mutex(本文):公平、不可重入、本地自旋,适合需要确定性顺序的读写场景。
  • null_rw_mutex:空实现,用于“本来需要锁、但当前场景保证单线程访问”时消除开销。

从 test/tbb/test_mutex.cpp 可以看出,oneTBB 的测试套件将这几种读写锁统一纳入is_rw_mutex的泛型测试,因此它们在接口层面可以互换,选型主要依据公平性与性能特征的权衡。

七、测试与 ABI 稳定性

queuing_rw_mutex的正确性由多层测试保障:

  • test/tbb/test_mutex.cpp 用原生线程验证queuing_rw_mutex的互斥与读写语义;
  • 同一文件中的test_rwm_upgrade_downgrade与TestIsWriter(test/tbb/test_mutex.cpp#L182-L186)覆盖升级/降级与锁类型查询;
  • 在 test/abi/linux-64/tbb.txt 等 ABI 清单文件中,queuing_rw_mutex的导出符号被登记在册,用于保证跨版本的二进制兼容。

八、总结

queuing_rw_mutex是 oneTBB 读写锁家族中唯一兼顾公平性与实用语义的选择:它以scoped_lock作为隐式排队节点,通过本地自旋实现线程按请求顺序获取锁,同时支持读锁共享、写锁独占以及读→写升级、写→读降级。使用时牢记三点:锁不可重入、互斥量必须在无人持锁时析构、升级返回 false 时需重新检查共享状态。在需要确定性锁顺序的读多写少场景中,它是比spin_rw_mutex与rw_mutex更贴合的方案。

进一步阅读:完整的接口契约见 ReaderWriterMutex 需求,类头文件见 include/oneapi/tbb/queuing_rw_mutex.h,核心算法实现见 src/tbb/queuing_rw_mutex.cpp,同类可对比 rw_mutex_cls.rst 与 spin_rw_mutex_cls.rst。

  • 并发编程
  • 高性能计算

【免费下载链接】oneTBB

oneAPI Threading Building Blocks (oneTBB)

项目地址:https://gitcode.com/gh_mirrors/on/oneTBB
点击查看免费下载

相关推荐

上一篇:nteract 2020/04/13 Pre-Release 变更解读:配置系统 setConfigAtKey→setConfig 迁移与 CodeMirror 编辑器改造
下一篇:QR Code Monster与AVeryComfyNerd:打造惊艳螺旋艺术的简单方法

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询