死磕信号量实现读者-写者:我被自己写的代码坑惨了

死磕信号量实现读者-写者:我被自己写的代码坑惨了 目录一开始我看到题想先不看答案解决“经典问题”第一回合“完美”避开死锁却撞上了死锁第二回合死锁修好了又掉进了“并发度”的坑第三回合病急乱投医想用“关中断”当外挂结语最近学习并发编程感觉自己对信号量和 PV 操作已经拿捏了。正好碰到经典的“读者-写者问题”结果这一做不要紧硬是把自己绕进去反复推翻了好几次最后才搞明白到底坑在哪。一开始我看到题想先不看答案解决“经典问题”我当时并没有在操作系统中接触到用到“计数器“的题目我想信号量既然表示可用资源的数量那么拿来统计有多少个读者在读岂不正好进入时若该进程为第一个读者我就申请临界资源使用权退出时若是最后一个读者我就归还临界资源使用权。我第一版代码是这么写的semaphore sWrite 1; // 写者锁 semaphore sRead 0; // 读者计数器 Reader() { // 进入区 if (sRead 0) { P(sWrite); } V(sRead); // 读临界区 ... // 退出区 if (sRead 0) { V(sWrite); } // 先判断再减1 P(sRead); } Writer() { P(sWrite); // 写临界区 ... V(sWrite); }写完之后我还挺得意觉得逻辑非常完美。但是我交给老师一看老师开始帮我推演“退出区”的代码时我当场人就傻了。第一回合“完美”避开死锁却撞上了死锁我的退出区写的是先if(sRead 0)再P(sRead)。这就意味着只要当前同时有读者在读sRead肯定大于0这个if判断就永远为假那个V(sWrite)压根就执行不到。这意味着什么意味着第一个进来的读者拿走了写锁后面无论多少读者进进出出写锁再也没人归还了那写者不就直接被饿死死锁了吗我当时真的是拍大腿代码确实看着挺对一跑逻辑全是坑。第二回合死锁修好了又掉进了“并发度”的坑赶紧改呗把退出区的顺序调换一下改成“先减1再判断”这样最后一个读者走的时候sRead变 0 了就正好能把写锁还回去。// 退出区调整了顺序先减1再判断 P(sRead); // 先减1 if (sRead 0) { V(sWrite); } // 再判断是否为0改完之后我长舒一口气觉得终于把死锁解决了。我再次兴致勃勃的交给老师看改良版的结果我又往前一看“进入区”的逻辑if(sRead 0) { P(sWrite); }。问题又来了。这俩操作根本就不是原子的假设两个读者同时到达他俩都在一瞬间看到sRead 0然后都去抢这个P(sWrite)锁。最后肯定会有一个读者抢到锁进去了另一个被死死卡在外面等着。等第一个读完释放了第二个才能进。这哪里还是并发读这简直就是强制大家串行排队啊死锁是没了但是并发度直接归零。我终于悟了解决死锁只是及格线不损失并发性能才是真本事。第三回合病急乱投医想用“关中断”当外挂当时我脑子一热既然判断和加锁这俩动作没法一气呵成那我动大招吧把它俩包在“关中断”里面看你怎么插队还好我没真这么写代码而是直接把想法告诉了老师发现这想法离谱到家了多核CPU直接无效你关了自己这个CPU核心的中断别的核心上的进程照样抢资源啊根本无法保证全局原子性。用户态根本不让用关中断是操作系统的底层特权指令。你要是在写的用户程序代码里搞个CLI程序直接报“非法指令”崩给你看。副作用大到爆炸如果真让你关了中断系统时钟、磁盘读写、网卡中断全停了只要几毫秒你自己的系统就先干宕机了。所以关中断这种操作系统内核的专属手术刀根本就不是我们写用户态程序该拿的武器。终极顿悟老老实实回到教科书兜了一大圈我真的服气了。最后老老实实按照正统解法引入一把mutex锁老老实实地在锁保护下访问Rcount。int Rcount 0; semaphore mutex 1; // 保护 Rcount semaphore sWrite 1; // 写者锁 Reader() { P(mutex); if (Rcount 0) { P(sWrite); } Rcount; V(mutex); // 读临界区 ... P(mutex); Rcount--; if (Rcount 0) { V(sWrite); } V(mutex); }这层mutex锁的精髓就在于它把“判断if”和“修改Rcount”牢牢捆绑在了一起。在锁的保护下哪怕是最普通的if也变得绝对安全。结语这次折腾虽然耽误了不少时间但我个人觉得非常值。它让我彻底想明白了两件事并发编程别靠直觉代码写得“感觉没问题”和真正“原子安全”完全是两码事。判断和行动如果不绑定必出错。死锁只是起步真正的考验在于你怎么把多线程的并发性能最大化。光顾着防死锁最后搞出个串行执行那不仅没优化反而添乱。这一波从“自以为是”到“彻底清醒”的过程确实挺酸爽的希望对你也有一点点帮助