深入浅出讲解操作系统——进程同步
本文章是运用deepseek生成的操作系统讲解,其中还有本人对操作系统知识的理解概括以及怎么向ds提问,以及思考的过程,围绕专业讲解+通俗讲解两种模式,灵活使用AI提高学习效率,本专栏会持续更新完善。
🧸 我们正式进入 第四章:进程同步。
这一章我们从一个真实的问题出发:
为什么多个程序一起跑的时候,会“乱”?
🧵 第1课:为什么需要进程同步?
对应教材:4.1 进程同步的基本概念
🎓 第一部分:一个“翻车”的例子
场景描述
假设有两个进程:
-
P1:做一个加法
counter = counter + 1 -
P2:做一个减法
counter = counter - 1
counter 是一个共享变量,初始值为 5。
你预期:不管怎么跑,最终 counter 应该还是 5(因为 +1 再 -1,或者 -1 再 +1)。
但实际上,结果可能是 4、5、或 6
为什么?
因为 counter++ 和 counter-- 在底层不是一步完成的,而是三条机器指令:
counter++ 对应: ① 把 counter 的值读到寄存器(比如 reg = counter) ② 把寄存器加 1(reg = reg + 1) ③ 把寄存器的值写回 counter(counter = reg) counter-- 对应: ① 把 counter 的值读到寄存器(reg = counter) ② 把寄存器减 1(reg = reg - 1) ③ 把寄存器的值写回 counter(counter = reg)
翻车的时间线
假设它们交错执行:
| 时间 | P1(加1) | P2(减1) | counter 的值 |
|---|---|---|---|
| 初始 | 5 | ||
| t1 | ① 读 counter(reg=5) | 5 | |
| t2 | ① 读 counter(reg=5) | 5 | |
| t3 | ② reg = 5 + 1 = 6 | 5 | |
| t4 | ② reg = 5 - 1 = 4 | 5 | |
| t5 | ③ 写回 counter = 6 | 6 | |
| t6 | ③ 写回 counter = 4 | 4 |
最终结果:4 ❌ 不是 5
这就是与时间有关的错误。
🧠 第二部分:为什么会乱?
根本原因
多个进程同时访问共享资源,而且访问顺序不可控。
这会导致三种问题:
| 问题类型 | 说明 | 例子 |
|---|---|---|
| 结果不确定 | 同一个输入,输出可能不同 | 上面的 counter 例子 |
| 数据不一致 | 一个进程读到“中间状态”的数据 | 银行转账时查到不准确的余额 |
| 死锁 | 互相等对方释放资源 | 第3章学过 |
🎯 第三部分:解决办法——进程同步
什么是进程同步?
让多个进程按照一定的规则“排队”访问共享资源,避免乱套。
两种核心概念
| 概念 | 含义 | 像什么 |
|---|---|---|
| 互斥 | 同一时间只让一个进程进临界区 | 厕所一次只进一个人 🚻 |
| 同步 | 规定谁先谁后,按顺序执行 | 你先做菜,我后上菜 👨🍳 |
互斥是“不能同时进”,同步是“必须按顺序”。
🏠 第四部分:生活类比(帮你彻底理解)
场景:银行柜台
-
共享资源:银行柜员(一次只能服务一个人)
-
并发:多个顾客同时在排队
-
互斥:柜员一次只接待一个顾客,其他人等着
-
同步:必须先取号、再叫号、再办业务(顺序固定)
如果没有互斥:两个顾客同时挤到窗口 → 乱套
如果没有同步:顾客不取号直接冲上去 → 乱套
进程同步 = 银行叫号系统
📊 第五部分:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 为什么会乱 | 多个进程同时访问共享资源,顺序不可控 |
| 什么错误 | 结果不确定、数据不一致、死锁 |
| 怎么解决 | 进程同步(互斥 + 按顺序) |
| 互斥 | 同一时间只让一个进程访问临界资源 |
| 同步 | 规定进程之间的执行顺序 |
上节课我们用一个“counter翻车”的例子,看到了多个进程同时访问共享资源会出什么问题。
这节课我们来正式认识两个核心概念:临界资源和临界区。
🧵 第2课:临界区与临界资源
对应教材:4.1 临界区问题
🎓 第一部分:什么是临界资源?
专业定义
临界资源:一次只能被一个进程使用的资源。
通俗理解
这个东西不能两个人同时用,必须排队。
常见例子
| 临界资源 | 为什么是临界的 |
|---|---|
| 打印机 | 两个人同时打印,纸会乱套 🖨️ |
| 共享变量 | 两个人同时改一个变量,结果不确定 |
| 文件 | 两个人同时写同一个文件,会覆盖 |
| 内存中的某个数据结构 | 同时修改会乱 |
🎓 第二部分:什么是临界区?
专业定义
临界区:进程中访问临界资源的那段代码。
通俗理解
就是“危险地带”,一次只能进一个人。
🎨 代码结构
一个进程如果访问临界资源,它的代码一般分成四部分:
while (TRUE) {
进入区 // 申请进入临界区(“我想进去”)
临界区 // 访问临界资源(“危险动作”)
退出区 // 释放临界资源(“我出来了”)
剩余区 // 其他代码(“安全区域”)
}
🧸 生活类比:厕所
| 代码区域 | 厕所比喻 |
|---|---|
| 进入区 | 敲门,看里面有没有人 🚪 |
| 临界区 | 进去上厕所 🚻 |
| 退出区 | 出来,冲水,开门 🚿 |
| 剩余区 | 出来之后洗手、照镜子 |
关键:一次只能有一个人在里面上(临界区)。
🎓 第三部分:为什么临界区要互斥?
核心原因
如果两个进程同时进入临界区,共享资源就会被破坏。
用 counter 例子再看一遍
// 临界区代码(两个进程都在执行这一段)
counter = counter + 1; // 或者 counter = counter - 1
如果 P1 和 P2 同时进入这个临界区:
-
它们会互相干扰
-
最终结果不确定(可能是4、5、6)
所以必须保证:同一时间,只有一个进程在临界区里。
🎓 第四部分:临界区的划分(一个例子)
例子:两个进程共享一个变量 count
// 进程 P1
while (TRUE) {
// 进入区(申请)
// 临界区(危险操作)
count = count + 1;
// 退出区(释放)
// 剩余区(安全操作)
printf("P1 done\n");
}
// 进程 P2
while (TRUE) {
// 进入区(申请)
// 临界区(危险操作)
count = count - 1;
// 退出区(释放)
// 剩余区(安全操作)
printf("P2 done\n");
}
这里的
count = count + 1和count = count - 1就是临界区。
📊 第五部分:临界区解决问题的目标(四个准则)
这四个准则很好理解,就拿上厕所进行比喻即可,你想上厕所,此时厕所里正好也没人,那你就上,如果有人那你就等着,但是你等的时间肯定是有限的,除非那个人掉厕所里了(开玩笑),但是你肯定不会傻傻的憨等,可以干别的事,前三个是必须满足的,第四个不强制要求,你可以在厕所外面闻着味痴痴的等待。
一个好的临界区解决方案,必须满足以下四个准则:
| 准则 | 含义 | 一句话 |
|---|---|---|
| 空闲让进 | 没人用时,想进的可以直接进 | 厕所没人,直接进 🚻 |
| 忙则等待 | 有人用时,其他人必须等 | 厕所被占,排队等 |
| 有限等待 | 不能无限等下去,迟早能进 | 不能让人等到天荒地老 |
| 让权等待 | 等的时候别“干等”(别一直占用CPU) | 排队时可以玩手机,别一直敲门 |
前三个是必须满足的,第四个是优化目标(防止忙等)。
🏠 第六部分:生活类比(帮你记住)
场景:自动取款机(ATM)
-
临界资源:ATM机(一次只能一个人用)
-
临界区:输入密码 → 取钱 → 退卡的那段操作
| 准则 | ATM场景 |
|---|---|
| 空闲让进 | ATM没人 → 你直接进去 |
| 忙则等待 | 有人在用 → 你在外面排队 |
| 有限等待 | 不会让你排一辈子,总归轮到你 |
| 让权等待 | 排队时你不是一直盯着ATM,可以刷手机(不占用“注意力资源”) |
📊 第七部分:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 临界资源 | 一次只能一个人用的东西 |
| 临界区 | 访问临界资源的那段代码 |
| 进入区/退出区 | 进去前申请,出来后释放 |
| 四个准则 | 空闲让进、忙则等待、有限等待、让权等待 |
🧵 第4课:软件同步方法 —— Peterson算法
对应教材:4.2 软件同步机制
🎓 第一部分:为什么需要“软件方法”?
在没有硬件支持(比如没有 TSL 指令、没有“关中断”指令)的早期系统里,
程序员只能用普通的变量和循环来实现多个进程之间的互斥。
Peterson 算法就是用软件解决两个进程互斥问题的经典算法。
🎓 第二部分:Peterson 算法的核心思想
“我让一下,但我也要进去”
Peterson 算法用两个变量来协调两个进程:
| 变量 | 含义 |
|---|---|
flag[i] |
进程 i 是否想进临界区(true = 想进) |
turn |
该谁进了(谦让标志) |
两个进程(P0 和 P1)的代码
// 进程 i 的代码(另一个进程 j = 1 - i)
do {
flag[i] = true; // 我想进去
turn = j; // 但我让给你
while (flag[j] && turn == j); // 如果你也想进且该你进,我就等,此时处于忙则等待状态
// 临界区代码,等待结束,该我进去了
flag[i] = false; // 我结束了,我出来了
// 剩余区代码
} while (true);
🧸 第三步:用生活例子帮你彻底理解
场景:两个人想进同一个房间(只有一个门)
-
小A(P0) 和 小B(P1)
-
flag:门口挂一个牌子“我想进去”
-
turn:一个门牌“该谁进了”
情况1:只有小A想进
-
小A 挂出牌子:我想进(
flag[0] = true) -
小A 把门牌翻到“该小B进”(
turn = 1) -
检查:小B 没挂牌子(
flag[1] = false)→ 条件不成立 -
小A 直接进去 ✅
情况2:两人同时想进
-
两人同时挂出牌子(
flag[0] = true, flag[1] = true) -
同时把门牌翻到对方(门牌只有一个,
turn = 1, turn = 0)
最后一个生效的是谁?假设最后是turn = 0(该小A进) -
小A 检查:小B 挂着牌子(
flag[1]=true)且turn == 1?不,turn是 0,所以条件不成立 → 小A 进去 -
小B 检查:小A 挂着牌子(
flag[0]=true)且turn == 0✅ 条件成立 → 小B 等待
👉 只有一个人能进 ✅
📊 第四部分:Peterson 算法满足的三个准则
| 准则 | Peterson 是否满足 | 说明 |
|---|---|---|
| 忙则等待 | ✅ | 有人进,别人等 |
| 空闲让进 | ✅ | 没人进,可以进 |
| 有限等待 | ✅ | 不会无限等(有 turn 机制) |
| 让权等待 | ❌ | while 循环是“忙等”(一直占用 CPU) |
Peterson 是教学经典,但实际系统中很少用,因为忙等浪费 CPU。
🎯 第五部分:软件同步方法的局限性
| 问题 | 说明 |
|---|---|
| 忙等 | 进程在等的时候一直占用 CPU,浪费资源 |
| 复杂 | 两个进程还好,N 个进程很难实现 |
| 不可靠 | 编译器优化、乱序执行可能破坏逻辑 |
| 硬件依赖 | 有些机器上内存读写顺序不同,算法可能失效 |
所以现代操作系统更倾向用硬件同步或信号量。
📊 第六部分:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| Peterson 算法 | 用 flag + turn 解决两个进程互斥 |
| 核心思想 | “我想进,但我让给你” |
| 优点 | 纯软件,不需要硬件支持 |
| 缺点 | 忙等,只适用于两个进程 |
| 现实意义 | 教学经典,帮助你理解互斥的本质 |
好,我们继续。
上节课我们讲完了软件同步方法(Peterson算法),知道了两个进程可以通过“共享变量 + 忙等”的方式实现互斥。
但 Peterson 算法有两个明显的缺点:
-
只适用于两个进程(推广到 N 个进程非常复杂)
-
忙等(进程在等待时一直占用 CPU,浪费资源)
所以,现代操作系统更倾向于用硬件提供的特殊指令来实现互斥。
这节课我们就来讲:硬件同步方法。
🧵 第5课:硬件同步方法
对应教材:4.3 硬件同步机制
🎓 第一部分:为什么需要硬件支持?
软件方法(如Peterson)虽然“纯软件”,但有两个致命伤:
| 问题 | 说明 |
|---|---|
| 忙等 | 进程在 while 循环里空转,浪费 CPU |
| 复杂度高 | N 个进程的互斥很难用纯软件高效实现 |
硬件同步方法的核心思想是:
用一条“不可分割”的指令,同时完成“检查 + 加锁”两个动作。
这样就不会出现“两个进程同时检查到锁是空闲”的情况。
🎓 第二部分:三种硬件同步方法
方法1:关中断
核心思想:
在进入临界区之前,关闭所有中断;退出临界区时,再打开中断。
do {
关中断(); // 禁止任何中断(包括时钟中断)
临界区;
开中断(); // 恢复中断
剩余区;
} while (true);
| 优点 | 缺点 |
|---|---|
| ✅ 简单直接 | ❌ 只适用于单核 CPU(多核下,关一个核的中断没用) |
| ❌ 关中断时间过长会影响系统响应(收不到时钟中断) | |
| ❌ 权力太大,用户程序不能随便用 |
关中断只在内核态可用,用户程序不能自己关中断。
方法2:Test-and-Set(TSL / 测试并设置)
核心思想:
一条硬件指令,原子地完成“读旧值”和“写新值”。
// TSL 指令的伪代码(实际是一条CPU指令,不可中断)
int TSL(int *lock) {
int old = *lock; // 读旧值
*lock = 1; // 写新值(加锁)
return old; // 返回旧值
}
使用方式:
do {
while (TSL(&lock)); // 如果 lock 原来是 1,就一直等;如果是 0,就加锁并跳出
临界区;
lock = 0; // 释放锁
剩余区;
} while (true);
| 优点 | 缺点 |
|---|---|
| ✅ 简单,适合多核 CPU | ❌ 仍然是忙等(while 循环) |
| ✅ 一条指令完成“检查+加锁” | ❌ 不适合长时间等待 |
方法3:Swap(交换指令)
核心思想:
原子地交换两个内存单元的值。
// Swap 指令的伪代码(不可中断)
void Swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
使用方式:
do {
int key = 1; // 局部变量,初始为 1
do {
Swap(&lock, &key); // 交换 lock 和 key
} while (key == 1); // 如果 key 还是 1,说明原来 lock=1,继续等
临界区;
lock = 0; // 释放锁
剩余区;
} while (true);
| 优点 | 缺点 |
|---|---|
| ✅ 与 TSL 类似,适合多核 | ❌ 仍然是忙等 |
| ✅ 不依赖特定硬件 | ❌ 需要两次内存访问 |
🧸 第三部分:生活类比
关中断 → “开会时把门锁上”
你在一个隔音会议室里开会(临界区),
把门锁上(关中断),外面的人进不来(无法切换进程)。
开完会再开门(开中断)。
问题:如果你开会时间太长,外面的人会急死(系统响应变差)。
Test-and-Set → “试一下厕所有人吗?”
厕所门上有一个锁:
-
如果锁是“空闲”,你进去的同时把锁翻成“占用”
-
如果锁是“占用”,你就在门口等着,不断去试
问题:你在门口一直试(忙等),浪费精力(CPU)。
📊 第四部分:三种方法对比
| 方法 | 适合多核? | 是否有忙等? | 谁可以用? |
|---|---|---|---|
| 关中断 | ❌(只适合单核) | ❌(无忙等) | 只有内核 |
| Test-and-Set | ✅ | ✅(忙等) | 内核或用户(通过库) |
| Swap | ✅ | ✅(忙等) | 内核或用户(通过库) |
🎯 第五部分:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 硬件同步的核心 | 用一条不可分割的指令完成“检查+加锁” |
| 关中断 | 简单但不适合多核,只能内核用 |
| Test-and-Set | 原子操作,但仍然是忙等 |
| Swap | 与 TSL 类似,原子交换 |
| 硬件方法的共同缺点 | 忙等(进程在等的时候还在占用 CPU) |
🧸 好,我们继续。
上节课我们讲了硬件同步方法(关中断、TSL、Swap),它们解决了“检查+加锁”的原子性问题,但忙等的问题依然存在。
这节课我们讲一个更高级、更实用的同步工具:信号量。
信号量是操作系统中最经典的同步机制,也是考试和面试的绝对重点。
🧵 第6课:信号量机制(P、V操作)
对应教材:4.4 信号量机制
🎓 第一部分:为什么需要信号量?
| 之前的方法 | 存在的问题 |
|---|---|
| 软件方法(Peterson) | 只适用于两个进程,且忙等 |
| 硬件方法(TSL/Swap) | 仍然是忙等,不适合长时间等待 |
信号量的核心改进:
当进程得不到资源时,不是“忙等”,而是“主动阻塞”,让出 CPU。
这样,CPU 就可以去执行其他进程,而不是空转。
🎓 第二部分:什么是信号量?
信号量顾名思义起到的是信号作用,信号作用是什么?可以参考现实中常见的信号,最简单的就是红绿灯,红灯行绿灯停,信号量起到的就是这个作用。
专业定义
信号量 是一个特殊的整型变量,用于表示可用资源的数量,
它有两个原子操作:P(wait)和V(signal)。P 操作:申请一个资源。如果没有资源,就阻塞(去睡觉)。
V 操作:释放一个资源。如果有进程在等,就唤醒一个。它们是原子操作(不可中断)。
通过这两个操作便能避免忙等,释放CPU
P 操作 —— 申请资源:
专业写法:
void P(semaphore *S) { S->value--; // 申请一个资源 if (S->value < 0) { // 如果资源不够 block(S->L); // 把自己阻塞,加入等待队列 } }生活例子
你去停车场 🅿️:
信号量
S= 剩余车位数
P(S)= 开进去一辆车
剩余车位 你开进去后 结果 3 → 2 还有车位 正常停进去 ✅ 1 → 0 最后一个车位 正常停进去 ✅ 0 → -1 负了 没车位了,你在门口等 ❌ 负数表示“有多少车在等”。比如
-3表示有 3 辆车在门口排队。V 操作 —— 释放资源:
专业写法:
void V(semaphore *S) { S->value++; // 释放一个资源 if (S->value <= 0) { // 如果有进程在等(value 是负数或0) wakeup(S->L); // 唤醒一个等待的进程 } }🧸 生活例子
车离开停车场 🚗:
离开前剩余 离开后 结果 -3 → -2 还有车在等 叫一辆进来 ✅ -1 → 0 还有车在等 叫一辆进来 ✅ 0 → 1 没人在等 不叫,只是多了一个空位 关键:只要
value <= 0,就说明有人在等。
通俗理解
信号量 = 停车场的车位计数器 🅿️
-
初始值 = 空闲车位数
-
P= 车进去,车位减 1(如果车位为 0,就在门口等) -
V= 车出来,车位加 1(唤醒一辆等待的车)
🎓 第三部分:信号量的类型
类型1:整型信号量
int S = 1; // 初始值,表示有 1 个资源
void P(int S) { // 申请资源
while (S <= 0); // 忙等
S--;
}
void V(int S) { // 释放资源
S++;
}
| 优点 | 缺点 |
|---|---|
| 简单 | ❌ 仍然是忙等(没有解决本质问题) |
类型2:记录型信号量(重点 ✨)
这才是真正的信号量,没有忙等。
typedef struct {
int value; // 资源数量
struct process *L; // 等待队列
} semaphore;
void P(semaphore *S) {
S->value--; // 申请一个资源
if (S->value < 0) {
// 资源不够,把自己阻塞,加入等待队列
block(S->L);
}
}
void V(semaphore *S) {
S->value++; // 释放一个资源
if (S->value <= 0) {
// 有进程在等待,唤醒一个
wakeup(S->L);
}
}
🧸 第四部分:信号量的生活类比
场景:银行柜台
-
信号量 S = 空闲柜台的数量
-
初始值 = 3(3个柜台)
P 操作(申请柜台):
-
你想办业务
-
看看有空闲柜台吗?(
S.value--) -
如果
S.value < 0,说明没空闲了 → 你去休息区等(阻塞) -
否则,直接去柜台办理
V 操作(释放柜台):
-
你办完了
-
S.value++ -
如果
S.value <= 0,说明有人在等 → 叫下一个进来
🎓 第五部分:P、V 操作的含义
| 操作 | 全称 | 含义 | 生活中的动作 |
|---|---|---|---|
| P | Proberen(荷兰语:测试) | 申请资源,没有就等 | 取号,等叫号 |
| V | Verhogen(荷兰语:增加) | 释放资源,唤醒等待者 | 办完,叫下一个 |
这两个名字来自荷兰计算机科学家 Dijkstra(就是提出“银行家算法”的那位)。
📊 第六部分:信号量如何解决互斥问题和同步问题
信号量解决互斥的核心思路:
-
用一个“锁”信号量(初始为 1)
-
进入临界区前执行 P 操作(加锁)
-
离开临界区后执行 V 操作(解锁)
-
如果锁已经被别人拿走,P 操作会让当前进程主动阻塞(不是空等)
🎓 第一步:什么是“互斥锁”信号量
我们定义一个信号量:
semaphore mutex = 1;
这个 mutex 的作用:
mutex 的值 |
含义 |
|---|---|
| 1 | 锁是空闲的,没有进程在临界区 |
| 0 | 锁已被占用,有一个进程在临界区 |
| -n | 锁被占用,且有 n 个进程在等待 |
初始值必须是 1,才能起到“互斥”的作用。
🎓 第二步:两个进程的代码
// 进程 A
do {
P(&mutex); // 加锁(申请进入临界区)
// 临界区代码
V(&mutex); // 解锁(离开临界区)
// 剩余区
} while (true);
// 进程 B(完全一样的代码)
do {
P(&mutex);
// 临界区代码
V(&mutex);
} while (true);
🧠 第三步:逐步推演(两个进程同时想进)
初始状态
mutex = 1(锁是空闲的)
t1:进程 A 执行 P 操作
P 操作做的事:
P(&mutex) {
mutex.value--; // mutex 从 1 → 0
if (mutex.value < 0) {
block(); // 不会执行,因为 0 不小于 0
}
}
现在:
-
mutex = 0 -
进程 A 进入临界区
t2:进程 B 执行 P 操作(A 还没出来)
P(&mutex) {
mutex.value--; // mutex 从 0 → -1
if (mutex.value < 0) { // -1 < 0 成立
block(); // 进程 B 主动阻塞,加入等待队列
}
}
现在:
-
mutex = -1 -
进程 A 在临界区
-
进程 B 在信号量的等待队列里(不占用 CPU)
t3:进程 A 执行 V 操作(退出临界区)
V(&mutex) {
mutex.value++; // mutex 从 -1 → 0
if (mutex.value <= 0) { // 0 <= 0 成立
wakeup(); // 唤醒等待队列里的第一个进程(B)
}
}
现在:
-
mutex = 0 -
进程 B 被唤醒,重新变为就绪状态
t4:进程 B 继续执行
被唤醒后,进程 B 会回到 P 操作里被阻塞的地方,继续往下执行(进入临界区)。
🎯 第四步:为什么这样就是“互斥”?
因为任何时候,
mutex的值决定了锁的状态。
-
当
mutex = 1时:锁空闲 → 谁先 P,谁拿走锁 -
当
mutex <= 0时:锁被占用 → 想进的人必须等 -
V 操作会释放锁,并唤醒等待者
绝不可能出现两个进程同时看到 mutex = 1 的情况,
因为 P 操作是原子的(不会被中断)。
📊 一张图看懂整个过程
时间 →
┌─────────────────────────────────────────────────────┐
│ mutex = 1 │
├─────────────────────────────────────────────────────┤
│ 进程 A: P → 进入临界区 (mutex = 0) │
├─────────────────────────────────────────────────────┤
│ 进程 B: P → mutex = -1 → 阻塞(进入等待队列) │
├─────────────────────────────────────────────────────┤
│ 进程 A: V → mutex = 0 → 唤醒 B │
├─────────────────────────────────────────────────────┤
│ 进程 B: 从阻塞中醒来 → 进入临界区 │
└─────────────────────────────────────────────────────┘
🧸 第五步:对比之前的互斥方法
| 方法 | 等待方式 | 效率 |
|---|---|---|
| Peterson 算法 | 忙等(while 循环) | ❌ 浪费 CPU |
| TSL / Swap | 忙等(while 循环) | ❌ 浪费 CPU |
| 信号量(记录型) | 阻塞(不占用 CPU) | ✅ 高效 |
信号量把“等”变成了“睡”,把“能进了”变成了“叫醒”。
🎯 第六步:你容易搞混的两件事
❌ 误区1:P 操作和 V 操作可以分开用
✅ P 和 V 必须成对出现
一个 P 对应一个 V,不能多也不能少。否则会导致:
-
少一个 V → 锁永远不释放 → 死锁
-
少一个 P → 锁没加上 → 互斥失效
❌ 误区2:信号量只能用于互斥
✅ 信号量有两种用法:
-
互斥信号量:初始 = 1
-
同步信号量:初始 = 0(下一课讲)
📊 第七步:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 互斥信号量初始值 | 1 |
| P 操作 | 申请锁,没有就阻塞 |
| V 操作 | 释放锁,唤醒等待者 |
| 为什么能互斥 | 因为 P 和 V 是原子操作,不会被打断 |
| 和硬件方法的区别 | 信号量是无忙等的(阻塞) |
🎓 第一步:什么是“同步”?
定义
同步:让多个进程按照预期的顺序执行。
例子
-
进程 A 先执行某段代码
-
然后进程 B 才能执行某段代码
-
然后进程 C 才能执行……
顺序不能乱。
生活类比:接力赛 🏃
-
第一棒跑完,才能交棒给第二棒
-
第二棒跑完,才能交棒给第三棒
-
交棒这个动作,就是同步点
🎓 第二步:用信号量实现同步
核心思想
用一个初始值为 0 的信号量,作为“同步信号”。
semaphore sync = 0; // 同步信号量,初始为 0
-
V 操作:在“先执行”的进程末尾,表示“我完成了,你可以开始了”
-
P 操作:在“后执行”的进程开头,表示“等你完成了我才能继续”
代码模板
// 进程 A(先执行)
do {
// 步骤1
// 步骤2
V(&sync); // 发出“同步信号”
} while (true);
// 进程 B(后执行)
do {
P(&sync); // 等待同步信号(没有就阻塞)
// 步骤3
// 步骤4
} while (true);
🧸 第三步:逐步推演
初始状态
sync = 0
情况1:进程 A 先执行
-
进程 A 执行到
V(&sync):V(&sync) { sync.value++; // 0 → 1 // 没有进程在等,唤醒操作不执行 } -
进程 B 执行
P(&sync):P(&sync) { sync.value--; // 1 → 0 if (sync.value < 0) { // 0 < 0? 不成立 // 不阻塞,直接继续 } } -
进程 B 继续执行 ✅
结果:A 先做完,B 后做 ✅
情况2:进程 B 先执行(这是关键!)
-
进程 B 执行
P(&sync):P(&sync) { sync.value--; // 0 → -1 if (sync.value < 0) { // -1 < 0 ✅ 成立 block(); // 进程 B 阻塞,进入等待队列 } }-
sync = -1 -
进程 B 被挂起,不占用 CPU
-
-
进程 A 执行到
V(&sync):V(&sync) { sync.value++; // -1 → 0 if (sync.value <= 0) { // 0 <= 0 ✅ 成立 wakeup(); // 唤醒等待队列中的进程 B } } -
进程 B 被唤醒,继续执行 ✅
结果:B 虽然先到,但被迫等 A;最终顺序还是 A 先,B 后 ✅
📊 第四步:同步 vs 互斥(对比表)
| 对比维度 | 互斥 | 同步 |
|---|---|---|
| 信号量初始值 | 1 | 0 |
| 核心目的 | 保证同时只有一人 | 保证先后顺序 |
| 生活类比 | 厕所门锁 | 接力棒 |
| 代码位置 | 同一段临界区 | 不同的代码块 |
| P 操作位置 | 临界区前 | 后执行段开始 |
| V 操作位置 | 临界区后 | 先执行段结束 |
🧸 第五步:一个更完整的同步例子
场景:两个进程,要求 P1 先输出 “Hello”,P2 再输出 “World”
semaphore sync = 0; // 同步信号量
// 进程 P1
void P1() {
printf("Hello ");
V(&sync); // 告诉 P2:我好了
}
// 进程 P2
void P2() {
P(&sync); // 等 P1 完成
printf("World");
}
运行结果永远都是:Hello World
不会出现 World Hello ✅
🎯 第六步:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 同步是什么 | 控制多个进程的执行顺序 |
| 同步信号量初始值 | 0 |
| P 操作位置 | 后执行的进程开头(等信号) |
| V 操作位置 | 先执行的进程末尾(发信号) |
| 和互斥的区别 | 互斥是“不能同时进”;同步是“必须按顺序来” |
🧸 第七部分:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 信号量是什么 | 表示可用资源数量的变量 |
| P 操作 | 申请资源,没有就阻塞 |
| V 操作 | 释放资源,唤醒等待者 |
| 记录型信号量 | 无忙等,有等待队列 |
| 互斥信号量 | 初始为 1,用于实现互斥 |
| 同步信号量 | 初始为 0,用于实现顺序控制 |
前两课我们分别学了:
-
信号量实现互斥(初始为 1,P/V 包住临界区)
-
信号量实现同步(初始为 0,一个 P,一个 V)
这节课,我们把这两个知识结合起来,解决操作系统里最经典、最常考的问题:生产者-消费者问题。
这是信号量应用的“综合题”,也是面试和笔试的常客。
🧵 第8课:生产者-消费者问题
对应教材:4.6.1 生产者-消费者问题
🎓 第一部分:问题描述
场景
-
有一个固定大小的缓冲区(比如可以放 10 个数据)
-
生产者进程:生产数据,放入缓冲区
-
消费者进程:从缓冲区取走数据,消费它
约束条件
| 约束 | 说明 |
|---|---|
| 互斥 | 同一时间只能有一个进程操作缓冲区(不能同时写或同时读) |
| 同步1 | 缓冲区满时,生产者必须等(不能放) |
| 同步2 | 缓冲区空时,消费者必须等(不能取) |
🧸 第二步:用生活例子理解
场景:一个包子铺 🥟
-
缓冲区:蒸笼(最多放 10 个包子)
-
生产者:厨师做包子
-
消费者:顾客买包子
规则:
-
蒸笼满了 → 厨师不能做了(得等顾客买)
-
蒸笼空了 → 顾客不能买了(得等厨师做)
-
同一时间只能一个人动蒸笼(不能同时放和取)
🎓 第三步:需要几个信号量?
我们需要 3 个信号量:
| 信号量 | 初始值 | 作用 |
|---|---|---|
mutex |
1 | 保护缓冲区(互斥) |
empty |
N(缓冲区大小) | 空闲位置的数量(同步) |
full |
0 | 已占用的位置数量(同步) |
empty和full的关系:empty + full = N
🎓 第四步:生产者代码
semaphore mutex = 1; // 缓冲区互斥锁
semaphore empty = 10; // 空闲位置数(初始10)
semaphore full = 0; // 已占用位置数(初始0)
void producer() {
while (true) {
// 生产一个数据 item
P(&empty); // 等待空闲位置(如果没有空位,就等)
P(&mutex); // 加锁(操作缓冲区)
// 把 item 放入缓冲区
V(&mutex); // 解锁
V(&full); // 增加一个“已占用”位置,唤醒可能等待的消费者
}
}
🎓 第五步:消费者代码
void consumer() {
while (true) {
P(&full); // 等待有数据(如果没有数据,就等)
P(&mutex); // 加锁(操作缓冲区)
// 从缓冲区取出一个数据 item
V(&mutex); // 解锁
V(&empty); // 增加一个“空闲”位置,唤醒可能等待的生产者
// 消费数据 item
}
}
🧠 第六步:逐步推演(关键!)
初始状态
empty = 10, full = 0, mutex = 1
缓冲区是空的
场景1:消费者先跑
消费者执行 P(&full):
-
full从 0 → -1 -
条件成立(
full < 0)→ 消费者阻塞
没有数据,消费者只能等 ✅
场景2:生产者放数据
生产者执行 P(&empty):
-
empty从 10 → 9(有空格,不阻塞)
生产者执行 P(&mutex):
-
mutex从 1 → 0(拿到锁)
生产者放入数据
生产者执行 V(&mutex):
-
mutex从 0 → 1(释放锁)
生产者执行 V(&full):
-
full从 0 → 1 -
条件
full <= 0?不成立(1 <= 0是 false)
但等一下,这里有个细节:V 操作里是if (value <= 0)才唤醒。
等一下,这里需要重新确认 V 操作的逻辑。
🔧 第七部分:V 操作的“唤醒”逻辑
在记录型信号量中,V 操作的标准实现是:
void V(semaphore *S) {
S->value++;
if (S->value <= 0) {
wakeup(S->L);
}
}
关键:
<= 0才唤醒,不是< 0。
为什么?因为 value <= 0 时,说明有进程在等待队列里(value 的绝对值 = 等待进程数)。
例子验证
初始 full = 0,消费者先跑:
-
P(&full):full变成 -1,消费者阻塞 -
V(&full)之前:full = -1 -
V(&full):full++→ 从 -1 变成 0 -
0 <= 0成立 → 唤醒消费者 ✅
🎯 第八部分:为什么 P(&empty) 和 P(&mutex) 顺序不能换?
如果先 P(&mutex) 再 P(&empty):
-
缓冲区满时,生产者拿到
mutex(锁住缓冲区) -
然后发现
empty = 0,执行P(&empty)→ 生产者阻塞 -
但锁还被它拿着!
-
消费者想要取数据,需要先
P(&mutex)→ 消费者也阻塞
结果:死锁 💀
结论:必须先申请资源(
empty/full),再加锁(mutex)。
📊 第九部分:这一课的小结
| 你要记住的 | 一句话 |
|---|---|
| 信号量 | mutex, empty, full |
| 生产者 | P(empty) → P(mutex) → 放 → V(mutex) → V(full) |
| 消费者 | P(full) → P(mutex) → 取 → V(mutex) → V(empty) |
| 顺序关键 | 先申请资源,再加锁(否则会死锁) |
| 满时 | 生产者等(P(empty) 阻塞) |
| 空时 | 消费者等(P(full) 阻塞) |
更多推荐



所有评论(0)