经典同步问题
这一节是第 2 章大题的落点。四个经典问题各自演示了一种典型的同步结构,它们的价值在于模式可迁移——考题很少原样考,但几乎总是这几个模式的变形。
按 2.3.4 的三步法读每一段代码:先看有哪些关系,再看设了哪些信号量及其初值,最后看 P/V 放在哪。
机制
生产者-消费者问题
问题:一组生产者和一组消费者共享一个容量为
关系分析:
- 互斥:缓冲区是临界资源,同一时刻只能有一个进程访问 →
mutex = 1 - 同步一:缓冲区满时生产者不能放 → 需要”空位”这个资源 →
empty = n - 同步二:缓冲区空时消费者不能取 → 需要”产品”这个资源 →
full = 0
semaphore mutex = 1; // 互斥访问缓冲区
semaphore empty = n; // 空闲缓冲区数量,初值 = n
semaphore full = 0; // 产品数量,初值 = 0
producer() {
while (1) {
生产一个产品;
P(empty); // 申请一个空位(同步)
P(mutex); // 进入临界区(互斥)
把产品放入缓冲区;
V(mutex); // 离开临界区
V(full); // 产品数 +1(同步)
}
}
consumer() {
while (1) {
P(full); // 申请一个产品(同步)
P(mutex); // 进入临界区(互斥)
从缓冲区取出产品;
V(mutex); // 离开临界区
V(empty); // 空位数 +1(同步)
消费产品;
}
}这段代码有四处必须记牢的要点:
① empty 和 full 是一对此消彼长的量。 任何时刻 empty + full ≤ n。生产者消耗 empty 产出 full,消费者消耗 full 产出 empty。
② 两个 P 的顺序不能换。 必须先 P(empty) 再 P(mutex)。理由见 2.3.4 的”不要抱着锁睡觉”——若先拿 mutex 再发现没空位而阻塞,消费者就永远进不来腾空位,死锁。
③ 两个 V 的顺序可以换。 V(mutex) 和 V(full) 谁先都不会死锁,因为 V 永不阻塞执行者。
④ 生产和消费动作要放在临界区外。 生产一个产品 和 消费产品 都写在 P/V 之外,因为它们不访问缓冲区。放进临界区只会无谓延长互斥时间,降低并发度。
多生产者-多消费者问题
问题(盘子模型):桌上有一个盘子,只能放一个水果。爸爸放苹果,妈妈放橘子,儿子专吃橘子,女儿专吃苹果。
关键在于识别:这里的”同步关系”是按品类分开的。
semaphore plate = 1; // 盘子里的空位,初值 1(盘子只能放一个)
semaphore apple = 0; // 盘中苹果数
semaphore orange = 0; // 盘中橘子数
father() { while(1){ 准备苹果; P(plate); 放苹果; V(apple); } }
mother() { while(1){ 准备橘子; P(plate); 放橘子; V(orange); } }
daughter() { while(1){ P(apple); 取苹果; V(plate); 吃; } }
son() { while(1){ P(orange); 取橘子; V(plate); 吃; } }为什么这里不需要 mutex? 因为 plate 的初值是 1,它同时兼任了互斥的角色——任何时刻最多只有一个进程能通过 P(plate) 去访问盘子。
范围提示:这个”省掉 mutex”的简化只在缓冲区容量为 1 时成立。若盘子能放 plate = n),就必须重新加上 mutex = 1,否则多个进程会同时往盘子里放,产生竞态。这是本题最常见的变形考法。
吸烟者问题
问题:三个吸烟者各自拥有无限的一种原料(烟草 / 纸 / 胶水),供应者每次把另外两种放到桌上,拥有第三种的那个吸烟者才能卷烟抽。
这个问题演示的模式是”一对多的定向通知”——供应者要精确唤醒三者中的某一个。
semaphore offer1 = 0; // 桌上是"纸+胶水"组合(对应吸烟者1有烟草)
semaphore offer2 = 0; // 桌上是"烟草+胶水"组合
semaphore offer3 = 0; // 桌上是"烟草+纸"组合
semaphore finish = 0; // 吸烟完成的信号
int i = 0;
provider() {
while (1) {
if (i == 0) V(offer1);
else if (i == 1) V(offer2);
else V(offer3);
i = (i + 1) % 3;
P(finish); // 等这一轮抽完才放下一组
}
}
smoker1() { while(1) { P(offer1); 拿组合并卷烟; V(finish); 抽烟; } }
smoker2() { while(1) { P(offer2); 拿组合并卷烟; V(finish); 抽烟; } }
smoker3() { while(1) { P(offer3); 拿组合并卷烟; V(finish); 抽烟; } }要点:finish 保证了供应者不会在上一轮还没抽完时就放下一组,桌子被当作容量为 1 的缓冲区使用。
读者-写者问题
问题:多个进程共享一个文件。读者之间可以同时读,但写者写时不允许任何其他进程(读者或写者)访问。
这个问题的特殊之处在于它不是简单的互斥——读-读之间是允许并发的,只有读-写、写-写才互斥。
版本一:读者优先(基本版)
semaphore rw = 1; // 保证读写互斥、写写互斥
semaphore mutex = 1; // 保护 count 这个变量
int count = 0; // 当前正在读的读者数量
writer() {
while (1) {
P(rw);
写文件;
V(rw);
}
}
reader() {
while (1) {
P(mutex);
if (count == 0) P(rw); // 【第一个】读者负责上锁
count++;
V(mutex);
读文件;
P(mutex);
count--;
if (count == 0) V(rw); // 【最后一个】读者负责解锁
V(mutex);
}
}核心技巧是”计数 + 首尾负责制”:只有第一个到达的读者去 P(rw) 抢锁,只有最后一个离开的读者去 V(rw) 放锁。中间的读者直接进出,不碰 rw。这样就实现了”读者之间可并发”。
mutex 的作用是保护 count——如果没有它,两个读者可能同时读到 count == 0 从而都去执行 P(rw),其中一个会被永久阻塞。
这个版本的问题:写者可能饥饿。 只要读者源源不断地到来,count 就永远不会归零,rw 就永远不会被释放,等在 P(rw) 上的写者永远进不去。
版本二:读写公平
疑问点:读写公平法的设计思想
读写公平的实现思路是什么?该方法不易凭直觉想到,其设计动机需要说明。
先看清版本一的漏洞究竟在哪,公平版的设计就自然了。
版本一中,写者在 P(rw) 上阻塞等待。而后来的读者根本不去碰 rw——它们发现 count > 0,就直接 count++ 然后去读了。也就是说:
读者有一条绕过写者的”捷径”,它们从等待的写者身边源源不断地插队进去。
要恢复公平,就得堵掉这条捷径。办法是设一个所有人都必须先通过的”闸门”信号量 w:
semaphore rw = 1; // 读写互斥
semaphore mutex = 1; // 保护 count
semaphore w = 1; // 【新增】排队闸门:读者和写者都要先过这一关
int count = 0;
writer() {
while (1) {
P(w); // ← 进闸门
P(rw);
写文件;
V(rw);
V(w); // ← 出闸门(写完才出)
}
}
reader() {
while (1) {
P(w); // ← 进闸门
P(mutex);
if (count == 0) P(rw);
count++;
V(mutex);
V(w); // ← 出闸门(注意:登记完就出,不等读完)
读文件;
P(mutex);
count--;
if (count == 0) V(rw);
V(mutex);
}
}这个设计有两个精心安排的点,缺一不可:
① 写者在整个写入期间一直持有 w。
注意写者的 V(w) 放在写完之后。这意味着:当写者卡在 P(rw) 上等读者退场时,它手里还攥着 w。于是后来的读者全部堵在 P(w) 这一关上,无法再进入去增加 count。捷径被堵死了。
② 读者只在”登记”期间持有 w,登记完立刻释放。
读者的 V(w) 放在 读文件 之前。这样一个读者登记完就把闸门让出来,下一个读者可以立刻进来登记并一起读——读者之间的并发性没有被破坏。
完整走一遍(这是理解该算法最快的方式):
| 时刻 | 事件 | w | rw | count | 说明 |
|---|---|---|---|---|---|
| 1 | 读者 R1 到达 | R1 持有→释放 | R1 持有 | 1 | 第一个读者上锁 rw,登记完释放 w,开始读 |
| 2 | 写者 W 到达 | W 持有 | R1 占着 | 1 | W 过了闸门,但卡在 P(rw),并一直攥着 w |
| 3 | 读者 R2 到达 | 被 W 挡住 | — | 1 | R2 堵在 P(w),无法插队 |
| 4 | R1 读完 | W 仍持有 | R1 释放 | 0 | 最后一个读者解锁 rw |
| 5 | W 获得 rw | W 持有 | W 持有 | 0 | 写者终于开始写 |
| 6 | W 写完 | W 释放 | W 释放 | 0 | R2 的 P(w) 这才通过 |
写者不会再被饿死。
一句话概括这个设计:w 是一道按到达顺序放行的闸门,它剥夺了读者”从等待的写者身旁溜进去”的特权。 而通过控制 V(w) 放置的位置——写者写完才放、读者登记完就放——同时保住了读者之间的并发。
这个版本严格说是”公平”而非”写者优先”:它只保证了不饿死,先到者先服务,并没有给写者更高的优先级。
版本三:写者优先
疑问点:如何实现真正的写者优先
若要求只要有写者在等待,后续读者一律让路,应当如何实现?
思路是给写者一个更强的武器:不是被动地排队,而是主动地把读者挡在门外。做法是让写者群体共同持有一个专门用来封锁读者的信号量 rd,采用与读者相同的”首尾负责制”:
semaphore rw = 1; // 读写互斥
semaphore mutexR = 1; // 保护 countR
semaphore mutexW = 1; // 保护 countW
semaphore rd = 1; // 写者用来封锁读者的信号量
int countR = 0, countW = 0;
writer() {
P(mutexW);
if (countW == 0) P(rd); // 【第一个】写者到来就封锁读者入口
countW++;
V(mutexW);
P(rw);
写文件;
V(rw);
P(mutexW);
countW--;
if (countW == 0) V(rd); // 【最后一个】写者离开才解封读者
V(mutexW);
}
reader() {
P(rd); // ← 只要有写者在(等待或正在写),这里就过不去
P(mutexR);
if (countR == 0) P(rw);
countR++;
V(mutexR);
V(rd);
读文件;
P(mutexR);
countR--;
if (countR == 0) V(rw);
V(mutexR);
}关键在于 rd 被”写者群体”整体持有:第一个写者一到就 P(rd) 把读者入口封死,此后所有写者陆续进出,直到最后一个写者离开才 V(rd) 解封。只要还有任何一个写者在排队或写入,读者就一律被挡在 P(rd)。
代价是读者可能饥饿——只要写者不断到来,读者就永远进不去。这与版本一恰好互为镜像。
三个版本的实质区别,就在于”谁能插队”:
| 版本 | 谁能插到对方前面 | 谁可能饿死 |
|---|---|---|
| 一 · 读者优先 | 读者(绕过 rw 直接 count++) | 写者 |
| 二 · 读写公平 | 谁也不能(w 闸门按序放行) | 无 |
| 三 · 写者优先 | 写者(用 rd 封锁读者入口) | 读者 |
哲学家进餐问题
问题:5 位哲学家围坐,相邻两人之间放 1 根筷子(共 5 根)。哲学家要同时拿起左右两根才能进餐。
这个问题演示的是死锁:若 5 人同时拿起左边的筷子,每人都持有一根、等待另一根,形成循环等待,全体死锁。这正是 2.4 死锁的经典模型。
三种解法,各自破坏死锁的不同必要条件:
① 限制人数:最多允许 4 位哲学家同时去拿筷子。
semaphore chopstick[5] = {1,1,1,1,1};
semaphore count = 4; // 最多 4 人同时尝试
Pi() {
P(count);
P(chopstick[i]);
P(chopstick[(i+1)%5]);
进餐;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
V(count);
}道理:5 根筷子分给 4 个人,必有一人能拿到两根,循环等待就被打破了。
② 奇偶分流:奇数号哲学家先拿左边,偶数号先拿右边。
这样相邻两人会争抢同一根筷子,其中一人必然失败并让出,不会形成首尾相接的环。
③ 把”拿两根”变成原子操作:仅当左右两根筷子都可用时,才允许拿起。
semaphore mutex = 1; // 把取筷子过程变成互斥的
Pi() {
P(mutex);
P(chopstick[i]);
P(chopstick[(i+1)%5]);
V(mutex); // 注意 V(mutex) 在两个 P 之后
进餐;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
}道理:mutex 保证同一时刻只有一个人在拿筷子,他要么两根都拿到,要么一根也拿不到(阻塞在临界区内),不会出现”人人各持一根”的中间状态。
边界
各问题考的是什么模式
这四个问题的真正价值是模式,不是题目本身。 考试中出现的都是变形,认出模式就能套:
| 问题 | 模式 | 迁移场景 |
|---|---|---|
| 生产者-消费者 | 缓冲区 + 一对此消彼长的资源信号量 | 一切”有容量限制的传递” |
| 多生产者-多消费者 | 按品类拆分同步信号量 | 多种数据流共用一个缓冲区 |
| 吸烟者 | 一对多定向通知 | 一个进程按轮次唤醒不同的对象 |
| 读者-写者 | 计数 + 首尾负责制 | 读读可并发、读写互斥的任何资源 |
| 哲学家进餐 | 循环等待与死锁避免 | 需要同时持有多个资源的场景 |
什么时候可以省掉 mutex
多生产者-多消费者的盘子模型里省掉了 mutex,但这不是通用简化。判据是:
当某个同步信号量的初值为 1,且它已经保证了”同一时刻只有一个进程能访问缓冲区”时,才可以省掉互斥信号量。
一旦缓冲区容量 > 1,同步信号量就不再能兼任互斥职责,必须补回 mutex。这是变形题的高频陷阱。
读者-写者中 mutex 保护的是什么
初学时容易以为 mutex 是用来保护文件的。不是——保护文件的是 rw。
mutex 保护的是 count 这个计数变量。如果不保护,两个读者可能同时读到 count == 0 并都去执行 P(rw),导致其中一个被永久阻塞(因为只有一个能成功,另一个会阻塞在 rw 上,而它的 count++ 也没执行完)。
凡是”多个进程要读改写同一个计数器”的地方,都需要一个专门的互斥信号量。 这是可迁移的通用规则。
首尾负责制的两种用法互为镜像
读者-写者里出现了两次”首尾负责制”,方向正好相反:
- 读者用它来共享资源:第一个读者
P(rw)占住文件,最后一个读者V(rw)放开。目的是让同类之间能并发。 - 写者用它来封锁对手(版本三):第一个写者
P(rd)封住读者入口,最后一个写者V(rd)解封。目的是把异类挡在外面。
同一个技巧,一个对内、一个对外。
对照速查
| 生产者-消费者 | 值 |
|---|---|
mutex | 1(互斥访问缓冲区) |
empty | n(空位数) |
full | 0(产品数) |
| 顺序纪律 | 先同步 P 后互斥 P;V 可换序 |
| 读者-写者三版本 | 新增信号量 | 谁可能饿死 |
|---|---|---|
| 读者优先 | 无 | 写者 |
| 读写公平 | w = 1(闸门) | 无 |
| 写者优先 | rd = 1(封锁读者) | 读者 |
| 哲学家三解法 | 手段 |
|---|---|
| 限制人数 | count = 4 |
| 奇偶分流 | 奇数先左、偶数先右 |
| 原子取筷 | mutex 包住两次 P(chopstick) |
考点
- 生产者-消费者的三个信号量及初值,以及两个 P 不可换序
- 生产/消费动作必须放在临界区之外
- 读者-写者的计数 + 首尾负责制;
mutex保护的是count而非文件 - 读写公平中
w的作用:堵住读者绕过写者的捷径;写者写完才V(w),读者登记完即V(w) - 三个版本各自谁会饿死
- 哲学家进餐的三种解法及各自原理
- 缓冲区容量为 1 时可省
mutex,容量 > 1 时必须补回
链接
- 🏠 返回总览:操作系统第 2 章:进程与线程总览
- ⬅️ 上一节:2.3.4 信号量
- ➡️ 下一节:2.3.6 管程
- 🔗 工具与三步法见 2.3.4 信号量
- 📖 名词库:第 2 章名词库