🧸 我们正式进入 第四章:进程同步

这一章我们从一个真实的问题出发:

为什么多个程序一起跑的时候,会“乱”?


🧵 第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想进
  1. 小A 挂出牌子:我想进(flag[0] = true

  2. 小A 把门牌翻到“该小B进”(turn = 1

  3. 检查:小B 没挂牌子(flag[1] = false)→ 条件不成立

  4. 小A 直接进去 ✅


情况2:两人同时想进
  1. 两人同时挂出牌子(flag[0] = true, flag[1] = true

  2. 同时把门牌翻到对方(门牌只有一个,turn = 1, turn = 0
    最后一个生效的是谁?假设最后是 turn = 0(该小A进)

  3. 小A 检查:小B 挂着牌子(flag[1]=true)且 turn == 1?不,turn 是 0,所以条件不成立 → 小A 进去

  4. 小B 检查:小A 挂着牌子(flag[0]=true)且 turn == 0 ✅ 条件成立 → 小B 等待

👉 只有一个人能进 ✅


📊 第四部分:Peterson 算法满足的三个准则

准则 Peterson 是否满足 说明
忙则等待 有人进,别人等
空闲让进 没人进,可以进
有限等待 不会无限等(有 turn 机制)
让权等待 while 循环是“忙等”(一直占用 CPU)

Peterson 是教学经典,但实际系统中很少用,因为忙等浪费 CPU。


🎯 第五部分:软件同步方法的局限性

问题 说明
忙等 进程在等的时候一直占用 CPU,浪费资源
复杂 两个进程还好,N 个进程很难实现
不可靠 编译器优化、乱序执行可能破坏逻辑
硬件依赖 有些机器上内存读写顺序不同,算法可能失效

所以现代操作系统更倾向用硬件同步信号量


📊 第六部分:这一课的小结

你要记住的 一句话
Peterson 算法 用 flag + turn 解决两个进程互斥
核心思想 “我想进,但我让给你”
优点 纯软件,不需要硬件支持
缺点 忙等,只适用于两个进程
现实意义 教学经典,帮助你理解互斥的本质

 好,我们继续。

上节课我们讲完了软件同步方法(Peterson算法),知道了两个进程可以通过“共享变量 + 忙等”的方式实现互斥。
但 Peterson 算法有两个明显的缺点:

  1. 只适用于两个进程(推广到 N 个进程非常复杂)

  2. 忙等(进程在等待时一直占用 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 操作(申请柜台)

  1. 你想办业务

  2. 看看有空闲柜台吗?(S.value--

  3. 如果 S.value < 0,说明没空闲了 → 你去休息区等(阻塞

  4. 否则,直接去柜台办理

V 操作(释放柜台)

  1. 你办完了

  2. S.value++

  3. 如果 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 先执行

  1. 进程 A 执行到 V(&sync)

    V(&sync) {
        sync.value++;   // 0 → 1
        // 没有进程在等,唤醒操作不执行
    }
  2. 进程 B 执行 P(&sync)

    P(&sync) {
        sync.value--;   // 1 → 0
        if (sync.value < 0) { // 0 < 0? 不成立
            // 不阻塞,直接继续
        }
    }
  3. 进程 B 继续执行 ✅

结果:A 先做完,B 后做 ✅


情况2:进程 B 先执行(这是关键!)

  1. 进程 B 执行 P(&sync)

    P(&sync) {
        sync.value--;   // 0 → -1
        if (sync.value < 0) { // -1 < 0 ✅ 成立
            block();    // 进程 B 阻塞,进入等待队列
        }
    }
    • sync = -1

    • 进程 B 被挂起,不占用 CPU

  2. 进程 A 执行到 V(&sync)

    V(&sync) {
        sync.value++;   // -1 → 0
        if (sync.value <= 0) { // 0 <= 0 ✅ 成立
            wakeup();   // 唤醒等待队列中的进程 B
        }
    }
  3. 进程 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)

  1. 缓冲区满时,生产者拿到 mutex(锁住缓冲区)

  2. 然后发现 empty = 0,执行 P(&empty) → 生产者阻塞

  3. 但锁还被它拿着!

  4. 消费者想要取数据,需要先 P(&mutex) → 消费者也阻塞

结果:死锁 💀

结论:必须先申请资源(empty/full),再加锁(mutex)。


📊 第九部分:这一课的小结

你要记住的 一句话
信号量 mutexemptyfull
生产者 P(empty) → P(mutex) → 放 → V(mutex) → V(full)
消费者 P(full) → P(mutex) → 取 → V(mutex) → V(empty)
顺序关键 先申请资源,再加锁(否则会死锁)
满时 生产者等(P(empty) 阻塞)
空时 消费者等(P(full) 阻塞)
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