ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ 实现读写锁的代码详解

C++ 实现读写锁的代码详解 前言读写锁reader-writer lock要解决的是一类很常见的并发矛盾共享数据被读的次数远远多于 被写的次数。用普通的std::mutex两个读者明明互不干扰却必须排队白白浪费了并行度 而用读写锁多个读者可以同时进入只有写者才需要独占。先说清一件事C17 已经提供了标准的读写锁std::shared_mutex 绝大多数场合不该自己造。本文把它分两部分讲——先讲标准设施怎么用这是你真正该写的代码 再从原理出发手写一个因为「自己实现一遍」是理解读者计数、写者优先、条件变量谓词这些 概念最有效的途径。要纠正的典型误解有「shared_mutex一定比mutex快」——不一定。读写锁本身要多维护读者计数、要处理两种等待队列临界区很短或写操作占比高时它可能比普通互斥量更慢。「读锁可以升级成写锁」——std::shared_mutex没有升级upgrade能力也没有降级接口。想「先读后写」只能先释放读锁再重新申请写锁 而这段时间里数据可能已经被别人改过。「读锁里改数据没问题反正别人也在读」——多个读者之间的写-写、读-写冲突就是数据竞争属于 UB标准不保证任何行为。示例按C17标准编写目标编译器为 GCC 13 / Clang 17 / MSVC 19.3x。一、std::shared_mutex 与 std::shared_lock 的基本用法std::shared_mutex在C17进入标准定义在shared_mutex中。 它提供两套加解锁接口接口语义对应 RAII 包装器lock()/try_lock()/unlock()独占写模式std::unique_lock、std::lock_guardlock_shared()/try_lock_shared()/unlock_shared()共享读模式std::shared_lock注意它没有shared_lock()这个成员函数那是std::shared_lock这个类的名字 也没有lock_upgrade()之类的方法。一个std::map配置表的完整例子#include map #include mutex #include shared_mutex #include string class ConfigStore { public: void set(const std::string key, int value) { std::unique_lockstd::shared_mutex lk(mtx_); // 写独占 data_[key] value; } bool get(const std::string key, int out) const { std::shared_lockstd::shared_mutex lk(mtx_); // 读共享多个读者可同时进入 const auto it data_.find(key); if (it data_.end()) { return false; } out it-second; return true; } private: mutable std::shared_mutex mtx_; // mutable让 const 成员函数也能加锁 std::mapstd::string, int data_; };三个容易出错的细节mutable不能省。get是const成员函数里面的mtx_本来是常量而std::shared_lockstd::shared_mutex的构造函数要绑定到非 const 的std::shared_mutex不加mutable会编译报错。这不是「风格问题」 是语义上的必然加锁会修改锁对象的状态所以锁必须是可变的。读锁也要加。只读访问照样要和写者互斥忘了加读锁就是数据竞争。std::shared_mutex不可重入。同一个线程在持有共享锁的情况下再调lock()或者持写锁再调lock_shared()都会死锁标准不保证这类用法有任何合理行为。如果需要带超时的接口用C14的std::shared_timed_mutex 它多出try_lock_for、try_lock_until、try_lock_shared_for、try_lock_shared_until四个成员std::shared_mutexC17没有超时版本。二、共享读为什么可能更快前提与代价读写锁能带来收益的前提是三条同时成立读操作远多于写操作。写操作天然串行读写锁对写没有任何帮助。临界区足够长。如果临界区只是读一个变量那么加解锁本身的原子操作开销就足以盖过并行带来的收益。读者之间有真正的并行度。单核或者读者被别的东西挡住时共享模式没有意义。除了加解锁还有两个隐性成本值得知道读者计数的伪共享false sharing。如果读者计数是一个全局原子变量多个核上的读者会反复对同一条缓存行做原子加这条缓存行就在各核的缓存之间来回弹 开销可能远超一次普通原子操作。手写锁时把计数器放在自己的对象里已经算隔离得不错 但在超高频读的场景下依然要留意。上下文切换。读锁竞争激烈时等待的线程会被挂起唤醒一次就要付出一次内核调度代价。临界区越短这些代价占比越高。所以「读多写少就该用读写锁」这个结论要加个前提先测再换。 不要凭直觉把std::mutex全换成std::shared_mutex。三、手写一个写者优先的读写锁下面这个类实现了标准的共享/独占两套接口。它用了三个状态变量读者计数、 等待中的写者数、是否有活跃写者全部由一个普通互斥量保护用条件变量做等待。#include condition_variable #include mutex class ReadWriteLock { public: // ---------- 共享读模式 ---------- void lock_shared() { std::unique_lockstd::mutex lk(mtx_); // 谓词没有活跃写者且没有排队的写者后者保证写者不会被源源不断的读者饿死 cv_.wait(lk, [this] { return !writer_active_ waiting_writers_ 0; }); readers_; } bool try_lock_shared() { std::lock_guardstd::mutex lk(mtx_); if (writer_active_ || waiting_writers_ 0) { return false; } readers_; return true; } void unlock_shared() { std::lock_guardstd::mutex lk(mtx_); --readers_; if (readers_ 0) { cv_.notify_all(); // 最后一个读者离场叫醒可能在等的写者 } } // ---------- 独占写模式 ---------- void lock() { std::unique_lockstd::mutex lk(mtx_); waiting_writers_; // 先登记之后来的读者会被挡住写者优先 cv_.wait(lk, [this] { return !writer_active_ readers_ 0; }); --waiting_writers_; writer_active_ true; } bool try_lock() { std::lock_guardstd::mutex lk(mtx_); if (writer_active_ || readers_ 0) { return false; } writer_active_ true; return true; } void unlock() { std::lock_guardstd::mutex lk(mtx_); writer_active_ false; cv_.notify_all(); // 读者和写者都可能被唤醒交给各自的谓词去筛选 } private: std::mutex mtx_; // 保护下面全部状态 std::condition_variable cv_; int readers_ 0; // 共享模式的持有次数 int waiting_writers_ 0; // 已进入 lock() 但尚未拿到写锁的线程数 bool writer_active_ false; // 当前是否有写者持锁 };逐条解释设计取舍为什么用谓词版本的wait。条件变量的唤醒可能是虚假的也可能在你开始等待之前就已经发生。谓词形式等价于while (!pred()) wait(lk);把「为什么醒」这件事交给状态变量去回答 这是条件变量唯一正确的用法。为什么读者也要看waiting_writers_。如果读者只看「没有活跃写者」那么只要读者源源不断readers_就永远不为 0等待的写者会被无限期饿死。 加上这道门槛一旦有写者在排队新读者就必须让路——这就是写者优先。 代价是反过来写作密集时读者也可能被拖延。为什么unlock_shared里要判readers_ 0。写者关心的是「还有没有人读」只有计数真正归零才值得唤醒它否则唤醒后谓词不满足还得回去接着睡。为什么unlock()用notify_all而不是notify_one。写者释放锁之后读者和写者都可能变得可运行。如果只唤醒一个而被唤醒的恰好是谓词不满足的读者 那么真正的写者就白等了——这是手写读写锁最隐蔽的一个死锁来源。notify_all会多唤醒几个线程但它们会发现谓词不满足而重新睡下代价可以接受。把这个类包装成 RAII 用法需要提供上面这六个成员函数 对应标准里Lockable和SharedLockable的要求之后标准包装器就能直接套上#include mutex #include shared_mutex void demo(ReadWriteLock rw) { { std::shared_lockReadWriteLock read_lock(rw); // 构造时调用 rw.lock_shared() // 在这里安全地读取共享数据 } // 析构时自动调用 rw.unlock_shared() { std::unique_lockReadWriteLock write_lock(rw); // 构造时调用 rw.lock() // 在这里安全地修改共享数据 } // 析构时自动调用 rw.unlock() }一个必须说明的缺陷上面这段实现没有做异常安全处理。 如果cv_.wait抛异常极罕见通常只在底层同步原语失败时发生waiting_writers_会残留一个多出来的计数之后所有新读者都会被永久挡住。 生产代码里应当用 RAII 守卫在异常路径上恢复计数 或者把等待逻辑封装成「只在成功路径上修改状态」的形式。四、公平性、递归与选型公平性标准不作规定。std::shared_mutex只要求「共享与独占互斥」 至于等待中的读者和写者谁先被唤醒完全由实现决定属于实现定义的行为MSVC STL的std::shared_mutex用 Windows 的SRWLOCK内部名_Smtx_t实现其唤醒策略由系统决定。libstdc在bits/shared_mutex.h里提供了两套实现平台上有pthread_rwlock_t时用它否则用基于互斥量加条件变量的版本。 据 glibc 的文档默认属性的pthread_rwlock_t是偏向读者的 PTHREAD_RWLOCK_PREFER_READER_NP读者持续不断的场景下写者可能饥饿。libc有自己的实现互斥量 条件变量维护读者计数行为同样以实现为准。递归是禁止的。std::shared_mutex不是递归锁也不是「可升级锁」。 Boost 里那个能升级的boost::upgrade_mutex提供了upgrade语义 但标准库至今没有对应设施。需要「读的时候发现要写」时正确的做法是 先释放共享锁再用独占锁重新检查一遍条件因为中间状态可能已经变了。场景建议读远多于写临界区较长std::shared_mutexstd::shared_lock读写差不多或临界区极短直接用std::mutexshared 的额外簿记不一定划算需要超时std::shared_timed_mutexC14需要在读的时候升级为写标准库不支持释放后重新申请并重新校验状态需要严格公平不饿死任何一方标准库不保证需要自己实现带排队队列的锁或改用别的并发数据结构常见坑点场景❌ 错误写法✅ 正确写法只读不加锁int v data_[k];与其他线程的写构成数据竞争UBstd::shared_lockstd::shared_mutex lk(mtx_);之后再读读锁下写数据持共享锁时执行data_[k] v;写操作必须换成std::unique_lock锁升级持shared_lock时再申请unique_lock自死锁先unlock()/离开作用域释放读锁再申请写锁并重新校验状态手写实现的唤醒unlock_shared()里用notify_one()叫醒写者notify_all()notify_one可能唤醒谓词不满足的读者导致写者长期等不到条件变量等待cv_.wait(lk);不带谓词cv_.wait(lk, [this]{ return ...; });const 成员函数忘记给互斥量加mutable编译失败mutable std::shared_mutex mtx_;递归加锁同一线程重复lock_shared()再lock()死锁/UB把需要写的那段逻辑提到持锁之前或换一把锁手写锁的异常路径wait抛异常后计数没恢复后续读者全被卡住用 RAII 守卫恢复状态或只在成功路径上改状态性能直觉把项目里所有std::mutex无脑换成std::shared_mutex先测量短临界区和写多的场景未必更快读锁对象的传播把std::shared_lock的引用传给另一个线程去解锁锁对象的生命周期必须与加锁线程一致不要跨线程传递其中「锁升级」值得再展开一句C 里没有「把读锁原子地变成写锁」的标准手段。 如果你在持读锁时发现需要写唯一安全路径是释放读锁、重新申请写锁 然后重新检查你读到的状态是否还有效——在你放手的那一瞬间 别的线程完全可能已经把数据改了。这不是实现缺陷而是这类锁的固有限制。总结要点结论标准设施C17 的std::shared_mutex配std::shared_lock读、std::unique_lock写超时版本C14 的std::shared_timed_mutex不可升级没有升级/降级接口只能先解锁再重新加锁并重新校验不可重入持共享锁再申请独占锁会死锁公平性标准不规定是各家实现的行为MSVC 用SRWLOCKlibstdc 用pthread_rwlock_t或条件变量版手写要点谓词等待、读者计数、写者排队、notify_all、异常路径恢复计数性能判断读多、临界区长、核数足三者同时成立才有明显收益先测再换读写锁的核心思想只有一句话把「互斥」拆成「读-读共享」和「读-写、写-写互斥」两类关系。 理解了这句话无论是用std::shared_mutex还是自己数着读者、排队写者去实现一个 都不会再被它那些看起来矛盾的失效规则和饥饿问题绕晕。真正要警惕的永远是两件事共享锁下不要写数据以及不要指望它总能更快。
返回列表