个人主页-爱因斯晨
文章专栏-操作系统

#前言
在操作系统学习中,PV操作这个板块较为复杂,本文用大白话一文讲清其中逻辑。
我们在学习进程控制的时候,绕不开进程同步和互斥问题。为什么会有这两个概念呢?随着多道进程并发执行,多个进程会共享系统资源。以打印机为例,多个进程同时访问同一台独占资源时,会发生资源争抢、数据错乱,因此需要进程互斥:指多个进程竞争临界资源,同一时刻仅允许一个进程访问,保证资源排他性使用。
而部分进程存在前后协作关系,必须遵循固定执行顺序,比如生产完成后才能消费,由此产生进程同步:指具有依赖关系的进程,按既定次序协调执行,保证任务有序推进。二者常借助 PV 操作实现并发环境下的正常运行。
为什么一定要用PV操作呢?别的方法不行吗?别的也是可以的,之前我们学进程互斥的时候学过两类方法:软件实现,硬件实现。软件实现就是靠自己写代码控制,靠几个变量互相标记,我要用了,你不要来。但是代码很复杂,且只能用单进程,多进程就乱套了,还满足不了。硬件实现解决了原子性的问题,但是都没有满足让权等待,这就是导致进程忙等,CPU浪费严重。
一、浅析PV操作
PV操作就很好的解决了这一点,通过阻塞和唤醒机制消除了忙等待的问题,当进程执行P操作资源不足时,进程会被阻塞并进入等待队列,主动放弃CPU资源,不再循环占用CPU, 待其他进程执行V操作释放资源后,再唤醒等待进程。因此信号量机制满足让权等待,资源利用率更高。
PV操作具体是什么?
要谈这个,就要先说什么是信号量,信号量相当于你的钱包余额,主要记录资源的载体,就是管理资源的计数器,说书面一点就是:操作系统用来管理共享资源、控制进程有序执行的一种机制,分为整形信号量和记录型信号量。
整型信号量只有一个整数数值,仅能记录资源数量,搭配简单的 P、V 操作,资源不足时进程会循环等待,产生忙等待,效率很低。只会数数,不会安排排队。
而我们课本所学的记录型信号量,在数值基础上增加了进程等待队列;这个就很高级了,会数数,还会让你排队等待,负责让你休息,和负责叫醒你,可以说是个很负责的管家。
P 操作负责申请资源,让信号量数值减一,资源不够时就把进程阻塞、进入队列等待并让出 CPU;
V 操作负责释放资源,让信号量数值加一,如果有进程在排队就将其唤醒。
二、经典例题
我们常常学的那几个例子:生产者-消费者问题,还有他的延申(多生产者-多消费者问题),读者写者,哲学家进餐问题。这些不是新的知识点,而是PV操作这个工具在不同场景下的不同应用。
2.1万能公式
P=拿资源/等条件
V=放资源/发信号
mutex互斥信号量=1:只要多个进程抢同一个缓冲区、同一个公共数据,就加他,防止打架。(互斥锁)
同步信号量就是控制先后顺序的
2.2生产者-消费者(源头-祖宗)
场景
生产者:往缓冲区放数据
消费者:从缓冲区拿数据
缓冲区是有限大小的仓库
两个硬性的规则
1.仓库满了-生产者不准放,没地方放了(要等待)
2.仓库空了-消费者不准拿,没东西了咋拿(要等待)
3.仓库同一时间只能一个人操作(互斥)
三个信号量
empty:空格子数量 → 初值 = 仓库大小
full:装满的格子 → 初值 = 0
mutex:互斥锁 → 初值 = 1
这里要特别注意:mutex的值的变化:
他的初始值一定是1,代表,临界区一次只能进一个进程。
只有两个操作
- P(
mutex) 上锁 - V(
mutex) 解锁
① 执行 P (mutex)
mutex = mutex - 1
② 执行 V (mutex)
mutex = mutex + 1
就这么简单。
全程三种情况,值怎么变(重点)
情况 1:没人在用资源
mutex = 1
进程 A 来了:
执行 P (mutex)
mutex = 0
✅ 上锁成功,A 进入临界区
情况 2:A 正在用(mutex=0)
这时进程 B 也想来,执行 P (mutex)
mutex = -1
mutex < 0
👉 B 阻塞、排队、睡觉,不能进去
情况 3:A 用完了,离开
A 执行 V (mutex)
mutex = 0
发现外面有人排队(B)
👉 自动唤醒 B,B 进去干活
B 结束再 V 一下,mutex 回到 1
总结 mutex 数值含义
mutex= 1:没人占用,空闲mutex= 0:有 1 个进程正在用mutex< 0 :有人在用,还有 |mutex| 个进程在排队等着
逻辑
生产者:
c
P (empty) // 先找空位,没空位就卡住
P (mutex) // 上锁,独占仓库
放数据
V (mutex) // 解锁
V (full) // 成品 + 1,唤醒消费者
消费者:
c
P (full) // 先等有产品,没货就卡住
P (mutex) // 上锁
拿数据
V (mutex) // 解锁
V (empty) // 空位 + 1,唤醒生产者
伪代码:
信号量定义:
c
semaphore empty = n; // 空缓冲区数量,初值n
semaphore full = 0; // 满缓冲区数量,初值0
semaphore mutex = 1; // 互斥锁,初值1
生产者:
c
生产者进程(){
while(1){
生产一个产品;
P(empty); // ① 先同步:申请空位
P(mutex); // ② 后互斥:上锁
把产品放入缓冲区;
V(mutex); // 先解锁
V(full); // 再通知消费者
}
}
消费者:
c
消费者进程(){
while(1){
P(full); // ① 先同步:申请产品
P(mutex); // ② 后互斥:上锁
从缓冲区取出产品;
V(mutex); // 先解锁
V(empty); // 再通知生产者
消费产品;
}
}
如果把 P(mutex) 写在最前面可以吗?
不可以!
c
// ❌ 错误:互斥P在前,同步P在后
P(mutex); // 先上锁
P(empty); // 再等空位
会出现什么致命问题 ?死锁!
举个场景:
-
缓冲区已经满了 ,
empty = 0 -
一个生产者先执行
P(mutex)→ 抢到锁,
mutex=0,把缓冲区
死死锁住
-
然后执行
P(empty)→ empty 减 1 变成 -1,
阻塞卡住
-
重点:
它拿着
mutex锁不放、又卡在原地睡觉✅ 消费者想拿产品释放空位?
进不来!因为拿不到
mutex锁✅ 别的生产者也进不来
👉 所有人全部卡死,死锁
2.3多生产者、多消费者
区别就一点:
不止 1 个工厂、不止 1 个买家,一堆人同时生产、一堆人同时消费
会出现什么新问题?
多个生产者同时往里塞 、多个消费者同时往外抢,
更容易乱套、覆盖数据、重复拿数据。
解决办法:
信号量完全不变,代码完全不变!
只靠 mutex 互斥锁拦住所有人:
不管你有多少个生产者、多少个消费者,
只要操作缓冲区,必须排队,一个一个来。
代码实现:
c
semaphore empty = n; // 空缓冲区数量
semaphore full = 0; // 满缓冲区数量
semaphore mutex = 1; // 互斥锁:保护缓冲区
process 生产者()
{
while(1)
{
生产产品;
P(empty); // 【先同步】申请空位
P(mutex); // 互斥上锁
放入缓冲区;
V(mutex); // 解锁
V(full); // 唤醒消费者
}
}
process 消费者()
{
while(1)
{
P(full); // 【先同步】申请产品
P(mutex); // 互斥上锁
取出缓冲区数据;
V(mutex); // 解锁
V(empty); // 唤醒生产者
消费产品;
}
}
核心思路:
多生产、多消费,代码和单生产单消费完全一样
mutex 作用:
不管多少个进程,同一时刻只能一个人操作缓冲区
防止多个生产者同时放数据、覆盖出错
硬性规则:
P 同步在前,P 互斥在后,防止死锁
V 操作顺序随便换,不会出错
结论
多生产多消费 = 原版生产者消费者 + 加强互斥保护
套路一模一样,只是进程变多了,模型完全通用。
2.4读者--写者问题(第二大经典)
场景
一个文件:
- 读者:只看、不改
- 写者:修改、改动
规则
- 读和读:可以一起读(不用互斥)
- 读和写:绝对不能同时
- 写和写:绝对不能同时
核心思想
不用所有人都排队
- 一堆读者一起看书没问题
- 只要来了写者,全部锁住
类比:图书馆
自习的人随便坐(读者)
装修的人一来,全部清场(写者)
c
semaphore rmutex = 1; // 保护读者计数器
semaphore wmutex = 1; // 读写、写写互斥
int read_count = 0; // 记录当前读者数量
process 读者()
{
while(1)
{
P(rmutex); // 锁住计数器
if(read_count == 0)
{
P(wmutex); // 第一个读者:禁止写者进来
}
read_count++;
V(rmutex); // 释放计数器
读取文件; // 多个读者可以一起读
//只要没人在读(read_count=0)
//第一个进来的读者,直接锁住写者
//后面再来一堆读者:
//人数 + 1 就行,不用再锁文件,大家一起读
P(rmutex);
read_count--;
if(read_count == 0)
{
V(wmutex); // 最后一个读者:放开写者
}
V(rmutex);
}
}
//只要还有人在读,写者继续等着
//最后一个读者离开
//才会解锁文件,放写者进来修改
process 写者()
{
while(1)
{
P(wmutex); // 上锁,禁止读、禁止其他写
修改文件;
V(wmutex); // 解锁
}
}
//写者非常霸道:
//只要拿到 wmutex,
//所有读者、其他写者 全部拦住
思路大白话
-
只用第一个读者 加写锁,最后一个读者解写锁
-
中间来的读者,直接读,不用反复加解锁,提高效率
-
写者全程独占,只要有人读,写者就进不来
读者写者问题,核心就是为了提升效率,允许多个读者同时访问文件。依靠一个读者计数器统计当前阅读人数,用一把小锁保护计数不乱,再依靠文件大锁限制写者。
依靠「首位读者上锁、末尾读者解锁」的规则,完美实现:读读可以并行、读写互相排斥、写写无法同时执行,这也是 PV 操作在不同场景下灵活运用的典型例子。
2.5哲学家进餐问题(最好理解防死锁)
场景
5 个哲学家坐一圈,
左右两边各一根筷子
要吃饭:必须同时拿到左右两根筷子
哲学家 i:左手筷子 i,右手筷子 (i+1)%5
坑点
如果 5 个人同时拿左手筷子
所有人都少一根,全部卡死 → 死锁
解决办法(三选一)
- 最多只允许 4 个人同时拿筷子
- 奇数先拿左、偶数先拿右(顺序错开)
- 必须一次性拿两根筷子
👉 本质:用 PV 操作限制资源申请顺序,防止卡死
c
semaphore chopstick[5] = {1,1,1,1,1};
semaphore eat_limit = 4; // 限制:最多4个人拿筷子,防死锁
process 哲学家(int i)
{
while(1)
{
思考;
P(eat_limit); // 限制最多4人抢筷子
P(chopstick[i]); // 拿左手筷子
P(chopstick[(i+1)%5]);// 拿右手筷子
吃饭;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
V(eat_limit); // 释放名额
}
}
核心防死锁思路
原本会死锁的情况:
5 个人同时拿起左手筷子,所有人都缺右手筷子,全员卡死。
解决办法:
加一个eat_limit=4
最多允许 4 个哲学家竞争筷子
必然至少有一个人能拿到两根筷子、顺利吃饭
打破死锁的「循环等待」
哲学家进餐问题,是典型的资源争夺造成死锁的案例。
五位哲学家环形就坐,筷子相互共用,若所有人同时抢占单侧筷子,就会互相僵持、全部无法进餐。
利用 PV 操作增加人数限制,只允许最多四人竞争筷子资源,破坏循环等待的条件,保证总有哲学家可以拿到一双筷子正常吃饭。
本质就是通过合理限制资源抢夺顺序与并发数量,用 PV 操作预防死锁。
三、总结
综上所述,信号量与 PV 操作是解决进程同步、互斥问题的核心手段。相比于有明显缺陷的软件、硬件实现,以记录型信号量为基础的 PV 操作,依靠阻塞与唤醒机制,真正实现了让权等待,大幅提升系统资源利用率。
生产者消费者、多生产多消费、读者写者、哲学家进餐等经典问题,本质上都是 PV 操作的实际应用延伸,万变不离其宗。只要牢记P 负责申请等待、V 负责释放唤醒 ,分清同步信号量与互斥mutex的作用,严守「先同步、后互斥」的 P 操作顺序,就能避免死锁与数据混乱。弄懂一套核心逻辑,就可以轻松吃透所有进程并发的经典模型。