ARTICLE DETAIL

资讯详情

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

操作系统进程同步:读者写者问题PV操作与读写锁策略详解

操作系统进程同步:读者写者问题PV操作与读写锁策略详解 操作系统课上学到PV操作绝大多数人的第一反应都是不就是P申请、V释放吗信号量初值设成资源数用完还回去能有多难。生产者消费者问题确实可以这么平推过去可读者写者问题一上来就会打脸——它多了一条生产者消费者压根没有的规则多个读者进程能同时读读操作之间不互斥。这条规则一加信号量的用法就从守一扇门变成了守一扇门但还要区分谁能一起进一个计数器、两个临界区、三四种策略全冒出来了。进程同步这一章里很多人在PV操作上翻车翻的就是这道题。这篇就把读者写者问题从最朴素的互斥版本一路推到写者优先和公平策略把每行代码为什么这么写、信号量值在每个瞬间到底是多少、哪里最容易写错全都摊开说清楚。正在啃操作系统进程管理这一章的同学能直接用写过并发代码、被读写锁坑过的人看一遍也会有收获。1. 朴素互斥能跑通为什么还要为读者写者问题单开一节课1.1 用一个互斥信号量守住整份数据先把最简单的做法摆出来。既然读和写都要访问同一份共享数据那干脆给这份数据配一个互斥信号量mutex初值设为1。不管你是读者还是写者进门之前一律P(mutex)出门之后一律V(mutex)。semaphore mutex 1; // 任意进程读者或写者 P(mutex); // 访问共享数据要么读要么写 V(mutex);这段代码在正确性上没有任何毛病任何时刻至多只有一个进程待在临界区里读写冲突、写写冲突自然都不存在了。要真这么简单读者写者问题也就不值得单独讲了。问题出在效率两个字上——读操作本身不会改变数据两个读操作同时进行谁也碍不着谁凭什么要让它们排队如果一个系统里读者占绝大多数用这把大锁的代价就是本可以并行的一大堆读请求被强行串行化吞吐量直接掉一个数量级。这就是朴素方案的死穴它保证了正确性却把并发读的并行性白白浪费了。1.2 读和读不冲突这才是问题的真正起点我们重新梳理一下这份共享数据上的三类关系。第一类读和读互不影响可以并发。第二类读和写会读到脏数据或者读到写了一半的中间状态必须互斥。第三类写和写两个写会互相覆盖也必须互斥。所以真正合理的规则应该是允许任意多个读者同时读但写者必须独占。一句话概括只要临界区里还有读者新的读者就能继续进但只要临界区里出现了写者那所有读者和写者都得在外面候着。这个规则听上去简单落到信号量上就要求我们做一件以前没做过的事——判断临界区里当前有没有人、是什么人、有几个。信号量本身只回答资源还有没有这种是/否问题它没法直接告诉你现在里面有几个读者。要补上这个信息就必须引入一个额外的共享计数器。这正是读者写者问题比生产者消费者更绕的根本原因光靠信号量不够还得配一个需要被保护的普通变量。1.3 三条约束和三种策略取向把这个问题的标准约束写清楚后面所有策略都是围绕它们做取舍约束含义朴素方案的满足情况写写互斥两个写者不能同时进满足读写互斥读者和写者不能同时进满足允许多读多个读者可以同时在临界区不满足在此基础上还能衍生出三种不同的策略取向它们的差别只在谁优先上读者优先只要还有读者在读或者不断有读者到来写者就得一直等写者可能被饿死。写者优先一旦有写者在等待后续新来的读者要排到写者后面避免写者被源源不断的读请求淹没。读写公平谁先到谁先服务读者和写者按到达顺序排队谁也不会被无限期饿死。注意很多人以为读者写者问题只有一套标准答案其实教材里通常给了至少两套读者优先和写者优先考试时先看清楚题目要求的是哪一种再动笔否则代码逻辑对了却不符合题意照样拿不到分。2. 读者优先方案readcount计数器才是真正的主角2.1 为什么一个信号量不够必须两个既然要记录当前有几个读者在里面我们就定义int readcount 0。但光有计数器还不够因为计数器本身也是共享变量多个读者会同时去改它改计数器的这段代码同样得进临界区。于是我们需要两个信号量分工rmutex只负责保护readcount这个计数器的读写初值1。wmutex真正的读写互斥信号量负责保护共享数据本身初值1。两个信号量各管一摊这是理解读者优先方案的关键。新手最容易犯的错就是把这两个职责混在一起结果要么读者之间互相阻塞要么写者根本进不来。2.2 readcount的加减为什么必须进临界区有人会问readcount不就一条语句吗至于专门拿个信号量保护非常至于。readcount这条看似原子的话编译到机器层其实是读—改—写三步先把值取到寄存器加一再写回去。假设现在readcount 0读者R1和R2几乎同时执行R1读到0准备加一R2也读到0准备加一两次写回之后readcount只变成了1可实际上有两个读者在里面。这个错误的后果很严重当第一个读者读完离开时它发现readcount减到0了于是执行V(wmutex)把写权限放出去可这时候另一个读者还在读写者一旦进来写读写就撞在了一起。这就是典型的竞态条件而它恰恰是并发编程里最难复现、最隐蔽的那类bug。所以计数器的加减必须老老实实包在P(rmutex)和V(rmutex)之间。2.3 完整代码与逐行拆解下面就是读者优先的经典写法两个角色的动作分开列semaphore rmutex 1; // 保护 readcount semaphore wmutex 1; // 读写互斥 int readcount 0; // 当前正在读的进程数 // 读者进程 P(rmutex); // 准备修改 readcount if (readcount 0) // 我是第一个读者 P(wmutex); // 那我就负责把写锁扣下来 readcount; // 读者计数加一 V(rmutex); // 释放 readcount 的保护 // ... 执行读操作 ... P(rmutex); readcount--; if (readcount 0) // 我是最后一个离开的读者 V(wmutex); // 把写锁放出来 V(rmutex); // 写者进程 P(wmutex); // ... 执行写操作 ... V(wmutex);读懂这段代码核心就抓住第一个和最后一个这两个角色。第一个进来的读者负责P(wmutex)相当于由它代表所有读者抢先占住写锁中间的读者因为readcount ! 0不需要再抢写锁直接进去读就行最后一个离开的读者负责V(wmutex)把写锁还给后来的写者。这样一来只要还有读者在场写锁就一直被扣着写者进不来多读并行也就实现了。2.4 信号量取值推演光看代码容易糊弄自己我们拿具体数字走一遍。初始rmutex 1, wmutex 1, readcount 0此时三个读者R1、R2、R3和一个写者W依次到达动作rmutexwmutexreadcount说明R1进入1→0→11→00→1第一个读者扣下写锁R2进入1→0→101→2不是第一个不碰写锁R3进入1→0→102→3同上W到达10→-13写锁被占W阻塞R1离开1→0→1-13→2不是最后一个不放锁R2离开1→0→1-12→1同上R3离开1→0→1-1→01→0最后一个唤醒WW被唤醒100W获得写锁这张表把每个瞬间的信号量值都摆出来了wmutex从0降到-1那个负号不代表负数个资源而是等待队列里排着一个写者。信号量值的这个语义正值表示可用资源数负值绝对值表示等待进程数是理解PV操作的核心考试算信号量变化全靠它。3. 写者优先给写进程开一条绕开读者队伍的通道3.1 读者优先下写者为什么会饿死上面那套方案有个隐患只要读者络绎不绝写者就可能永远等下去。设想一个系统里读请求非常密集每当写者好不容易等到readcount归零眼看要拿到wmutex了又进来一个新读者它一进来发现readcount 0立刻执行P(wmutex)把写锁重新扣住。写者就这样被一批又一批的读者反复插队永远轮不到。这个问题在现实中是致命的。比如一个配置文件被频繁读取偶尔需要更新一次如果更新操作永远排不上队那配置就永远改不了。所以我们需要一种机制让写者的请求一旦发出后续的新读者就得排到它后面去这就是写者优先。3.2 新增信号量w的作用机理实现写者优先的关键是再增加一个信号量w然后调整读者的进入顺序读者在动手改readcount之前先P(w)。它的逻辑是这样的写者到达时也会先P(w)。如果此时有读者还在进入流程里持有w写者就在w上等待但只要写者先抢到了w后面陆续到来的新读者就全部被堵在w的等待队列上进不来。于是当前这批已经进去的读者读完退出后写者就能顺利拿到wmutex执行写操作。w这条通道的作用就是给写者一个插队的入口让它一旦到达就能阻止新读者涌入。3.3 代码实现与申请顺序的讲究semaphore rmutex 1; // 保护 readcount semaphore wmutex 1; // 读写互斥 semaphore w 1; // 写者优先的关键信号量 int readcount 0; // 读者进程 P(w); // 先看有没有写者在等/在写 P(rmutex); if (readcount 0) P(wmutex); readcount; V(rmutex); V(w); // 这里就放掉 w别一直占着 // ... 读操作 ... P(rmutex); readcount--; if (readcount 0) V(wmutex); V(rmutex); // 写者进程 P(w); P(wmutex); // 独占共享数据 // ... 写操作 ... V(wmutex); V(w);这里有个必须注意的申请顺序写者一定是先P(w)再P(wmutex)不能反。因为w的职责是排队准入wmutex才是数据独占先排队再抢数据逻辑才顺。如果写者反过来先抢wmutex那它就绕过了准入通道写者优先的效果就没了。同理读者也是先P(w)再进P(rmutex)顺序错了整段逻辑就崩。另外要留意读者在V(rmutex)之后马上V(w)这个动作。它的含义是读者只借用w完成判断和登记这一步登记完立刻把通道让出来这样写者才有机会在下一批读者到来前插入。如果读者把w一直攥在手里直到读完那就变成另一种极端了——写者要等所有读者全部读完退化成读者优先。提示把写者优先和读者优先的两段代码放在一起对比你会发现唯一的区别就是读者开头多了P(w)、中间多了V(w)。改动的代码量极小但产生的行为差异巨大。这正是信号量编程改一行、效果天差地别的典型体现。4. 读写公平策略让先到的人先拿到数据访问权4.1 公平到底公平在哪读者优先会饿死写者写者优先从理论上也可能让读者饿死如果写请求持续不断读者就永远排在后面。如果我们的目标是读者和写者一视同仁谁先来谁先服务那就需要读写公平策略。它引入一个专门的排队信号量有的教材叫queue有的直接复用w不管是读者还是写者进入前都先在这个信号量上排一次队。这样所有进程都出现在同一个等待队列里信号量本身如果按FIFO顺序唤醒就能实现近似先来先服务。4.2 公平策略的代码写法semaphore rmutex 1; semaphore wmutex 1; semaphore queue 1; // 公平排队信号量 int readcount 0; // 读者进程 P(queue); // 进排队队列 P(rmutex); if (readcount 0) P(wmutex); readcount; V(rmutex); V(queue); // 排队阶段结束让出 // ... 读操作 ... P(rmutex); readcount--; if (readcount 0) V(wmutex); V(rmutex); // 写者进程 P(queue); // 同样进排队队列 P(wmutex); // ... 写操作 ... V(wmutex); V(queue);对比读者优先和写者优先公平策略的结构最对称读者和写者都先过queue这道关然后各干各的。它的代价是读者被稍微拖慢了——原本读者彼此之间不排队现在每个读者都要过一下queue虽然只是一瞬间的事但在极端读密集的场景下会有额外开销。4.3 三种策略的选择逻辑到底用哪一种取决于业务场景对优先级的容忍度策略优先对象适用场景潜在问题读者优先读者读远多于写、写操作可延迟写者可能饿死写者优先写者写操作有实时要求、不能久等读者可能被拖慢读写公平无读写都比较频繁、要求稳定读者进入略慢我个人的经验是绝大多数业务系统里只要没有明确要求写者优先或公平策略比读者优先更稳妥因为写操作往往关联着数据一致性让它无限期等待的风险要比读慢一点大得多。读者优先看似高效但它把写者饿死这颗雷埋在了系统里一旦触发就是数据长期不更新的故障。5. 把信号量值一个个算出来bug就无处可藏5.1 静态读代码看不到问题动起来才露馅信号量这类代码有个特点光用眼睛盯着看很难发现隐藏的竞态。因为它涉及的进程数是任意的执行顺序也是任意的你脑子里默认的那条顺序执行的主线往往恰恰不是出问题的那条路径。所以真正靠谱的验证办法是挑几个典型的执行序列把信号量的值一步步算出来看有没有哪个瞬间出现了矛盾。拿写者优先方案举个例子。假设初始w 1, wmutex 1, rmutex 1, readcount 0现在有读者R1先到写者W随后到动作wwmutexrmutexreadcount关键说明R1执行 P(w)1→0110R1拿到wR1执行 P(rmutex)011→00保护计数器R1判断readcount001→000第一个读者扣写锁R1执行 readcount0000→1计数加一R1执行 V(rmutex)000→11释放计数器R1执行 V(w)0→1011R1让出准入通道W执行 P(w)1→0011W拿到w准备写W执行 P(wmutex)00→-111写锁被R1占着W阻塞新读者R2执行 P(w)0→-1-111R2被挡在w外排队这张表暴露了写者优先的关键收益W拿到w之后新来的R2就在w上排队了即使R1还在读R2也进不来。等R1读完释放wmutexW就能上。读者优先方案在这条路径上早就让R2挤进去读了写者继续饿着。能把这种时序差异用表格摆出来才算真正吃透了策略。5.2 几个高频错误对照表下面这些错误我在批改作业和自己写代码时都反复见过列出来对照一下错误写法后果修正readcount没包在P(rmutex)里计数错乱读写出错计数加减都进临界区忘记最后一个读者释放wmutex写者永远进不去if (readcount 0) V(wmutex)忘记第一个读者抢占wmutex写者可能在读时插入if (readcount 0) P(wmutex)写者先P(wmutex)再P(w)绕过准入写者优先失效调整申请顺序读者V(w)的位置放错退化成读者优先或阻塞写者判断登记完就立刻释放有P无V或数量不匹配信号量失衡进程死锁逐对核对 P/V5.3 变体问题限制同时读进程数读者写者问题还会变形。常见的一种是限制同时读的进程数量虽然读读不冲突但读请求太多也会把内存、带宽或后端连接撑爆所以规定最多允许N个读者同时读。这时可以把原来那个二值信号量wmutex换成一个计数信号量初值设为N读者进来P一下、离开V一下就能自然地把并发读的数量卡在N以内。另一种变体是写者优先的加强版要求不仅新读者要排在写者后面而且一旦写者开始等连正在排队的读者都不能再抢在它前面。这类题目的套路都一样多一道准入信号量、调整申请顺序、必要时再加计数器。抓住信号量各管一摊、计数器保护共享状态这个思维框架变体再多也能拆开。提示做变体题时先画出资源—信号量的对应关系表明确每个信号量管什么、初值该是多少再去写代码。很多人一上来就闷头写P/V写着写着就记不清哪个信号量对应哪份资源了。6. wait和signal背后PV操作到底是怎么做到原子的6.1 P操作和V操作的标准定义前面一直用P和V现在把它们的标准行为写清楚这也是考试常考的定义// P操作wait / down void P(semaphore S) { S.value--; if (S.value 0) { // 当前进程进入S的等待队列并阻塞 block(S.queue); } } // V操作signal / up void V(semaphore S) { S.value; if (S.value 0) { // 从S的等待队列中唤醒一个进程 wakeup(S.queue); } }注意这里P里判断的是S.value 0V里判断的是S.value 0这两个边界条件的写法不同含义却是一致的。S.value在 P 之后变负说明这次申请没拿到资源得阻塞在 V 之后如果还是 0说明刚刚释放的资源立刻被一个等待者接管了所以要唤醒它。很多教材对value的语义有细微差别有的用value 0判断但P申请、V释放、负值代表等待进程数这个核心含义是统一的理解这个比死记符号重要。6.2 原子性从哪来P操作里的S.value--和判断、阻塞这一串动作必须整体不可分割地执行否则多个进程同时执行P就会出乱子。这种原子性不是天上掉下来的它靠的是硬件和内核的支持。常见的手段有几种关中断进入P操作前关掉时钟中断操作完再开、测试并设置指令、交换指令这类特殊的原子机器指令或者干脆由操作系统内核提供的系统调用来保证。这也是为什么我们在用户态根本没法真正实现一个可靠的PV操作——没有硬件和内核撑腰你写的P本身就会有竞态。理解了这一点就明白为什么信号量这东西必须由操作系统来实现而它的实现又绕不开对中断和特权指令的掌控。6.3 阻塞唤醒、忙等和真实读写锁P操作在拿不到资源时通常有两种处理方式。一种是阻塞把当前进程挂到信号量的等待队列上让出CPU等别人V的时候再唤醒它这种方式不浪费CPU。另一种是忙等自旋不阻塞而是死循环反复检查信号量这种方式在等待时间很短时反而更高效因为省去了进程切换的开销。操作系统里的信号量一般用阻塞实现而很多高性能并发库里的自旋锁就是忙等选哪种取决于等待时长和切换成本。最后说一个把理论和实际串起来的点我们平时在代码里用的读写锁本质上就是读者写者问题的一个工程化实现。无论是C语言的pthread_rwlock、Java的ReentrantReadWriteLock还是Go的sync.RWMutex它们提供的多读单写语义用的正是这套readcount 两个信号量的思路只是在其上增加了可重入、公平性选项、写者优先等更多细节。你在操作系统课上为读者写者问题掉的那些头发其实都变成了后来这些库替你把关的底气。我自己在写并发代码时只要碰到共享数据的读写场景第一反应都是先问三个问题读多还是写多写操作能不能容忍被延迟系统能不能接受某个角色被饿死。读多写少、写又必须实时的场景我会毫不犹豫倒向写者优先或者公平策略宁可让读慢一点也不让写无限期地排队。把这些取舍想明白了比背下那几行wait和signal值钱得多。
返回列表