PV之系统与并发的核心wu器

PV操作是操作系统和并发编程中用于i进程/线程同步的核心原语,它是理解现代操作系统如何管理并发、实现资源有序访问的基石。

PV操作的本质:信号量与原子性

它作用的对象------信号量(Semaphore)

  • 信号量是什么?

    信号量本质上是一个受保护的整型变量,它不仅包含一个计数值 ,还关联着一个等待队列(通常是PCB链表)。这个计数值代表了某类资源的可用数量。

    • 当值 S > 0 时,表示有 S 个资源可用。
    • 当值 S = 0 时,表示资源刚好用完。
    • 当值 S < 0 时,其绝对值 |S| 代表正在等待该资源的进程数量。
  • 什么是"原语"?

    PV操作是一对原语(Primitive) 。这意味着它们是原子性(Atomicity) 操作,在执行过程中不可被中断或分割。无论是P操作还是V操作,一旦开始就必须一气呵成地执行完毕,中间不会被调度器抢占或被其他中断打断。这是保证多线程/多进程环境下数据一致性的关键。在底层,这通常通过关中断 (单核CPU)或硬件原子指令(如CAS、Test-and-Set,多核CPU)来实现。

P操作与V操作的详细行为

PV操作的名称源自荷兰语:

  • P操作 (Proberen): 意为"尝试"或"试探",即申请资源。
  • V操作 (Verhogen): 意为"增加",即释放资源。

设信号量为 S,它们的具体行为如下:

P操作 (申请资源)

这是一个"减一判零,若负则等"的过程。

  1. 原子性地将 S 的值减 1 (S = S - 1)。
  2. 判断新值:
    • 如果 S ≥ 0,说明资源尚有盈余,当前进程可以继续执行。
    • 如果 S < 0,说明资源已耗尽,当前进程必须阻塞。它会被放入与该信号量关联的等待队列中,并让出CPU,进入睡眠状态,直到被唤醒。
V操作 (释放资源)

这是一个"加一唤一"的过程。

  1. 原子性地将 S 的值加 1 (S = S + 1)。
  2. 判断新值:
    • 如果 S > 0,说明没有进程在等待该资源,V操作直接结束。
    • 如果 S ≤ 0,说明有至少一个进程正在等待。此时,系统会从等待队列的队首唤醒一个进程,使其从阻塞态变为就绪态,参与下一轮的CPU调度。

为了更直观地理解,可以将其想象成图书馆借书:

  • 信号量S:代表图书馆里某本书的剩余可借数量。
  • P操作:你去借书。管理员先把书的数量减1。如果减完后数量≥0,恭喜你借到了;如果<0,说明没书了,你需要登记排队等待。
  • V操作:你还书。管理员把书的数量加1。然后他查看等待名单,如果有人排队(意味着加1之前数量≤0),就叫下一个排队的人来取书。

信号量的类型与应用场景

根据初始值和用途的不同,信号量主要分为两类:

互斥信号量 (Mutex Semaphore)
  • 特点: 初始值为 1,也称为二进制信号量。

  • 用途: 实现对临界区(Critical Section)的互斥访问。确保在任何时刻,只有一个进程能进入临界区操作共享资源。

  • 典型模式:

    semaphore mutex = 1; // 定义一个互斥信号量

    void process() {
    P(mutex); // 进入临界区前,申请锁
    // --- 临界区开始 ---
    // 访问共享资源(如修改全局变量)
    // --- 临界区结束 ---
    V(mutex); // 离开临界区后,释放锁
    }

计数信号量 (Counting Semaphore)
  • 特点: 初始值为一个非负整数 N,代表可用资源的总数。

  • 用途: 用于同步,控制对多个同类资源的并发访问,或者协调进程间的执行顺序。

  • 经典案例:生产者-消费者问题

    这个问题需要一个有限大小的缓冲区,生产者向其中放入数据,消费者从中取出数据。需要三个信号量协同工作:

    • mutex: 互斥信号量,初值为1,保护缓冲区本身不被同时读写。

    • empty: 计数信号量,初值为缓冲区大小N,表示空槽位的数量。

    • full: 计数信号量,初值为0,表示已满槽位的数量。

      semaphore empty = N; // 空缓冲区数量
      semaphore full = 0; // 满缓冲区数量
      semaphore mutex = 1; // 缓冲区互斥锁

      // 生产者进程
      void producer() {
      while(1) {
      生产一个数据;
      P(empty); // 1. 申请一个空位,有地方可放
      P(mutex); // 2. 申请进入临界区
      将数据放入缓冲区;
      V(mutex); // 3. 离开临界区
      V(full); // 4. 增加一个满位
      }
      }

      // 消费者进程
      void consumer() {
      while(1) {
      P(full); // 1. 申请一个满位,有东西可拿
      P(mutex); // 2. 申请进入临界区
      从缓冲区取出数据;
      V(mutex); // 3. 离开临界区
      V(empty); // 4. 增加一个空位
      消费数据;
      }
      }

