是什么
-
定义:多个并发进程通过互斥信号量(mutex)与多组资源信号量(empty/full)协同访问容量有限的共享缓冲区,满足“缓冲区互斥操作”与“特定产品供需同步”的问题模型。
-
大白话:货架容量有限,生产商送货前得看有没有空位;送上架的商品分为奇数和偶数两种,买奇数的人只挑奇数,买偶数的人只挑偶数。为了防止多个人同时伸手把货架打翻,拿放动作还要单独上一把锁。
核心内容
1. 通用分析原理与四步解题法
遇到任何复杂的 PV 同步互斥大题,按以下四步流水线化推进:
第一步:找出所有进程角色与动作(划分实体)
-
明确谁是生产方、谁是消费方,提取各自的核心动作(生产、放入、取出、消费)。
-
本题角色:(通用生产)、(奇数消费)、(偶数消费)。
第二步:识别资源与产品类别(确定同步信号量)
-
空闲槽位:无论放什么,只要占格子就需要申请空槽 设置
empty = N。 -
多类产品拆分:如果消费者对产品有针对性要求(如奇数/偶数、水果/信件),==绝对不能只用一个统一的
full==,必须为每类产品单独设立资源信号量:-
奇数产品数量
odd = 0 -
偶数产品数量
even = 0
-
第三步:识别共享临界资源(确定互斥信号量)
-
判断是否有两个及以上进程会并发访问同一块存储区域(如公用同一个缓冲区或指针)。
-
题干声明“互斥使用一个包含 个单元的缓冲区” 设置
mutex = 1。
第四步:推导执行链条与代码模板(套用防死锁骨架)
-
核心铁律:先申请资源(P操作),再申请互斥锁;先释放互斥锁,再释放资源(V操作)。
-
操作骨架:
C
// 生产者模板
P(empty); // 1. 占空位
P(mutex); // 2. 拿互斥锁
put_item(); // 3. 临界区:放入数据
V(mutex); // 4. 还互斥锁
if (条件A) V(full_A); // 5. 唤醒对应消费者
else V(full_B);
// 消费者模板
P(full_X); // 1. 占专属商品
P(mutex); // 2. 拿互斥锁
get_item(); // 3. 临界区:取出数据
V(mutex); // 4. 还互斥锁
V(empty); // 5. 腾出空位
consume(); // 6. 进程私有处理(放在临界区外!)
2. 信号量体系结构对照表
| 信号量名称 | 信号量类型 | 初始值 | 作用与物理含义 |
|---|---|---|---|
mutex | 互斥信号量 | 保证 互斥访问共享缓冲区 | |
empty | 资源同步信号量 | 表示缓冲区中当前剩余的空闲单元数量 | |
odd | 资源同步信号量 | 表示缓冲区中已存放且未被消费的奇数数量 | |
even | 资源同步信号量 | 表示缓冲区中已存放且未被消费的偶数数量 |
例题
-
题目:
【2009 统考真题】三个进程 互斥使用一个包含 个单元的缓冲区。 每次用
produce()生成一个正整数并用put()送入缓冲区某一空单元; 每次用getodd()从该缓冲区中取出一个奇数并用countodd()统计奇数个数; 每次用geteven()从该缓冲区中取出一个偶数并用counteven()统计偶数个数。请用信号量机制实现这三个进程的同步与互斥活动,并说明所定义的信号量的含义(要求用伪代码描述)。 -
分析:
-
缓冲区大小为 ,空闲单元初值为 ;放入奇数唤醒 ,放入偶数唤醒 ,因此需要分离
odd和even两个同步信号量。 -
三者共享同一缓冲区,修改数据指针会冲突,因此需要
mutex实现互斥。 -
产生数字后进行奇偶判断,走不同的 V 操作分支。
-
-
答案:
C
// 1. 信号量定义及初值说明
semaphore mutex = 1; // 控制对缓冲区的互斥访问
semaphore empty = N; // 缓冲区中空闲单元的数量,初值为 N
semaphore odd = 0; // 缓冲区中奇数的数量,初值为 0
semaphore even = 0; // 缓冲区中偶数的数量,初值为 0
// 2. 进程伪代码描述
void P1() {
int x;
while (1) {
x = produce(); // 生产一个正整数
P(empty); // 申请一个空闲缓冲区单元
P(mutex); // 申请缓冲区互斥锁
put(); // 将 x 送入缓冲区某一空单元
V(mutex); // 释放缓冲区互斥锁
if (x % 2 != 0) {
V(odd); // 放入奇数,奇数可用数+1
} else {
V(even); // 放入偶数,偶数可用数+1
}
}
}
void P2() {
while (1) {
P(odd); // 申请一个奇数
P(mutex); // 申请缓冲区互斥锁
getodd(); // 从缓冲区取出一个奇数
V(mutex); // 释放缓冲区互斥锁
V(empty); // 释放一个空闲单元
countodd(); // 统计奇数个数(放在临界区外)
}
}
void P3() {
while (1) {
P(even); // 申请一个偶数
P(mutex); // 申请缓冲区互斥锁
geteven(); // 从缓冲区取出一个偶数
V(mutex); // 释放缓冲区互斥锁
V(empty); // 释放一个空闲单元
counteven(); // 统计偶数个数(放在临界区外)
}
}
⚠️ 易错点
Warning
P 操作顺序颠倒引发死锁(高频扣分点):
必须先
P(empty)再P(mutex)。如果写反为
P(mutex); P(empty);:当缓冲区满时, 锁死mutex挂在empty队列上, 无法通过P(mutex)进入缓冲区取数,造成永久互相等待死锁。非共享操作塞入临界区(并发性能降级):
produce()、countodd()、counteven()属于进程私有处理,==严禁放入P(mutex)与V(mutex)之间==。放入临界区虽逻辑正确,但会无意义拉长阻塞时间,阅卷标准通常会扣除规范分。合并信号量导致假死:
- 切忌将
odd和even合并为一个full。如果只有一个full,缓冲区只有偶数时P2可能会抢到锁并一直寻找奇数,导致队列假死。
1. semaphore mutex = 1; 里的 semaphore 是什么意思?
semaphore 是操作系统中用来表示“信号量”的变量类型(类似于 C 语言中的 int、float)。
在标准 C 语言中并没有原生保留字叫 semaphore,但在 408 考研和经典操作系统教材(如汤子瀛《计算机操作系统》、王道)中,为了方便书写伪代码,通常用它来声明一个信号量对象。
它的底层结构实际上是一个包含数值和等待队列的结构体:
C
typedef struct {
int value; // 资源计数器(表示当前可用资源数,负数表示排队等待的进程数)
struct PCB *queue; // 进程等待队列(因该资源不足而被阻塞的进程链表)
} semaphore;
写 semaphore mutex = 1;,本质上就是告诉阅卷老师:“我定义了一个初值为 1 的互斥信号量变量,名字叫 mutex”。
2. 为什么申请的是 odd(P(odd)),释放的却是 empty(V(empty))?
这是因为“进出缓冲区”消耗的是不同的资源,产生了不同的结果。
-
进入缓冲区前(消费者的前提条件):
-
作为一个“奇数消费者”,它的需求是:缓冲区里必须有一个奇数给我取。
-
所以它必须先执行
P(odd)。如果此时缓冲区里只有偶数或者为空,odd == 0, 就会老老实实挂起等待,不去瞎捣乱。
-
-
离开缓冲区后(消费者的产出结果):
-
成功取走了一个奇数,那块内存单元就空出来了。
-
空间腾出来了,谁最需要这个空间?是生产者 !
-
所以 必须执行
V(empty),让“空闲槽位数”加 1,并负责唤醒可能正因为缓冲区满了而被卡住的 。
-
一句话底层逻辑:
消耗了一个奇数(所以减 odd),创造了一个空位(所以加 empty)。这并不是“同一种资源的配对”,而是两个进程间的交替供需闭环: 耗空位供奇数, 耗奇数供空位。
3. countodd() 必须在 V(empty) 之后才能统计吗?
从逻辑正确性来说,不需要;但在考研答题和工程规范中,极度推荐放在最后。
-
只要在
V(mutex)之后就可以:-
countodd()是 自己的私有统计动作(比如执行total_odd++),它完全不触碰公用的共享缓冲区。 -
只要出了缓冲区互斥区(即在
V(mutex)之后),放在V(empty)之前或之后,程序逻辑都完全正确,绝不会发生死锁或数据错乱。
-
-
为什么强烈建议放在
V(empty)之后?-
让生产者尽早干活(提升并发度):
你取完数据、出了临界区后,第一件利他事情就是赶紧
V(empty)告诉生产者“有空位了,你快来放”,让生产者并发去跑。通知完之后,你再慢慢去算你的countodd(),这样 CPU 和 I/O/内存 的并行效率最高。 -
阅卷标准偏好:
考研阅卷老师最喜欢的标准模式就是:“解互斥锁 释放资源 私有处理”。把属于当前进程的私有耗时操作放在最外层,规范且不易被误扣步骤分。
-
P、V 操作是操作系统中用于实现进程同步与互斥的两个最核心的原语(Primitive)。 它们由荷兰计算机科学家 Dijkstra 提出,P 来自荷兰语 Proberen(意为测试/申请),V 来自荷兰语 Verhogen(意为增加/释放)。
原语的核心特性是原子性(Atomicity):执行过程中不可被打断,通常由硬件指令(如关中断、TSL 等)提供支持。
核心数据结构
一个记录型信号量 S 包含两个核心分量:
-
S.value:整型数值,表示当前可用资源的数量。 -
S.L:等待队列指针,挂接因申请该资源不足而处于阻塞态的进程 PCB 链表。
P 操作(Wait / 申请资源)
-
伪代码定义:
C
void P(semaphore S) { S.value--; // 1. 资源数先减 1 if (S.value < 0) { // 2. 减完后若小于 0,说明资源不够用 block(S.L); // 3. 将当前进程设为阻塞态,挂入等待队列 S.L } } -
物理含义:
-
先扣减资源:
S.value--。 -
若
S.value >= 0:说明系统刚才还有空余资源,当前进程申请成功,继续往下执行。 -
若
S.value < 0:说明资源已被耗尽,当前进程无法继续运行,系统执行block原语将其主动阻塞并挂入等待队列,让出 CPU。
-
V 操作(Signal / 释放资源)
-
伪代码定义:
C
void V(semaphore S) { S.value++; // 1. 资源数先加 1 if (S.value <= 0) { // 2. 加完后若仍小于或等于 0,说明有进程在排队 wakeup(S.L); // 3. 从等待队列 S.L 中唤醒一个阻塞的进程 } } -
物理含义:
-
先归还资源:
S.value++。 -
若
S.value > 0:说明等待队列里根本没有进程在排队,释放后资源留给后续进程直接取用。 -
若
S.value <= 0:说明在此之前队列里至少有一个进程正被卡住。系统调用wakeup原语从队列中唤醒一个阻塞进程,将其状态改为就绪态并放入就绪队列。
-
信号量数值的物理状态总结
| S.value 的取值状态 | 实际物理含义 |
|---|---|
S.value > 0 | 系统中当前剩余的可用资源数量(等于该数值)。此时等待队列为空。 |
S.value = 0 | 资源刚好全部分配完毕,系统中既无多余资源,也没有进程被阻塞。 |
S.value < 0 | 资源已耗尽,且其绝对值 精确等于当前正在等待队列中被阻塞的进程总数。 |
经典应用模式对比
-
实现互斥(临界区保护):
-
信号量初值设为 (
mutex = 1)。 -
紧挨临界区写成:
P(mutex); [访问临界区]; V(mutex);。 -
在同一个进程内部成对出现。
-
-
实现同步(前驱后继关系):
-
信号量初值设为 (
sync = 0)。 -
规则是“必须先做操作 A,才能做操作 B”。
-
进程 1(负责前驱动作):
[执行 A]; V(sync); -
进程 2(负责后继动作):
P(sync); [执行 B]; -
分别分布在两个不同的进程中(前驱做完 V 释放,后继做前 P 检查)。
-