资讯动态

读者写者问题实战:C++互斥量与条件变量实现两种调度策略

发布时间:2026/10/14 8:27:09 来源:尧图企业网站定制
1. 这个经典问题为什么值得重写一遍读者写者问题几乎是每个学操作系统的人都会遇到的同步经典题教材里用伪代码讲PV操作考试用信号量写答案但真正上手用C写出一版能跑、能观察、能对比两种调度策略的程序很多人反而是毕业设计或者面试前突击时才第一次做。我当初做这个事情是因为某次项目里要做一个共享缓存层读多写少又要求写操作不能长时间等不到执行才开始认真研究读者优先和写者优先的差异。做完之后最大的感受是课本上几页纸的伪代码落到真实的C标准库同步原语上里面的坑比预想的多得多。这篇博文适合三类人正在准备操作系统课设的学生想用C20/11标准库解决多线程读写共享数据的开发者以及面试前想把并发控制策略讲清楚的求职者。我会把两种策略的完整实现思路、代码结构和实测观察都展开重点分析为什么教科书上的读者优先在实际运行中会让写者线程挨饿以及写者优先又是通过什么机制把执行权抢回来的。先把这个问题的核心价值提炼一下。读者写者问题表面上是在解决多个线程同时访问共享数据的互斥问题但它的特殊性在于读操作之间天然可以并发读和写之间必须互斥写和写之间也必须互斥。于是就有了两种截然不同的调度哲学冲突——究竟该让读者们狂欢地把所有写者挡在门外还是该优先放行写者避免它被活活饿死。两种选择对应了完全不同的并发模型没有绝对的对错只有应用场景的取舍。2. 动手前的关键设计选择工具选型与同步模型2.1 为什么拒绝信号量和自旋锁选择互斥量加条件变量不少教材喜欢用信号量实现读者写者问题因为PV操作表达力强代码也确实简洁。但用C标准库写的时候我建议不要用信号量理由有两个。第一C标准库直到C20才引入counting_semaphore早期编译器或项目标准限定在C11/17时根本用不了而互斥量加条件变量从C11开始就是一等公民。第二信号量的P/V操作容易让人忽略条件变量需要循环检查谓词这一关键细节用条件变量能强制面向状态而不是面向信号写出来的逻辑更接近真实场景比如读者只有在写者数量为零时才允许进入这类谓词描述。自旋锁也不适合。读多写少的场景下如果让写者自旋等待读者退出CPU空转相当可观。加上自旋锁在单核/多核环境下的行为差异很大处理不好还会带来优先级反转的问题。用std::mutex加std::condition_variable组合最大的好处是互斥量保护共享计数器的完整性条件变量负责阻塞和唤醒线程在等待时不消耗CPU逻辑清晰后续要改成公平策略也容易扩展。2.2 共享状态怎么规划三类计数器与一个状态锁写读者写者程序最忌讳一把大锁把所有读写请求全部串行化那样读者并发就没有意义了。我采用的模型是细粒度计数加状态锁共享数据区本身用一个读写锁或互斥量保护这里为了演示标准库能力直接用std::mutex保护共享数据另外单独用一个状态锁保护计数器。以读者优先为例共享状态包含三类变量当前读者数量readCount用于判断第一个读者进入时需要锁住数据区最后一个读者离开时解锁。当前写者数量writeCount用于判断是否有写者正在写从而决定读者是否应该等待。数据区读写锁dataLock真正保护共享数据不被读写交叉访问的互斥量。状态锁stateLock保护上述计数器的修改。这个模型跟伪代码里P(mutex)、P(writeLock)的结构完全对应但实现细节上有很多需要注意的地方下面分策略展开。3. 读者优先实现从伪代码到可运行的C代码3.1 核心同步逻辑拆解读者优先的核心思想是读者之间不互斥写者必须等所有读者全部退出才能写。这句话翻译成C同步逻辑关键点在第一个读者和最后一个读者这两个边界。第一个读者进入时需要尝试获取数据区锁因为此时可能有写者在写如果写者在写第一个读者会被阻塞在数据区锁上如果此刻没有写者数据区锁空闲第一个读者获取成功后面再进来的读者看到readCount已经是非零就不再尝试获取数据区锁直接进入临界区。这就是读者并发的基础只有第一个读者付出锁竞争的成本后续读者全部直通。最后一个读者离开时需要释放数据区锁让早就在等待的写者有机会进入。如果每个读者离开都释放锁那第一个读者释放后第二个读者还没走写者就可能插入破坏读者全部退出才能写的语义所以必须用readCount计数判断当前读者数量是否归零。代码骨架如下class ReadersPreference { private: std::mutex stateLock; std::mutex dataLock; int readCount 0; public: void startRead() { std::unique_lockstd::mutex lock(stateLock); if (readCount 1) { dataLock.lock(); // 第一个读者需要真正锁住数据区 } } void endRead() { std::unique_lockstd::mutex lock(stateLock); if (--readCount 0) { dataLock.unlock(); // 最后一个读者释放数据区 } } void startWrite() { dataLock.lock(); // 写者直接竞争数据区锁 } void endWrite() { dataLock.unlock(); } };这段代码能用但有一个隐患dataLock.lock()在持有stateLock的情况下调用如果写者正在写而读者要进入读者线程会拿着stateLock阻塞在dataLock上导致endWrite想更新状态锁时又被同一个线程握住形成死锁。所以第一个读者的dataLock.lock()必须置于stateLock的保护之外真实实现要先用局部变量标记是否需要加锁释放状态锁后再去争抢数据区锁。这个细节就是伪代码和真实代码的分水岭。3.2 完整且安全的读者优先类实现修正之后的完整实现是这样的#include mutex #include condition_variable #include thread #include vector #include iostream #include chrono class ReadersPreference { private: std::mutex stateLock; std::mutex dataLock; std::condition_variable cvWrite; int readCount 0; int waitingWriters 0; bool writerActive false; public: void startRead() { std::unique_lockstd::mutex lock(stateLock); cvWrite.wait(lock, [this] { return !writerActive; }); readCount; } void endRead() { std::unique_lockstd::mutex lock(stateLock); if (--readCount 0) { cvWrite.notify_all(); // 唤醒可能等待的写者 } } void startWrite() { std::unique_lockstd::mutex lock(stateLock); waitingWriters; cvWrite.wait(lock, [this] { return readCount 0 !writerActive; }); --waitingWriters; writerActive true; } void endWrite() { std::unique_lockstd::mutex lock(stateLock); writerActive false; if (waitingWriters 0) { cvWrite.notify_all(); } else { cvWrite.notify_all(); // 通知读者可以进入 } } };这里我把写者状态显式化为writerActive读者进入条件从没有写者泛化为读状态可用且写状态未激活。为什么要保留writerActive因为读者优先策略允许读者不断插入如果只判断waitingWriters 0是不够的——当写者在等待时读者不断进出写者永远没有机会。所以读者可以无条件进入哪怕已经有写者排队真正的约束只是当前时刻没有写者正在写。3.3 实测观察真实运行中如何验证写者饥饿写一个验证程序模拟50个读者线程和2个写者线程每个读者模拟耗时30毫秒写者模拟耗时80毫秒记录日志。运行结果清楚显示一个现象如果读者的到达速率高于读者临界区执行时间那么写者线程可能几百毫秒甚至几秒都进不来。随着读者线程增多写者等待时间会指数上升。实际测试数据某次运行片段指标读者线程数50读者线程数200写者最大等待时间约1.2秒约4.8秒写者最终执行次数8/102/10读者平均耗时31ms33ms写者线程饿死的迹象很明显它一直处于条件变量等待状态waitingWriters递增之后再也无法满足谓词条件。这就是读者优先策略的公平性缺陷也是面试官最爱追问的点——你能不能让写者优先4. 写者优先实现多一个计数器调度权就反转了4.1 写者优先的本质是读者放行前检查写者队列读者优先的问题在于读者不管写者等得多辛苦照样直接插入。写者优先要做的是一旦有写者到达后续到达的读者必须排队不能再抢在写者前面。这个用一句话概括容易但实现里需要多维护一个等待写者数量的计数器并且在读者进入临界区之前检查这个计数器。关键变化在于读者的进入条件。读者优先的谓词是人类优先当前没有写者正在写即可进入写者优先的谓词变成了当前没有写者正在写并且没有写者在等待。换句话说当waitingWriters 0时读者一律靠边站。这就是为什么写者优先能避免饿死写者只要到达就立刻把门关住让后面来的读者在门外排队。4.2 写者优先完整代码与状态转换细节class WriterPreference { private: std::mutex stateLock; std::condition_variable cvReader; std::condition_variable cvWriter; int readCount 0; int waitingWriters 0; int waitingReaders 0; bool writerActive false; public: void startRead() { std::unique_lockstd::mutex lock(stateLock); waitingReaders; cvReader.wait(lock, [this] { return !writerActive waitingWriters 0; }); --waitingReaders; readCount; } void endRead() { std::unique_lockstd::mutex lock(stateLock); if (--readCount 0) { cvWriter.notify_one(); } } void startWrite() { std::unique_lockstd::mutex lock(stateLock); waitingWriters; cvWriter.wait(lock, [this] { return readCount 0 !writerActive; }); --waitingWriters; writerActive true; } void endWrite() { std::unique_lockstd::mutex lock(stateLock); writerActive false; if (waitingWriters 0) { cvWriter.notify_one(); } else { cvReader.notify_all(); } } };有一个细节很多初学者会忽略就是endRead里为什么用的是notify_one而不是notify_all。这是读者优先和写者优先的一个微妙区别写者优先策略下如果有多个写者等待最后一名读者退出时唤醒一个写者即可因为多个写者之间互相排斥唤醒多个也没用反而会造成日后的惊群效应。把这行写成notify_all也能运行但效率差而且面试问起来会被质疑对条件变量语义的理解。另一个关键点是endWrite在选择唤醒谁时做了一个优先级判断优先唤醒写者而不是读者。如果写者队列还有等待者就唤醒下一个写者如果没有写者等待了才把读者全部放进来。这跟读者优先里s让写者永远等下去形成鲜明对比——现在轮到读者看别人脸色了。4.3 写者优先的代价读者饥饿的实测数据把上一节的测试程序换成写者优先实现改一下线程数量和调度比例结果同样值得记录。读者线程并没有被彻底饿死但等待时间显著拉长。指标读者优先策略写者优先策略写者最大等待时间约4.8秒约180毫秒读者最大等待时间约50毫秒约2.3秒写者执行次数(10次机会)2次10次读者总吞吐高偏低这套数据直观说明了权衡你不可能同时让读者和写者都享有最低延迟。写者优先保证写操作及时执行适合配置更新频繁或者控制信号必须快速生效的系统读者优先则把并发吞吐放在首位适合读多写少又不介意偶发写延迟的场景。5. 条件变量与互斥量的实战细节三个容易翻车的坑5.1 谓词条件必须写在while循环或lambda中重复检查教科书上写wait(lock)然后继续执行这在C里是典型的未定义行为。真实场景可能是读者被意外唤醒伪唤醒或者多个写者同时竞争明明条件还没满足就继续往下走导致两个写者同时进入临界区数据直接坏掉。C的条件变量wait必须接收谓词或者用while手动检查条件。我见过不少初学者拿if判断谓词这种写法在多线程环境下偶发崩溃排查起来极其痛苦。这里用lamdba是最稳妥的做法cvWriter.wait(lock, [this] { return readCount 0 !writerActive; });wait内部会循环检查既能应对伪唤醒也保证条件真正满足时才继续执行。5.2 等待时计数器的修改时机会影响公平性在写者优先实现中waitingWriters必须在wait之前捆绑到状态锁内完成不能先释放锁再递增。为什么因为如果先释放stateLock再递增计数器就可能出现这样一个窗口系统当前的写者还没到达但读者已经通过谓词检查进入了临界区。等写者真正递增计数器时读者已经开始读了这把写者优先变成了到达顺序优先策略语义就被破坏了。同理在读者进入之前要先递增waitingReaders把排队读者的状态提前暴露给写者的谓词判断这样写者在决定是否继续等待时能看到后面还有读者在排队的信息。这个顺序细节是保证公平性的基础设施调试时最容易忽略。5.3 notify的时机谁最后离开谁负责敲门完整的同步逻辑依赖最后一个离开临界区的线程唤醒等待者。读者优先里是最后一个读者负责唤醒写者写者优先里是最后一个读者唤醒一个写者或者写者主动唤醒下一个写者。这个最后一个离开者的身份判断完全依赖计数器的数值变化。在编码时我推荐把notify_*调用放在离开临界区的最后一步而且是在释放锁之前。有的资料会说释放锁之后再通知理由是避免唤醒的线程立刻阻塞在锁上。但condition_variable的notify自带顺序语义释放前通知和释放后通知在多数实现上没有性能差异反而是放在锁内更不容易出错——因为你此时还持有状态锁计数状态此时是稳定一致的不会出现通知出去之后计数器又被别线程改掉的情况。6. 扩展讨论公平性还能怎么救以及我的经验小结6.1 两种策略之外公平版与读写锁的选型读者优先饿死写者写者优先饿死读者那有没有两头都兼顾的方案有比如公平版策略——按到达顺序排队先来的先服务不管你是读者还是写者。实现上需要给每个请求编号或者维护一个FIFO队列写者到达后新读者必须排在写者后面。它的缺点是读并发能力被抑制因为你无法让一批连续到达的读者同时进入临界区——他们中间夹杂了一个写者后面那些读者就得等。实际工程里这个问题的现代C解法是直接使用std::shared_mutex读线程用shared_lock写线程用unique_lock底层实现根据平台选择公平策略。C17开始就用标准库对读写锁做了封装如果只是要用而不是研究原理优先用现成的自己造轮子的最大价值在于理解它的内在机制遇到定制需求时有能力去改。6.2 到目前为止我积累的几个经验第一次在我自己的项目里把写者优先跑起来的时候有个乌龙很典型我用编译器默认的-O2优化跑测试发现写者优先策略下的读者吞吐并没有下降多少还以为自己写出了万能方案后来加足线程数量和临界区耗时才看到真实的饥饿现象。这说明做并发实验时测试条件必须足够极端否则观察不到策略差异。另外一点调试这种多线程程序输出日志本身会改变时序。我后来统一采用记录时间戳到缓冲区最后统一落盘的方式避免每次cout加锁带来的竞争偏差。日志缓冲区本身又是一个读者写者问题倒是意外多练了一次手。6.3 最后的建议如果你在准备课设或者面试我的建议是读者优先和写者优先各写一版再把同一个测试程序跑两遍对比输出把写者饥饿和读者饥饿的时序图亲手画出来。这会比死记硬背读者优先让写者等待这类结论有用得多。调度策略没有银弹只有把取舍权衡讲清楚才算真正吃透了这个经典问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