注意: P操作的顺序至关重要。在生产者中,必须先P(empty)P(mutex)。如果颠倒,可能导致死锁:当缓冲区满时,生产者拿到mutex锁后被P(empty)阻塞,而消费者因无法获取mutex锁而无法消费数据,双方互相等待,形成僵局。

如何解决读者写者

解决"读者-写者问题"的核心在于,通过信号量巧妙地协调两类进程的访问权限:允许多个读者同时读取,但保证写者能够独占式地进行写入。

读者优先策略,它通过两个信号量和一个计数器来实现。

核心目标与读者优先策略

在深入细节前,我们先明确读者-写者问题的同步要求:

  1. 读-读允许: 多个读者可以同时读取共享资源(如文件、数据库)。
  2. 写-写互斥: 同一时间只能有一个写者进行写入。
  3. 读-写互斥: 当有写者在写入时,所有读者都不能读;反之,只要有读者在读,写者就不能写。

读者优先意味着,一旦有读者开始读取,后续到达的读者可以立即加入,即使已经有写者在等待。这可能导致写者长时间无法获得访问权,这种现象称为"写者饥饿"。

所需的数据结构

为了实现读者优先,我们需要以下三个变量:

  • rw_mutex (信号量): 初始值为 1。这是一个互斥信号量,用于实现读者和写者对共享资源的互斥访问。无论是第一个读者还是任何一个写者,都必须先获取这个信号量才能访问资源。
  • mutex (信号量): 初始值为 1。这也是一个互斥信号量,但它的作用是保护 read_count 变量本身,确保多个读者线程在修改 read_count 时不会发生竞争条件。
  • read_count (整型变量): 初始值为 0。用于记录当前正在读取数据的读者数量。
详细的PV操作步骤

下面是读者进程和写者进程的具体操作流程。

读者进程 (Reader)

读者的逻辑关键在于判断自己是否是"第一个"或"最后一个"读者。

复制代码
// 伪代码
while (true) {
    // --- 进入区 ---
    P(mutex);          // 1. 申请修改 read_count 的权限
    read_count++;      // 2. 读者数量加一
    if (read_count == 1) {
        // 3. 如果我是第一个读者,我需要阻止写者进入
        P(rw_mutex);   
    }
    V(mutex);          // 4. 释放修改 read_count 的权限

    // --- 临界区 ---
    执行读操作...       // 5. 在这里进行实际的读取工作

    // --- 退出区 ---
    P(mutex);          // 6. 再次申请修改 read_count 的权限
    read_count--;      // 7. 读者数量减一
    if (read_count == 0) {
        // 8. 如果我是最后一个读者,我离开后应该允许写者进入
        V(rw_mutex);   
    }
    V(mutex);          // 9. 释放修改 read_count 的权限
}
  • 第一个读者:read_count 从 0 变为 1 时,它会执行 P(rw_mutex),相当于为所有读者锁住了资源,阻止任何写者进入。
  • 后续读者: read_count 大于 1,它们只需增加计数即可直接进入读操作,无需再获取 rw_mutex。这使得多个读者可以并发读取。
  • 最后一个读者:read_count 从 1 变为 0 时,它会执行 V(rw_mutex),释放资源锁,唤醒可能正在等待的写者。

写者进程 (Writer)

写者的逻辑非常简单直接,因为它需要完全独占资源。

复制代码
// 伪代码
while (true) {
    // --- 进入区 ---
    P(rw_mutex);       // 1. 申请对共享资源的独占访问权

    // --- 临界区 ---
    执行写操作...       // 2. 在这里进行实际的写入工作

    // --- 退出区 ---
    V(rw_mutex);       // 3. 释放对共享资源的独占访问权
}
  • 写者只需要对 rw_mutex 执行一次 P 操作和一次 V 操作。
  • 如果当前有任何读者在读(即 rw_mutex 已被第一个读者占用),写者会在 P(rw_mutex) 处阻塞等待。
  • 同样,如果有其他写者正在写,后来的写者也会被阻塞。

正如前面提到的,这种"读者优先"的方案存在一个明显的缺陷:写者饥饿 。只要读者源源不断地到来,read_count 就不会归零,rw_mutex 也就一直被持有,导致写者可能永远无法获得执行机会。

