操作系统同步:读者-写者问题与读写公平法详解(附每个 PV 操作含义)

一、引言

在操作系统中,读者-写者问题是一个经典的进程同步问题。它描述的是多个进程共享同一份数据(如文件、数据库记录或内存区域)的场景。其中:

  • 读者:只读取数据,不修改数据;
  • 写者:会修改数据。

由于多个读者同时读不会破坏数据,但写者与任何其他进程同时访问都可能出错,因此需要设计同步机制来保证:

  1. 读读允许:多个读者可以同时读;
  2. 读写互斥:有写者写时,读者和其他写者都不能访问;
  3. 写写互斥:同一时刻只能有一个写者写。

此外,还需要尽量避免饥饿 ------某个进程长期得不到执行机会。本文将重点介绍一种经典解决方案:读写公平法,并详细解释代码中每一个 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=1V(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:对比不同算法

算法 读者优先 写者优先 读写公平法
读读并发
写者饥饿 可能发生 不会 不会
读者饥饿 不会 可能发生 不会
公平性 偏向读者 偏向写者 先来先服务

十、易错点与注意事项

  1. 写者忘记 V(w) :写者写完只 V(rw)V(w),会导致后续所有进程卡在 P(w),死锁。
  2. 读者 V(w) 位置错误 :如果把读者的 V(w) 放在读文件之后,读者在读期间一直占着 w,读读并发消失,退化为串行。
  3. count 操作忘记用 mutex 保护count 是共享变量,必须用 mutex 保护。
  4. 第一个/最后一个读者判断写反 :进入时 if(count == 0) P(rw);,退出时 if(count == 0) V(rw);
  5. 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
相关推荐
zmzmzmalo1 小时前
Linux ELF文件加载与内存管理揭秘
linux·网络·数据库
chuan.bai4 小时前
Java RAG 实战(第 11 篇):RAG 知识工作台网页
java·开发语言·人工智能
离陌在学C#6 小时前
C# 事件(Event)详解:从概念到实战
开发语言·c#
Terra.K7 小时前
Java异常学习[特殊字符]
java·开发语言·学习
三言老师7 小时前
K8s集群运行时自动化运维全覆盖落地实操(下)
linux·运维·服务器·网络
葡萄城技术团队8 小时前
InfluxDB 2\.x 深度解析:核心架构、Flux 函数与制造业落地指南(三)
java·开发语言·架构
counting money8 小时前
Java IO流详解:从InputStream到文件操作实战
java·开发语言·python
wuyk5558 小时前
98.C语言易混难点:字符数组与字符串指针的底层差异
c语言·开发语言·c++·stm32·嵌入式硬件·算法
ltl8 小时前
HAProxy HTX 与 HTTP 路径:内部表示、改写落点与协议分叉
linux