为什么 P 是“< 0”才阻塞,V 是“≤ 0”才唤醒?
- P 先减 1。减完 < 0,说明减之前 ≤ 0,本来就没资源了 → 阻塞。
- V 先加 1。加完 ≤ 0,说明加之前 < 0,队列里有人在等 → 唤醒一个。
记法:先改值,再判断;P 看“< 0”,V 看“≤ 0”。
多个进程并发跑,会遇到两类麻烦:抢同一个资源(互斥),以及必须按先后次序配合(同步)。这一页从最朴素的软件锁讲起,一步步看每种做法漏了哪条原则,最后落到信号量和 P/V 大题。
turn 表示轮到谁,用完把 turn 交给对方flag[i] 表示“我想进”。先看对方的 flag,再把自己的 flag 置 truewhile(S<=0); S--; signal:S++;临界资源:一次只允许一个进程使用的资源,比如打印机、共享变量、缓冲区。
临界区:进程中访问临界资源的那段代码。临界区是代码,临界资源是资源,别混。
访问临界资源的代码可以拆成四段:
do {
entry section; // 进入区:上锁
critical section; // 临界区
exit section; // 退出区:解锁
remainder section; // 剩余区
} while (true);
| 同步 | 互斥 | |
|---|---|---|
| 又叫 | 直接制约关系 | 间接制约关系 |
| 起因 | 进程之间相互合作,有先后次序 | 进程之间共享资源,谁也不让谁 |
| 例子 | 生产者先放,消费者才能取 | 两个进程都要用打印机 |
| 信号量 | 初值 0 或资源数;P、V 在不同进程 | 初值 1;P、V 在同一进程成对 |
判别口诀:题干里有“必须在……之后”“等……完成才能”,是同步;有“不能同时”“一次只能一个”,是互斥。
共享变量都在内存里,两个进程交替执行。下面代码里每一行都当作不可分割的一步;危险就出在“两行之间被切走”。
while (turn != 0); // 进入区:不是我就空转 critical section; // 临界区 turn = 1; // 退出区:交给 P1 remainder section; // 剩余区
while (turn != 1); critical section; turn = 0; remainder section;
turn 只在退出区被改成对方。P0 用完后,如果 P1 一直不来,turn 永远是 1,P0 想再进也进不去,而临界区明明空着。必须 P0 → P1 → P0 严格轮流。
while (flag[1]); // ① 先检查:P1 想进吗 flag[0] = true; // ② 再上锁 critical section; flag[0] = false; // 解锁 remainder section;
while (flag[0]); // ③ flag[1] = true; // ④ critical section; flag[1] = false; remainder section;
按 ① ③ ② ④ 的顺序执行:两人检查时对方都还没上锁,于是都通过,然后一起进入临界区。根源:检查和上锁不是一个原子操作。
flag[0] = true; // ① 先上锁 while (flag[1]); // ② 再检查 critical section; flag[0] = false; remainder section;
flag[1] = true; // ③ while (flag[0]); // ④ critical section; flag[1] = false; remainder section;
把上锁提前,忙则等待修好了。但按 ① ③ ② ④ 执行:两人都举了手,又都看到对方举手,一起谦让,谁也不进。临界区空着没人进(违背空闲让进),双方长期进不去(违背有限等待,产生饥饿)。
flag[0] = true; // 表态:我想进 turn = 1; // 谦让:你先请 while (flag[1] && turn == 1); // 你也想进且轮到你,我才等 critical section; flag[0] = false; remainder section;
flag[1] = true; turn = 0; while (flag[0] && turn == 0); critical section; flag[1] = false; remainder section;
双标志后检查 + 单标志的 turn。两人同时想进时,turn 只能有一个值:最后写 turn 的那个人把机会让了出去,自己等,另一个进。对方不想进时 flag 为 false,直接进。剩下的缺点只有一个:等的时候还在 while 里空转。
| 算法 | 核心变量 | 违背的原则 | 一句话原因 |
|---|---|---|---|
| 单标志 | turn | 空闲让进 | 严格轮流,对方不进自己也进不了 |
| 双标志先检查 | flag[2] | 忙则等待 | 先检查后上锁,两步之间被打断,两人一起进 |
| 双标志后检查 | flag[2] | 空闲让进 有限等待 | 先上锁后检查,两人都上锁后互相等,产生饥饿 |
| Peterson | flag[2] + turn | 让权等待 | 前三条都满足;等待时仍空转 |
四种软件方法等待时都在 while 里空转,所以都不满足让权等待。表里只列出各自额外违背的那条,是选择题最常考的对应关系。
每点一次按钮,对应进程执行一行。while 那一行条件成立时它原地空转(高亮变黄),不成立就往下走。试着排出让两人同时进临界区、或者两人都卡死的顺序。当前显示的是“双标志先检查”的反例已经跑完的结果。
软件方法的病根是检查和上锁之间会被打断。硬件的办法:要么干脆不让打断(关中断),要么提供一条执行过程中不可分割的指令(TSL、Swap)。
关中断; critical section; 开中断;
进程切换靠中断触发。关了中断,当前进程就不会被切走,临界区自然独占。
// 硬件执行,全程不可打断
bool TestAndSet(bool *lock) {
bool old = *lock; // 记下原来的值
*lock = true; // 无论如何上锁
return old;
}
// 使用
while (TestAndSet(&lock)); // 原来是 true 就空转
critical section;
lock = false;
remainder section;
返回 false 说明原来没锁,而且这一步已经顺手锁上了:检查和上锁在一条指令里完成。
// 硬件执行,交换两个变量
void Swap(bool *a, bool *b) {
bool temp = *a;
*a = *b;
*b = temp;
}
// 使用
bool key = true;
while (key == true)
Swap(&lock, &key); // 把 lock 旧值换到 key
critical section;
lock = false;
remainder section;
逻辑和 TSL 相同:lock 被换成 true,旧值到了 key;key 为 false 说明抢到了。
bool available = true; // 锁是否可用
void acquire() { // 原子执行
while (!available); // 忙等
available = false;
}
void release() { // 原子执行
available = true;
}
需要连续循环忙等的互斥锁都叫自旋锁(TSL、Swap、单标志法也是)。缺点是忙等,违背让权等待。优点是等待时不用切换进程;在多处理机上,如果上锁时间很短,自旋的代价比切换进程低。不适合单处理机:自旋时持锁进程根本上不了 CPU,时间片内不可能解锁。
信号量就是一个表示“某种资源还剩几个”的变量,只能用两个原语操作:P(S) 即 wait(S),申请一个;V(S) 即 signal(S),释放一个。原语执行时不可中断,所以不会出现先检查法那种漏洞。
int S = 1; // 可用资源数
void wait(int S) { // P 操作
while (S <= 0); // 没资源就空转
S = S - 1;
}
void signal(int S) { // V 操作
S = S + 1;
}
和前面的软件、硬件方法一样,没资源时在 while 里忙等。S 的值不会小于 0,也不记录有几个人在等。
typedef struct {
int value; // 剩余资源数
struct process *L; // 等待队列
} semaphore;
void wait(semaphore S) { // P
S.value--;
if (S.value < 0)
block(S.L); // 把自己挂到 S.L,运行态 → 阻塞态
}
void signal(semaphore S) { // V
S.value++;
if (S.value <= 0)
wakeup(S.L); // 从 S.L 唤醒一个,阻塞态 → 就绪态
}
没资源就调用 block 自我阻塞,放弃处理机,满足让权等待。
记法:先改值,再判断;P 看“< 0”,V 看“≤ 0”。
常考:初值为 k 的资源信号量,被 n 个进程各 P 一次,value 的取值范围是 k−n ~ k。互斥信号量(k = 1)就是 1−n ~ 1。
semaphore mutex = 1;
P1() {
...
P(mutex); // 上锁
临界区;
V(mutex); // 解锁
...
}
P2() { 同上 }
// 要求:代码 x 先于代码 y
semaphore S = 0;
P1() {
代码 x;
V(S); // x 做完,发信号
}
P2() {
P(S); // 没收到信号就等
代码 y;
}
P1() { S1; V(a); V(b); }
P2() { P(a); S2; V(c); V(d); }
P3() { P(b); S3; V(g); }
P4() { P(c); S4; V(e); }
P5() { P(d); S5; V(f); }
P6() { P(e); P(f); P(g); S6; }
生产者往大小为 n 的缓冲区放产品,消费者取。缓冲区满了生产者得等,空了消费者得等,而且同一时刻只能有一个进程访问缓冲区。
mutex = 1empty = nfull = 0| 信号量 | 初值 | 含义 |
|---|---|---|
| mutex | 1 | 互斥访问缓冲区 |
| empty | n | 空闲缓冲区的数量 |
| full | 0 | 产品数(满缓冲区的数量) |
semaphore mutex = 1; // 互斥访问缓冲区
semaphore empty = n; // 空闲缓冲区数
semaphore full = 0; // 产品数
producer() {
while (1) {
生产一个产品;
P(empty); // 要一个空位
P(mutex); // 进缓冲区
把产品放入缓冲区;
V(mutex); // 出缓冲区
V(full); // 产品数 +1
}
}
consumer() {
while (1) {
P(full); // 要一个产品
P(mutex);
从缓冲区取出一个产品;
V(mutex);
V(empty); // 空位 +1
使用产品;
}
}
每点一次按钮,对应进程完整跑一轮循环,直到跑完或被阻塞。被唤醒的进程接着从阻塞处往下执行。当前显示的是:生产者连续生产,把缓冲区塞满后,第 7 个产品放不进去,生产者阻塞在 empty 的队列里。
桌上一个盘子,每次只能放一个水果。爸爸只放苹果,妈妈只放橘子;女儿只吃苹果,儿子只吃橘子。盘子空时爸妈才能放;盘里是自己要的水果时,儿女才能取。
| 信号量 | 初值 | 含义 |
|---|---|---|
| plate | 1 | 盘子里还能放几个水果(同步) |
| apple | 0 | 盘中苹果数(同步) |
| orange | 0 | 盘中橘子数(同步) |
| mutex | 1 | 互斥访问盘子(容量为 1 时可省) |
semaphore mutex = 1, plate = 1;
semaphore apple = 0, orange = 0;
dad() {
while (1) {
准备一个苹果;
P(plate);
P(mutex);
把苹果放入盘子;
V(mutex);
V(apple);
}
}
mom() { // 与 dad 对称
while (1) {
准备一个橘子;
P(plate);
P(mutex);
把橘子放入盘子;
V(mutex);
V(orange);
}
}
daughter() {
while (1) {
P(apple);
P(mutex);
从盘中取出苹果;
V(mutex);
V(plate); // 盘子空了
吃掉苹果;
}
}
son() { // 与 daughter 对称
while (1) {
P(orange);
P(mutex);
从盘中取出橘子;
V(mutex);
V(plate);
吃掉橘子;
}
}
三个抽烟者分别只有烟草、纸、胶水中的一种。供应者每次把另外两种材料放到桌上,缺这两种的那位抽烟者拿走、卷烟、抽完后通知供应者,供应者再放下一组。三位抽烟者轮流抽。
| 信号量 | 初值 | 含义 |
|---|---|---|
| offer1 | 0 | 桌上组合一(纸 + 胶水)的数量,给有烟草的 1 号 |
| offer2 | 0 | 组合二(烟草 + 胶水),给有纸的 2 号 |
| offer3 | 0 | 组合三(烟草 + 纸),给有胶水的 3 号 |
| finish | 0 | 抽烟是否完成 |
semaphore offer1 = 0, offer2 = 0, offer3 = 0;
semaphore finish = 0;
int i = 0; // 轮到哪位抽烟者
provider() {
while (1) {
if (i == 0) {
把组合一放桌上;
V(offer1);
} else if (i == 1) {
把组合二放桌上;
V(offer2);
} else {
把组合三放桌上;
V(offer3);
}
i = (i + 1) % 3;
P(finish); // 等抽烟者抽完
}
}
smoker1() {
while (1) {
P(offer1);
从桌上拿走组合一; 卷烟; 抽掉;
V(finish);
}
}
smoker2() { // P(offer2),其余同 smoker1
while (1) {
P(offer2);
从桌上拿走组合二; 卷烟; 抽掉;
V(finish);
}
}
smoker3() { // P(offer3),其余同 smoker1
while (1) {
P(offer3);
从桌上拿走组合三; 卷烟; 抽掉;
V(finish);
}
}
多个读者可以同时读文件;写者写的时候,其他读者、写者都不能访问;写者写之前,已有的读者、写者都要退出。
| 变量 | 初值 | 含义 |
|---|---|---|
| rw | 1 | 互斥访问文件(读-写、写-写互斥) |
| count | 0 | 整型变量:正在读的读者数 |
| rmutex | 1 | 互斥访问 count(王道原书叫 mutex) |
semaphore rw = 1; // 文件的互斥
int count = 0; // 正在读的读者数
semaphore rmutex = 1; // 保护 count
writer() {
while (1) {
P(rw);
写文件;
V(rw);
}
}
reader() {
while (1) {
P(rmutex);
if (count == 0) // 第一个读者
P(rw); // 替所有读者挡住写者
count++;
V(rmutex);
读文件;
P(rmutex);
count--;
if (count == 0) // 最后一个读者
V(rw); // 放写者进来
V(rmutex);
}
}
在读优先的基础上再加一个信号量 w = 1,读者和写者进门前都先 P(w)。
semaphore w = 1; // 新增:实现写优先
writer() {
while (1) {
P(w);
P(rw);
写文件;
V(rw);
V(w);
}
}
reader() {
while (1) {
P(w); // 有写者在排队就进不来
P(rmutex);
if (count == 0) P(rw);
count++;
V(rmutex);
V(w); // 进门后立刻放开 w
读文件;
P(rmutex);
count--;
if (count == 0) V(rw);
V(rmutex);
}
}
5 名哲学家围坐圆桌,每两人之间一根筷子。哲学家 i 左边是 chopstick[i],右边是 chopstick[(i+1)%5],拿到左右两根才能吃。
| 信号量 | 初值 | 含义 |
|---|---|---|
| chopstick[5] | 各 1 | 每根筷子的互斥 |
| limit | 4 | 方案一:同时拿筷子的人数上限 |
| mutex | 1 | 方案三:拿筷子这件事整体互斥 |
每人先拿左、再拿右。若 5 人同时拿起左筷子,每人都在等右边那根,而右边那根正被邻居拿着:循环等待,死锁。
semaphore chopstick[5] = {1, 1, 1, 1, 1};
// 朴素写法:可能死锁
Pi() {
while (1) {
P(chopstick[i]); // 拿左
P(chopstick[(i+1)%5]); // 拿右
吃饭;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
思考;
}
}
// 方案一:最多 4 人同时拿
semaphore limit = 4;
Pi() {
while (1) {
P(limit);
P(chopstick[i]);
P(chopstick[(i+1)%5]);
吃饭;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
V(limit);
思考;
}
}
// 方案二:奇数号先左后右,偶数号先右后左
Pi() {
while (1) {
if (i % 2 == 1) {
P(chopstick[i]);
P(chopstick[(i+1)%5]);
} else {
P(chopstick[(i+1)%5]);
P(chopstick[i]);
}
吃饭;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
思考;
}
}
// 方案三:拿筷子整体互斥
semaphore mutex = 1;
Pi() {
while (1) {
P(mutex);
P(chopstick[i]);
P(chopstick[(i+1)%5]);
V(mutex);
吃饭;
V(chopstick[i]);
V(chopstick[(i+1)%5]);
思考;
}
}
信号量的 P/V 分散在各个进程里,顺序错一个就死锁。管程把共享数据和操作它的过程封装在一起,进程只能通过调用这些过程访问共享数据,而且同一时刻只允许一个进程在管程内执行。
x.wait 阻塞并释放管程,x.signal 唤醒一个在 x 上等的进程monitor ProducerConsumer {
condition full, empty; // 条件变量
int count = 0; // 产品数
void insert(Item item) {
if (count == N) wait(full); // 满了就等
count++;
insert_item(item);
if (count == 1) signal(empty); // 空→非空,叫醒消费者
}
Item remove() {
if (count == 0) wait(empty); // 空了就等
count--;
if (count == N - 1) signal(full); // 满→不满,叫醒生产者
return remove_item();
}
}
producer() { while (1) { item = 生产; ProducerConsumer.insert(item); } }
consumer() { while (1) { item = ProducerConsumer.remove(); 使用 item; } }
| 对比项 | 信号量 P / V | 条件变量 wait / signal |
|---|---|---|
| 有没有值 | 有 value,记录剩余资源数 | 没有值,只有一个等待队列 |
| 等待 | P:value − 1,结果 < 0 才阻塞 | wait:一定阻塞,并释放管程 |
| 唤醒 | V:value + 1;没人等也会加上,这次 V 被“记住” | signal:唤醒一个等待者;没人等就什么也不做,这次 signal 丢失 |
| 资源剩多少 | value 本身 | 管程里的共享变量(如 count),wait 前先用 if 判断 |
| 互斥由谁做 | 程序员写 P(mutex) / V(mutex) | 编译器保证管程内每次只有一个进程 |
信号量的定义(含义 + 初值)通常单独给分。代码没写完,只要关系和信号量写对,也能拿到一部分分。
while (1) { … }。同步:前操作之后 V,后操作之前 P;互斥:P/V 贴着临界区。最后按下面的自检表过一遍。把消费者写成先 P(mutex) 再 P(full),缓冲区为空时:
| 步 | 谁 | 操作 | 结果 |
|---|---|---|---|
| 1 | 消费者 | P(mutex) | mutex 1→0,进入缓冲区 |
| 2 | 消费者 | P(full) | full 0→−1,阻塞,手里还拿着 mutex |
| 3 | 生产者 | P(empty) | empty n→n−1,通过 |
| 4 | 生产者 | P(mutex) | mutex 0→−1,阻塞 |
消费者等生产者的 V(full),生产者等消费者的 V(mutex),谁都醒不了:死锁。生产者先 P(mutex) 再 P(empty) 也一样,在缓冲区满时死锁。回到模拟器打开开关看一遍。
V 操作不会阻塞,所以两个 V 的顺序可以随便换。
| 问题 | 信号量 | 初值 | 含义 | 类型 |
|---|---|---|---|---|
| 生产者-消费者 | mutex | 1 | 互斥访问缓冲区 | 互斥 |
| empty | n | 空闲缓冲区数 | 同步 | |
| full | 0 | 产品数 | 同步 | |
| 苹果橘子 | plate | 1 | 盘子还能放几个水果 | 同步 |
| apple | 0 | 盘中苹果数 | 同步 | |
| orange | 0 | 盘中橘子数 | 同步 | |
| mutex | 1 | 互斥访问盘子(容量 1 时可省) | 互斥 | |
| 吸烟者 | offer1/2/3 | 0 | 桌上组合 1/2/3 的数量 | 同步 |
| finish | 0 | 抽烟完成 | 同步 | |
| 读者-写者 | rw | 1 | 文件的读写、写写互斥 | 互斥 |
| rmutex | 1 | 保护 count | 互斥 | |
| count | 0 | 正在读的读者数(整型变量,不是信号量) | 计数 | |
| w | 1 | 写优先时新增,挡住写者之后到来的读者 | 互斥 | |
| 哲学家进餐 | chopstick[5] | 各 1 | 每根筷子 | 互斥 |
| limit | 4 | 方案一:最多 4 人同时拿筷子 | 资源数 | |
| mutex | 1 | 方案三:拿筷子动作整体互斥 | 互斥 | |
| 前驱图 | 每条边一个 | 0 | 边的起点做完 V,终点开始前 P | 同步 |