为了解决这个问题,操作系统领域还提出了其他方案:

  • 写者优先: 当一个写者到达时,它会"锁定大门",阻止后续的读者进入,直到所有已存在的读者完成读取,然后让写者执行。这可以避免写者饥饿,但可能导致读者饥饿。

    • w (信号量): 初始值为 1这是实现写者优先的关键。它充当一个全局锁,用来阻止新来的读者,并保证写者之间的排队顺序。
    复制代码
      // 伪代码
      while (true) {
          // --- 进入区 ---
          P(w);            // 1. 【关键步骤】抢占"大门"。这行代码会阻止所有后续的读者进入。
          P(rw_mutex);     // 2. 申请对共享资源的独占访问权。如果还有读者在读,就在这里等待直到读完获取到。
    
          // --- 临界区 ---
          执行写操作...     // 3. 进行实际的写入工作。
    
          // --- 退出区 ---
          V(rw_mutex);     // 4. 释放对共享资源的独占访问权。
          V(w);            // 5. 【关键步骤】离开时打开"大门",允许后续的读者或其他写者尝试进入。
      }

    读者的逻辑也相应地发生了变化,它在最外层增加了对信号量 w 的检查。
    *

    复制代码
      // 伪代码
      while (true) {
          // --- 进入区 ---
          P(w);            // 1. 【关键步骤】检查"大门"是否被写者锁住。如果被锁,就在这里阻塞等待。
          P(mutex);        // 2. 申请修改 read_count 的权限。
          read_count++;    // 3. 读者数量加一。
          if (read_count == 1) {
              // 4. 如果是第一个读者,负责锁住资源,不让写者进入。
              P(rw_mutex); 
          }
          V(mutex);        // 5. 释放修改 read_count 的权限。
          V(w);            // 6. 【关键步骤】进门后立刻释放"大门"锁,让其他读者或写者可以尝试。
    
          // --- 临界区 ---
          执行读操作...     // 7. 进行实际的读取工作。
    
          // --- 退出区 ---
          P(mutex);        // 8. 再次申请修改 read_count 的权限。
          read_count--;    // 9. 读者数量减一。
          if (read_count == 0) {
              // 10. 如果是最后一个读者,负责释放资源锁,允许写者进入。
              V(rw_mutex); 
          }
          V(mutex);        // 11. 释放修改 read_count 的权限。
      }
  • 公平方案: 引入额外的机制(如一个服务信号量),让读者和写者按照它们请求资源的先后顺序排队,从而保证双方都不会饥饿。

    • 使用一个全局队列(FIFO),严格按照请求到达的顺序来分配锁。
    • 并发性会有所下降,因为严格的排队限制了部分并发机会,但在大多数通用文件系统或数据库中,这是最稳妥的选择。
  • 选择"写者优先"还是"读者优先",本质上是在数据实时性系统吞吐量 之间做权衡。没有绝对的好坏,只有是否适合你的业务场景。

    • 追求性能与吞吐量 →→ 读者优先
    • 追求数据准确与实时 →→ 写者优先
    • 两者都要,怕出Bug →→ 公平策略
特性 读者优先 (Reader-Preference) 写者优先 (Writer-Preference)
核心逻辑 只要有读者在读,新来的读者可以直接进,写者必须等。 一旦有写者想写,新来的读者必须等,写者插队先执行。
优点 并发度极高。读操作通常很快,允许多人同时读,系统整体吞吐量大。 数据更新及时。写者不会被无限期阻塞,保证了数据的强一致性和实时性。
缺点 写者饥饿。如果读请求源源不断,写者可能永远拿不到锁,导致数据长时间无法更新。 读者饥饿/延迟。频繁的写入会阻塞大量读取请求,导致用户感觉系统响应变慢。
形象比喻 图书馆阅览室:只要里面有人在看书,外面的人可以一直进;管理员(写者)想打扫卫生得等人全走光。 银行柜台:虽然有大堂经理(读者)在咨询,但一旦有VIP客户(写者)要办业务,后面的人得先等着。
  • 在实际开发中(比如咱们使用到的 Java 的 ReentrantReadWriteLock 或 Go 的 sync.RWMutex),或许已经发现很多标准库默认采用读者优先或非公平的混合策略。如果你处于高并发的金融或交易领域,务必检查你的锁实现是否支持"写者优先"或"公平模式",必要时需手动实现或调整配置。

注意事项与潜在风险

尽管PV操作功能强大,但使用不当会引发严重问题:

  • 死锁 (Deadlock): 最常见的问题。例如,两个进程分别持有对方需要的锁,并都在等待对方释放,导致所有相关进程都无法继续执行。
  • 饥饿 (Starvation): 某个进程由于优先级低或其他原因,长期无法获得所需的信号量,从而一直得不到执行。
  • 配对与顺序: P操作和V操作必须成对出现,且逻辑顺序要正确,否则会导致资源泄漏或上述的死锁问题。
相关推荐
Lucifer三思而后行1 小时前
⭐️ LeetCode解题系列 ⭐️ 192. 统计词频(Shell)
后端
Lucifer三思而后行1 小时前
Linux 获取磁盘的UUID和序列号WWID
后端
Lucifer三思而后行2 小时前
⭐️ LeetCode解题系列 ⭐️ 194. 转置文件(Shell)
后端
Lucifer三思而后行2 小时前
⭐️ LeetCode解题系列 ⭐️ 185. 部门工资前三高的所有员工(Oracle dense_rank函数)
后端
Lucifer三思而后行2 小时前
Linux 配置 Swap 功能
后端
Lucifer三思而后行2 小时前
⭐️ LeetCode解题系列 ⭐️ 177. 第N高的薪水(Oracle dense_rank函数)
后端
Lucifer三思而后行2 小时前
Linux 执行 df -h 卡着不动,HANG 住了,怎么破?
后端
Lucifer三思而后行2 小时前
KingbaseES V9R1C10 版本安装指南
后端
Lucifer三思而后行2 小时前
Linux 多台主机配置 ssh 互信脚本
后端