一、引言
在操作系统中,读者-写者问题是一个经典的进程同步问题。它描述的是多个进程共享同一份数据(如文件、数据库记录或内存区域)的场景。其中:
- 读者:只读取数据,不修改数据;
- 写者:会修改数据。
由于多个读者同时读不会破坏数据,但写者与任何其他进程同时访问都可能出错,因此需要设计同步机制来保证:
- 读读允许:多个读者可以同时读;
- 读写互斥:有写者写时,读者和其他写者都不能访问;
- 写写互斥:同一时刻只能有一个写者写。
此外,还需要尽量避免饥饿 ------某个进程长期得不到执行机会。本文将重点介绍一种经典解决方案:读写公平法,并详细解释代码中每一个 P/V 操作的含义,以及考试时如何快速推导和书写。
二、最简单的版本:读者优先算法
2.1 代码实现
cpp
semaphore rw = 1; // 文件锁,控制对共享文件的互斥访问
int count = 0; // 当前正在读的读者数量
semaphore mutex = 1; // 保护 count 的互斥信号量
reader() {
while(1) {
P(mutex);
if(count == 0)
P(rw); // 第一个读者负责加锁
count++;
V(mutex);
读文件...
P(mutex);
count--;
if(count == 0)
V(rw); // 最后一个读者负责解锁
V(mutex);
}
}
writer() {
while(1) {
P(rw); // 写者直接申请文件锁
写文件...
V(rw);
}
}
2.2 读者优先的问题
该算法中,只要有一个读者正在读,写者就必须等待。而且,在写者等待期间,新到达的读者仍然可以进入(因为它们只需要通过 mutex 修改 count,不受写者影响)。如果读者源源不断地到来,写者可能永远无法获得 rw,导致写者饥饿。
问题根源:后来的读者可以"插队"到写者前面。
三、读写公平法的设计思想:如何想到加一个 w 信号量?
3.1 目标
解决写者饥饿,同时保持读读并发。
3.2 思路:让所有进程先排队
要阻止读者插队,可以设置一个"门禁"信号量 w,所有进程(读者和写者)在进入前都必须先通过 P(w)。谁先到达谁先进入,实现先来先服务。
3.3 关键设计:读者和写者对 w 的不同处理
- 读者 :只需在"登记"阶段占用
w,登记完立即释放,以保持读读并发; - 写者 :在等待文件锁
rw时不释放w,这样后面的读者就被挡在P(w)上,无法插队。
这个不对称的处理正是读写公平法的核心。
四、完整代码与信号量定义
4.1 信号量与变量
cpp
semaphore rw = 1; // 文件锁,保证读写互斥、写写互斥
int count = 0; // 当前正在读的读者数量
semaphore mutex = 1; // 保护 count 的互斥信号量
semaphore w = 1; // 排队信号量,实现读写公平
| 信号量/变量 | 初值 | 作用 |
|---|---|---|
rw |
1 | 文件访问锁,控制对共享文件的互斥访问 |
count |
0 | 记录当前有几个读者在读 |
mutex |
1 | 保护 count,防止多个读者同时修改 |
w |
1 | 全局排队锁,所有进程先在这里排队,实现先来先服务 |
4.2 写者进程
cpp
writer() {
while(1) {
P(w); // ① 进入排队区
P(rw); // ② 申请文件独占访问
写文件...
V(rw); // ③ 释放文件锁
V(w); // ④ 释放排队锁
}
}
4.3 读者进程
cpp
reader() {
while(1) {
P(w); // ① 进入排队区
P(mutex); // ② 保护 count
if(count == 0)
P(rw); // ③ 第一个读者加文件锁
count++; // ④ 读者数量+1
V(mutex); // ⑤ 释放 count 保护
V(w); // ⑥ 登记完成,立即释放排队锁
读文件...
P(mutex); // ⑦ 再次保护 count
count--; // ⑧ 读者数量-1
if(count == 0)
V(rw); // ⑨ 最后一个读者释放文件锁
V(mutex); // ⑩ 释放 count 保护
}
}
五、每个 PV 操作的含义详解
5.1 写者进程的 PV 操作
① P(w):进入排队区
- 含义 :写者申请排队锁
w,进入全局队列。 - 作用:保证所有进程都先在这里排队,实现先来先服务,防止插队。
- 若不可用:说明已有其他进程占用排队区,写者必须等待。
② P(rw):申请文件锁
- 含义 :写者已经通过排队区,申请文件锁
rw,准备独占文件。 - 作用:保证写文件时没有读者或其他写者同时访问。
- 若不可用 :说明有读者正在读或另一写者正在写。写者会阻塞在此,但不释放
w。这是实现公平的关键:写者占住排队位置,后来的读者无法越过它。
③ V(rw):释放文件锁
- 含义 :写操作完成,释放文件锁
rw。 - 作用 :允许其他等待
rw的进程(读者或写者)获取访问权。
④ V(w):释放排队锁
- 含义 :写者完全结束本次访问,释放排队锁
w。 - 作用:允许下一个进程进入排队区。
5.2 读者进程的 PV 操作
① P(w):进入排队区
- 含义 :读者也申请排队锁
w,与其他进程一起排队。 - 作用:防止读者绕过已在排队的写者。
② P(mutex):保护 count
- 含义 :准备修改共享变量
count,申请互斥信号量mutex。 - 作用 :防止多个读者同时修改
count导致数据错乱。
③ if(count == 0) P(rw):第一个读者加文件锁
- 含义 :若当前没有读者在读,说明这是第一个读者,需要先申请文件锁
rw。 - 作用:阻止写者进入,保证读者读期间无写者。
- 只有第一个读者才执行 :后续读者共享读,无需再申请
rw。
④ count++:读者数量加一
- 含义:当前读者成功登记,读者总数增加。
- 注意 :此操作在
mutex保护下进行。
⑤ V(mutex):释放 count 保护
- 含义 :修改完
count,释放mutex,允许其他读者修改。
⑥ V(w):登记完成,立即释放排队锁
- 含义 :读者已完成登记(修改
count),不再需要占用w,立即释放。 - 作用:允许其他读者进入排队区,实现读读并发。
- 关键 :如果读者在读文件期间一直占着
w,其他读者将无法进入,读读并发就被破坏。所以必须在此处立即V(w)。
⑦ P(mutex):再次保护 count
- 含义 :读者读完文件,准备减少
count,再次申请mutex。 - 作用 :防止多个读者同时修改
count。
⑧ count--:读者数量减一
- 含义:当前读者退出,计数减 1。
⑨ if(count == 0) V(rw):最后一个读者释放文件锁
- 含义 :若减少后
count == 0,说明这是最后一个读者,释放文件锁rw。 - 作用:允许等待的写者获取文件访问权。
⑩ V(mutex):释放 count 保护
- 含义 :修改完
count,释放mutex。
六、为什么写者和读者的 V(w) 位置不同?
这是整个算法最巧妙的地方,也是考试重点。
| 进程 | P(w) 之后做什么 |
V(w) 何时执行 |
|---|---|---|
| 写者 | P(rw) 申请文件锁 |
写完文件并 V(rw) 之后 |
| 读者 | P(mutex) 修改 count |
登记完 count 后立即 |
原因:
- 读者 :只需在"登记"时占用
w,登记完成即可释放,以保持读读并发。 - 写者 :如果在
P(rw)阻塞时释放w,后面的读者就能通过P(w)插队,写者又可能饥饿。所以写者必须一直占着w,直到真正获得rw并写完,才能释放w。
这样,当写者到达并等待时,它就像在队列中占住了位置,后面的读者不能越过它。这就是"读写公平"的核心。
七、典型执行流程分析
7.1 读者1 → 读者2
cpp
读者1: P(w) → P(mutex) → P(rw) → count=1 → V(mutex) → V(w) → [读文件]
读者2: P(w) → P(mutex) → count=2 → V(mutex) → V(w) → [读文件]
两个读者同时读文件,互不影响。
7.2 读者1 → 写者1 → 读者2
这是最能体现公平性的场景。
| 步骤 | 进程 | 操作 | 状态 |
|---|---|---|---|
| 1 | 读者1 | P(w) 成功,P(mutex) 成功,count==0 执行 P(rw),count=1,V(mutex),V(w) |
读者1开始读,持有 rw |
| 2 | 写者1 | P(w) 成功,P(rw) 阻塞(rw=0) |
写者1占着 w |
| 3 | 读者2 | P(w) 阻塞(w=0) |
读者2被挡在排队区 |
| 4 | 读者1 | 读完,P(mutex),count--,count==0 执行 V(rw),V(mutex) |
释放 rw,唤醒写者1 |
| 5 | 写者1 | P(rw) 成功,开始写 |
仍占着 w |
| 6 | 写者1 | 写完,V(rw),V(w) |
释放所有锁,唤醒读者2 |
| 7 | 读者2 | P(w) 成功,继续执行 |
开始读 |
最终顺序:读者1读 → 写者1写 → 读者2读。写者没有饥饿。
7.3 写者1 → 读者1 → 写者2
- 写者1先写;
- 读者1和写者2都在
w上排队,若读者1先到,则读者1先读; - 读者1读完后,写者2再写。
体现了先来先服务原则。
八、考试时如何快速写出代码?
很多同学担心记不住代码,其实只要掌握推导过程,代码可以现场推出来。
8.1 从读者优先版本出发
先写出读者优先代码:
cpp
reader() {
P(mutex);
if(count==0) P(rw);
count++;
V(mutex);
读...
P(mutex);
count--;
if(count==0) V(rw);
V(mutex);
}
writer() {
P(rw);
写...
V(rw);
}
8.2 加入 w 信号量
定义 semaphore w = 1;
- 读者 :在
P(mutex)前加P(w),在V(mutex)后加V(w)。 - 写者 :在
P(rw)前加P(w),在V(rw)后加V(w)。
即得到读写公平法代码。
8.3 记忆口诀
- 读者:进门登记,登记完开门;出门销号,最后一个关门。
- 写者:进门,拿钥匙,写,还钥匙,出门。
九、常见题型与解题模板
题型1:补全代码
题目:请补全以下读写公平法的代码。
cpp
semaphore rw = 1;
int count = 0;
semaphore mutex = 1;
semaphore w = 1;
reader() {
while(1) {
____;
P(mutex);
if(count == 0)
____;
count++;
V(mutex);
____;
读文件...
P(mutex);
count--;
if(count == 0)
____;
V(mutex);
}
}
writer() {
while(1) {
____;
P(rw);
写文件...
V(rw);
____;
}
}
答案:
cpp
P(w)
P(rw)
V(w)
V(rw)
P(w)
V(w)
题型2:分析执行顺序
题目:在读写公平法中,如果进程到达顺序为:读者1、写者1、读者2,请分析执行顺序。
答案要点:
- 读者1先读,写者1等待时占着
w,读者2被挡住; - 读者1读完,写者1写,写者1写完,读者2读。
最终顺序:读者1读 → 写者1写 → 读者2读。
题型3:对比不同算法
| 算法 | 读者优先 | 写者优先 | 读写公平法 |
|---|---|---|---|
| 读读并发 | 是 | 是 | 是 |
| 写者饥饿 | 可能发生 | 不会 | 不会 |
| 读者饥饿 | 不会 | 可能发生 | 不会 |
| 公平性 | 偏向读者 | 偏向写者 | 先来先服务 |
十、易错点与注意事项
- 写者忘记
V(w):写者写完只V(rw)不V(w),会导致后续所有进程卡在P(w),死锁。 - 读者
V(w)位置错误 :如果把读者的V(w)放在读文件之后,读者在读期间一直占着w,读读并发消失,退化为串行。 count操作忘记用mutex保护 :count是共享变量,必须用mutex保护。- 第一个/最后一个读者判断写反 :进入时
if(count == 0) P(rw);,退出时if(count == 0) V(rw);。 P(w)的位置 :必须放在P(mutex)和P(rw)之前,否则公平性失效或死锁。
十一、总结
读写公平法的核心思想一句话:
用
w让所有进程先排队,读者登记完就释放w,写者等待文件锁时占着w。
关键代码:
cpp
semaphore rw = 1, mutex = 1, 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);
}
}
理解每个 PV 操作的含义,掌握从读者优先到读写公平的推导过程,考试时就能游刃有余。
参考资料:
- 王道考研系列《操作系统》
- Silberschatz, Galvin, Gagne. Operating System Concepts
- Andrew S. Tanenbaum. Modern Operating Systems