ARTICLE DETAIL

资讯详情

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

读者写者问题全解析:PV操作、信号量与读写公平

读者写者问题全解析:PV操作、信号量与读写公平 1. 读者写者问题到底在解决什么矛盾操作系统、进程、PV操作、读者写者问题这四个词放在一起基本就是进程同步这一章的分水岭。前面生产者消费者还算好理解一进到读者写者很多人就开始迷糊为什么读者之间不用互斥写者却要和所有人互斥那个readcount到底在保护什么。我第一次学这块的时候把代码背下来了可换个问法就答不上来后来自己动手写了一遍带日志的模拟程序看着线程输出一行行刷过去才算真正搞明白。先把场景说清楚。有一块共享数据比如一个配置文件、一张缓存表、一段内存里的字典有一批进程要读它另一批进程要改它。读操作不会破坏数据改操作会。如果两个人同时读谁都不影响谁没有任何问题如果一个人在读、另一个人在写读到的可能是写了一半的残缺数据如果两个人同时写那更是彻底乱套。所以约束只有三条多个读者可以同时进行写者必须独占写者进行时读者不能进。整个读者写者问题的代码说到底都是在用PV操作表达这三条约束。它适合谁来啃如果你正在学操作系统课程、准备期末或者考研复试这是必考题型如果你在做后台开发手里有读多写少的共享资源比如配置热更新、本地缓存的刷新这套模型就是现成的思路参考。哪怕你平时写的是 Java、Go 或者 Python底层的信号量模型是通用的理解了它再去看ReentrantReadWriteLock这类现成工具会有一种“原来里面就是这么干的”的感觉。我打算按从业者做项目的思路来拆先把PV操作和临界区的底层逻辑捋顺再讲三种策略为什么存在、各自取舍在哪然后手写从伪代码到能跑起来的完整实现接着真的把程序跑起来观察饥饿现象最后把踩过的坑和常见追问整理成速查表。全程不绕弯代码能给的我尽量给全。2. 把PV操作和临界区的地基打牢2.1 P操作和V操作到底做了什么P操作和V操作本质是对一个信号量做原子加减。P操作很多教材写成 wait 或者 down的含义是把信号量的值减一如果减完之后小于零当前进程就阻塞进到该信号量的等待队列里排队V操作signal 或者 up的含义是把信号量加一如果加完之后小于等于零说明等待队列里还有进程就唤醒其中一个。关键在于“原子”两个字这一减一判一阻塞在硬件层面是不可中断的否则多进程同时进来就会数错。为什么非得是原子的想象一个公共厕所只有一个坑位信号量初值为1。两个人同时判断“现在是1我可以进”都减成0然后都进去了这就是经典的竞态。PV操作就是把“判断有没有空位”和“占用空位”这两步捏成了一个不可分割的动作操作系统用关中断、原子指令或者自旋锁来实现它。理解了这一点你就能理解为什么信号量能当互斥锁用初值设为1的信号量P一次就锁住V一次就解锁。我习惯用一个生活类比记住它P就是“取号”号发完了就站着等V就是“叫号”有人办完业务就喊下一个。饭店里的取号机就是个信号量号池是共享变量。读者写者问题里我们会有好几个这样的取号机每个管一件事下面会一个个说清楚。2.2 临界区、互斥与同步的区别临界区指的是进程里访问共享资源的那段代码必须保证同一时刻只有一个或者一类进程在里面。互斥讲的是“你不让我、我不让你”同一时刻只能有一个进程进临界区比如两个写者之间。同步讲的是“我等你、你等我”进程之间要按某个先后顺序执行比如写者写完之后读者才能读。这两个词经常被混着用但读者写者问题里两种关系都存在区分的意义就出来了。在读者写者问题里写者和写者之间是互斥写者和读者之间是互斥读者和读者之间没有约束是并行的。如果把“读者优先”策略也算进来还有一层同步味道的东西当第一个读者进场时它要先确认没有写者这个确认动作和写者的封锁之间是要协调的。这就是为什么我们需要一个额外的计数器——单靠一把互斥锁没法表达“第一个读者锁门、最后一个读者开门”这种语义。打个比方图书馆自习室很多读者可以同时进但图书管理员要进来整理书架时必须清场。读者之间的规则是第一个人进来时把门锁上不让管理员进后来的人直接进最后一个人走时把门打开。这个“第一个人锁门、最后一个人开门”的记账工作就落在一个计数器加一把小锁上也就是后面代码里的readcount和rmutex。2.3 为什么需要一个计数器这是新手最容易卡的地方。读者之间不需要互斥那为什么要用rmutex去保护readcount因为readcount本身也是个共享变量多个读者同时去readcount同样会发生竞态导致计数不准进而导致门该锁的时候没锁、该开的时候没开。所以rmutex不是用来锁“读操作”的它是专门保护readcount这个变量的是“锁的锁”。把它理解成会计用的那把专用小锁就对了读写数据的大锁是wmutex。看下面这段最经典的结构先建立肌肉记忆// 读者部分的记账逻辑伪代码 P(rmutex); // 锁住计数器 if (readcount 0) // 我是第一个读者 P(wmutex); // 那就把写者的门锁上 readcount; // 计数加一 V(rmutex); // 放开计数器 // ... 这里执行真正的读操作可以和其他读者并行 ... P(rmutex); // 再次锁住计数器 readcount--; // 计数减一 if (readcount 0) // 我是最后一个读者 V(wmutex); // 那就把写者的门打开 V(rmutex); // 放开计数器这段代码只有十几行但信息密度很高。if (readcount 0)判断的是“当前我是不是第一个”只有第一个读者才有资格去抢wmutex后面的读者看到readcount 0就安心进场。退场时同理只有最后一个读者负责释放wmutex。写者那边就简单粗暴进门P(wmutex)出门V(wmutex)中间独占。掌握了这个骨架剩下的三种策略都是在它上面做加法。3. 三种策略读者优先、写者优先与读写公平3.1 为什么同一个问题会有三种解法原始约束只说了读者之间可并发、写者独占并没有规定“读者和写者同时竞争时谁先拿到资源”。这个空白就留出了策略空间。如果系统里读操作远多于写操作倾向让读者优先吞吐会更高如果写操作很关键、必须尽快落地比如配置文件更新要立刻生效那就让写者优先避免写者被源源不断的读者饿死如果还要防止任何一方被无限期拖延那就得做成按到达顺序排队的读写公平策略。三种解法的核心约束完全一致差别只在于谁能插队。这跟现实里的排班很像。会议室的预约规则可以规定“在读的人继续读来写的人在外面等”也可以规定“一旦有人要写后来的人一律排队”前者读者爽后者写者不至于饿死。选哪个没有对不对只有合不合场景。面试的时候如果只答一种往往会被追问“那写者会不会饿死”提前把三种都想清楚回答的层次就出来了。3.2 读者优先的取舍读者优先的实现就是上一节那个骨架不做任何额外动作。它的特点是只要有读者在读新来的读者可以不断加入写者只能一直等。写者的P(wmutex)会一直阻塞直到所有读者都退场。极端情况下如果读者流源源不断写者可能永远拿不到锁这就是所谓的写者饥饿。写者饥饿在真实系统里是要警惕的。举个我遇到过的例子服务端有个内存缓存在被高频读取同时有个定时任务要刷新它。如果刷新逻辑走的是写者角色而读请求量一直很大刷新可能被无限推迟缓存就一直停留在旧数据上去了。所以读者优先适合那些“写很少发生偶尔延迟也能接受”的场景一旦写操作对时效有要求就得换成下面两种。3.3 写者优先与读写公平的实现思路写者优先的核心是增加一道“拦截闸”。思路是再引入一个信号量让第一个到来的写者把这道闸拉下来之后来的读者都必须在这道闸后面排队等写者走得差不多了再放行。具体做法是用一个writecount计数器统计正在等待或正在写的写者数量第一个写者进来时锁住读者通道最后一个写者离开时打开它。读写公平就更进一步用一个排队信号量让所有进程先按到达顺序取号谁先到谁先过读和写都不能插队。它的代价是牺牲了一点并发度——哪怕连续来了十个读者只要中间夹了一个写者后面的读者就得等写者做完再一起进。但在需要绝对公平、不想任何一方饿死的场景里这个代价是划算的。下面用一张表把三种策略放在一起对比参数一目了然策略额外信号量读者并发写者饥饿读者饥饿适用场景读者优先无完全并发可能发生不会读远多于写写延迟可接受写者优先r_blockwcount_mutex写者到来后受阻不会极端下可能写关键、必须尽快落地读写公平queue按序并发不会不会双方都不能被无限拖延看到这张表你会明白加信号量不是为了炫技每一个新增的闸门都对应用户提出的一个新要求。选型的时候先问清楚业务更怕哪一方被饿死答案基本就出来了。4. 手写完整实现从伪代码到跑得起来的程序4.1 读者优先版本的完整代码先把最基础的一版写全用的是 POSIX 信号量方便在 Linux 上直接编译运行。我习惯把信号量全初始化为 1因为我们要用它做互斥锁。这里创建了三个读者线程和两个写者线程读者睡短一点写者睡长一点方便观察现象。#include stdio.h #include stdlib.h #include pthread.h #include semaphore.h #include unistd.h sem_t rmutex; // 保护 readcount 的计数器锁 sem_t wmutex; // 写者互斥锁也是读者要争取的大锁 int readcount 0; // 当前正在读的读者数量 void* reader(void* arg) { int id *(int*)arg; while (1) { sem_wait(rmutex); if (readcount 0) sem_wait(wmutex); // 第一个读者锁门 readcount; sem_post(rmutex); printf([读者 %d] 开始读取, 在线读者%d\n, id, readcount); usleep(200000); // 模拟读耗时 printf([读者 %d] 读取结束, 在线读者%d\n, id, readcount); sem_wait(rmutex); readcount--; if (readcount 0) sem_post(wmutex); // 最后一个读者开门 sem_post(rmutex); sleep(1); } return NULL; } void* writer(void* arg) { int id *(int*)arg; while (1) { sem_wait(wmutex); printf([写者 %d] 开始写入 \n, id); usleep(300000); printf([写者 %d] 写入完成 \n, id); sem_post(wmutex); sleep(1); } return NULL; } int main() { sem_init(rmutex, 0, 1); sem_init(wmutex, 0, 1); pthread_t r[3], w[2]; int rid[3] {1, 2, 3}; int wid[2] {1, 2}; for (int i 0; i 3; i) pthread_create(r[i], NULL, reader, rid[i]); for (int i 0; i 2; i) pthread_create(w[i], NULL, writer, wid[i]); for (int i 0; i 3; i) pthread_join(r[i], NULL); for (int i 0; i 2; i) pthread_join(w[i], NULL); sem_destroy(rmutex); sem_destroy(wmutex); return 0; }编译命令是gcc reader_first.c -o rf -lpthread跑起来就能看到输出。这里有个细节要提醒sem_wait对应 P 操作sem_post对应 V 操作千万别写反了。P 是“减并等待”V 是“加并唤醒”方向反了整个程序要么锁死要么形同虚设。我第一次写的时候把sem_wait和sem_post名字搞混导致写者根本没被拦住输出乱成一团debug 了半小时才发现是名字记串了。4.2 写者优先版本的代码与关键差异写者优先要加两个信号量r_block当作拦截读者的闸门wcount_mutex保护writecount计数器。writecount记录当前有多少个写者想进来第一个写者进来时把r_block用 P 操作拉下后续读者就会卡在r_block上最后一个写者离开时用 V 操作抬起闸门。代码在读者优先的基础上改写如下sem_t rmutex, wmutex, r_block, wcount_mutex; int readcount 0, writecount 0; void* reader(void* arg) { int id *(int*)arg; while (1) { sem_wait(r_block); // 若写者已拉开闸门读者在此排队 sem_wait(rmutex); if (readcount 0) sem_wait(wmutex); readcount; sem_post(rmutex); sem_post(r_block); // 允许后续读者继续进闸 printf([读者 %d] 开始读取, 在线读者%d\n, id, readcount); usleep(200000); printf([读者 %d] 读取结束, 在线读者%d\n, id, readcount); sem_wait(rmutex); readcount--; if (readcount 0) sem_post(wmutex); sem_post(rmutex); sleep(1); } return NULL; } void* writer(void* arg) { int id *(int*)arg; while (1) { sem_wait(wcount_mutex); writecount; if (writecount 1) sem_wait(r_block); // 第一个写者拉闸拦住新读者 sem_post(wcount_mutex); sem_wait(wmutex); // 写者之间仍需互斥 printf([写者 %d] 开始写入 \n, id); usleep(300000); printf([写者 %d] 写入完成 \n, id); sem_post(wmutex); sem_wait(wcount_mutex); writecount--; if (writecount 0) sem_post(r_block); // 最后一个写者抬闸放行读者 sem_post(wcount_mutex); sleep(1); } return NULL; }关键差异就在那两处writecount 1和writecount 0的判断上它们把“第一个写者拉闸、最后一个写者抬闸”这件事表达出来了。运行之后你会发现写者一出现后面来的读者就得在r_block上等着不会再无限制地插队写者饥饿的问题基本解决了。要注意r_block初值必须为 1否则读者一开始就全被拦在外面程序直接卡死。4.3 用 Python 快速验证读写公平策略如果手边没有 C 环境或者只是想快速看现象用 Python 的threading写一版验证脚本更省事。这里实现的是读写公平版多了一个queue信号量让所有进程按到达顺序取号。Python 的Semaphore就是现成的信号量acquire对应 Prelease对应 V语义完全一致。import threading import time queue threading.Semaphore(1) # 排队信号量保证先到先服务 rmutex threading.Semaphore(1) # 保护 readcount wmutex threading.Semaphore(1) # 写者互斥 readcount 0 lock threading.Lock() def reader(rid): global readcount while True: queue.acquire() # 先取号排队 with lock: if readcount 0: wmutex.acquire() # 第一个读者锁门 readcount 1 queue.release() # 取完号立刻放行让后面的人能继续取 print(f[读者 {rid}] 开始读取, 在线读者{readcount}, flushTrue) time.sleep(0.2) print(f[读者 {rid}] 读取结束, flushTrue) with lock: readcount - 1 if readcount 0: wmutex.release() # 最后一个读者开门 time.sleep(1) def writer(wid): while True: queue.acquire() # 同样先取号 wmutex.acquire() # 独占 queue.release() print(f[写者 {wid}] 开始写入 , flushTrue) time.sleep(0.3) print(f[写者 {wid}] 写入完成 , flushTrue) wmutex.release() time.sleep(1) for i in range(1, 4): threading.Thread(targetreader, args(i,), daemonTrue).start() for i in range(1, 3): threading.Thread(targetwriter, args(i,), daemonTrue).start() time.sleep(10)这段脚本是我平时给学生演示用的因为输出带时间顺序一眼能看出谁在等谁。有个细节值得注意queue.release()的位置很讲究。读者在持有queue的同时去判断和更新readcount更新完立马释放queue这样连续到来的读者才不会互相阻塞同时又保留了“谁先取号谁先执行记账”的顺序。如果把queue.release()拖到读操作之前甚至之后并发度会掉下来效果就大打折扣。5. 跑起来之后暴露的真实问题与排查技巧5.1 用日志验证现象写者饥饿不是理论代码写对了重点还在于验证。我的做法是在输出里加上时间戳和进程标识让“谁在等、等了多久”看得见。跑读者优先版本的时候会明显看到写者的“开始写入”被压到很后面前面读者一行接一行不断刷写者就是排不上。这套验证方式比任何纸上推导都有说服力毕竟饥饿这种事看一眼日志就懂了。反直觉的是饥饿现象有时只在高并发下才明显。如果你把读者线程数量设得很小、间隔又很长写者很快就能抢到锁反而看不出问题。所以测试的时候要刻意制造持续的读者流多开几个读者线程、缩短读者的休眠时间让读者几乎不间断地进入这时写者的等待才会被拉长。验证一个同步程序最忌讳的就是随便跑一下没报错就认为没问题——没报错不代表没饥饿只是压力不够。5.2 P操作顺序写反会出什么后果很多 bug 都出在 PV 操作的顺序上。拿读者退场那段来说一定是先readcount--再判断是否为零最后才V(wmutex)并且这些动作都要在P(rmutex)和V(rmutex)的保护范围内。如果把V(rmutex)提前到readcount--之前计数器的更新就失去了保护多个读者同时退场时可能都读到刚减完但还没判断的中间值导致该开门的没开或者重复开门。还有一个高频错误写者忘记在P(wmutex)之后、V(wmutex)之前执行真正的写操作直接把V挨着P写了结果锁住又马上放开形同虚设。我建议写完代码后对着“谁保护谁、谁先谁后”逐行念一遍尤其检查每个共享变量是不是都在对应的锁里被访问。readcount和writecount这两个计数器是重灾区因为大家容易只盯着数据本身忘了计数器也是被共享的。5.3 死锁和误唤醒的排查思路写者优先版本里有一个容易翻车的点r_block和wmutex的获取顺序。写者在持有wcount_mutex的时候去P(r_block)读者在持有r_block之后去P(rmutex)再P(wmutex)只要每条路径上的获取顺序保持一致就不会形成环也就不会死锁。一旦你在某处调换了顺序两个线程各持一把锁互相等程序就挂住了。排查死锁最简单的办法是gdb挂上去看每个线程卡在哪个sem_wait栈里会写得明明白白。另外要提防“惊群”式的误唤醒理解偏差。V操作唤醒的是等待队列里的一个进程被唤醒的进程会重新去竞争信号量并不保证它立刻就能拿到资源可能又被别人抢走。这不叫 bug是信号量的正常语义。理解这一点你就不会因为看到日志里某个进程被唤醒后又等了一会而怀疑代码写错了。下面把常见问题整理成一张速查表方便对照定位现象可能原因排查与修正写者长时间无法进入读者优先策略固有缺陷换写者优先或公平策略程序直接卡死信号量初值设错或P操作顺序成环检查初值是否为1统一获取顺序同时进入多个写者忘记用 wmutex 包裹写操作确保写操作在 P(wmutex)/V(wmutex) 之间读者计数异常readcount 未受 rmutex 保护所有对计数器的读写都放进锁内并发度极低queue 释放位置过早/过晚记账完成立即释放 queue6. 面试与考试里绕不开的高频追问6.1 为什么读者之间不需要互斥这个问题几乎逢考必问。答案的核心是“读操作不改变共享数据”。既然读不会破坏数据两个读者同时读读到的都是完整的内容结果等价于串行读取就没有必要限制。反过来说写操作会改变数据如果和读并发读者可能读到一个改到一半的中间状态这种“脏读”破坏了正确性所以必须互斥。把这条判断标准记住判断要不要互斥看这个操作会不会让别的操作观察到不一致的中间状态。再深一层这也是性能考量。如果强行让读者之间也互斥那和直接把整块数据锁死没有区别读多写少的场景下吞吐会惨不忍睹。读者写者问题的全部价值就体现在“把可以并发的部分放开把必须互斥的部分锁死”这个取舍上。能把这个取舍讲清楚比背代码更能体现你真的理解了。6.2 公平策略真的做到公平了吗严格说“公平”是相对的。上面用queue实现的版本保证了进程按到达顺序取号但取号之后多个读者仍然可以并发进场所以实际的执行顺序并不是一个一个串行。真正的公平在读写场景里往往指“不会出现某一方被无限期拖延”而不是“严格的先后次序”。如果业务要求绝对的先到先执行那就退化成给整个数据加一把大锁读写都串行代价是并发度归零。实际工程中还有更细的变体比如给写者设置优先级阈值、限制读者连续进入的次数上限等等。这些花样的本质都是在“吞吐”和“公平”之间找平衡点。面试时如果能把“公平的定义本身就取决于业务目标”这句话说出来通常会加分因为它体现的是工程判断而不是死记硬背。6.3 从信号量到现成锁的迁移真正写业务代码的时候多数人不会手写信号量而是用语言自带的读写锁。比如 Java 的ReentrantReadWriteLock默认是非公平的构造时可以传入true开启公平模式行为和上面讲的公平策略很接近Go 的sync.RWMutex内部用了读计数和写优先的设计。理解了底层的 PV 操作模型再看这些 API 的文档你会发现它们讨论的“公平性”“写者优先”全都能对应上。我在做配置中心缓存刷新的时候就吃过默认非公平锁的亏——高并发读把写刷新一直往后拖后来换成公平模式刷新延迟才稳定下来。这个经历的启发是底层模型看着离业务很远但它决定了你在选锁时的默认参数该不该动。读者写者问题不是一道孤立的考题它是你理解所有读写锁行为的一把钥匙。7. 我个人在这道题上踩过的坑与一点经验第一次真正动手实现我把加法当成了验证。代码跑通、输出没乱就以为万事大吉结果没有制造压力根本没看到写者饥饿。后来又因为sem_wait和sem_post写反让整个互斥失效输出交错得像没加锁一样。踩过几次之后我学乖了验证同步程序一要看日志顺序二要放大压力三要逐行核对每个共享变量是否都在锁内被访问这三条缺一条都可能漏掉 bug。我现在教别人的时候会让他先把读者优先版本默写三遍再自己推导写者优先该加哪几个信号量。默写是为了形成肌肉记忆推导是为了理解“为什么加”。等你闭着眼睛能把readcount 0那一对判断写出来再往后看公平策略、读写锁源码基本上就是一马平川了。这道题的价值从来不在于那几行代码而在于它训练你用最小的工具去表达并发约束的能力这种能力换个语言、换个业务场景都用得上。
返回列表