Appearance
信号量与 PV 操作
概念
信号量(semaphore)是一个用于表示资源数目的整型变量,加上一个等待队列,只能通过 P、V 两个原语访问。
- P 操作(
wait,荷兰语 proberen,测试):申请一个资源,不够就阻塞自己。 - V 操作(
signal,荷兰语 verhogen,增加):释放一个资源,有等待者就唤醒一个。
一句话说清它是什么:信号量就是"带等待队列的计数器"——计数器记账(还剩几个资源),等待队列兜底(没资源的人在这里睡着,别占 CPU)。它比 os/12-sync.md 里所有软件方案都强的地方只有一处:它满足"让权等待"。
★ 这是 OS 里最重要的一个机制:408 的综合大题几乎必考 PV 设计(分值通常 8~10 分)。判据只有两条:① P、V 数量配对;② P 顺序正确。
原理
一、整型信号量(雏形,不满足让权等待)
c
/* 整型信号量: 就是普通 int */
void wait(int *S) {
while (*S <= 0) { } /* ← 忙等: 一直占着 CPU 空转 */
(*S)--;
}
void signal(int *S) {
(*S)++;
}问题一目了然:while (*S <= 0) {} 是自旋,不满足"让权等待"(与 os/12-sync.md 的软件方法一样)。要"让权",必须让进程"睡着"——这就需要一个等待队列。
二、记录型信号量(408 的标准口径,必须能默写)
c
typedef struct {
int value; /* 资源计数 */
struct process *L; /* 等待队列 (指向 PCB 队列) */
} semaphore;
/* P 操作 (wait): 申请一个资源 */
void P(semaphore *S) {
S->value--; /* ① 先记账: 减 1 */
if (S->value < 0) { /* ② 减完是负的 -> 说明资源已被抢光 */
将当前进程加入 S->L; /* ③ 进等待队列 */
block(S->L); /* ④ 阻塞自己 —— ★ 让权等待在这里实现 */
}
}
/* V 操作 (signal): 释放一个资源 */
void V(semaphore *S) {
S->value++; /* ① 先记账: 加 1 */
if (S->value <= 0) { /* ② 加完仍 <= 0 -> 说明队列里有人在等 */
从 S->L 中移出一个进程 p;
wakeup(p); /* ③ 唤醒它 (进就绪队列, 不立刻运行) */
}
}⚠️ 四个必须说清的细节:
S->value--与S->value++都是"先做,再判断"——判断的是"减完/加完之后"的值。这是所有 PV 轨迹题的算账基准。block()与wakeup()是原语(不可中断)——否则队列会不一致(回顾os/10-process.md)。- 信号量本身是临界资源:多个进程可能同时 P 同一个信号量,所以 P、V 的代码必须整体是原子的(通常靠关中断或硬件原子指令)。
wakeup()只是把进程从阻塞队列搬到就绪队列,它不会立刻运行——要等调度(回顾os/10-process.md的状态转换)。
三、value 的正负三种含义(必背,选择题年年考)
value | 含义 |
|---|---|
value > 0 | 当前还有 value 个可用资源,且没有进程在等 |
value == 0 | 资源刚好用完,但也没有进程在等 |
value < 0 | 资源已用完,且有 |
⚠️ 两个易错点:
value = 0时"没有等待者",不要把它当成"有 0 个等待者"就不对——"0 个等待者"就是"没有等待者",两者一致。真正的分界是"负号"。value的初值不等于"资源总数"时也能用——但你必须在答题时说明初值的含义(比如"初值 3 表示缓冲区有 3 个空位")。
四、用信号量实现互斥("夹住")
c
semaphore mutex = 1; /* ★ 互斥信号量初值恒为 1 */
P1: P2:
P(mutex); P(mutex);
/* 临界区 */ /* 临界区 */
V(mutex); V(mutex);为什么初值是 1:"同一时刻允许 1 个进程进入"= 只有 1 个资源。若初值为 2,就变成"最多同时 2 个进程"(这已经不是互斥,而是"限流")。
五、用信号量实现同步("前后")
同步是"前趋关系":要求"操作 B 必须在操作 A 之后"。
c
/* 要求: S1 执行完才能执行 S2 */
semaphore a = 0; /* ★ 同步信号量初值恒为 0 */
S1: { 做 S1; V(a); } /* 干完活 -> 给信号 (V 在后) */
S2: { P(a); 做 S2; } /* 先等信号 (P 在前) -> 再干 */为什么初值是 0:初始状态下"前驱事件还没发生",所以**"许可"数量的初值是 0**——必须等前驱执行完 V 一次,后继才能 P 到一次。
多条前趋关系(前趋图)的通用做法:
a=0 c=0
S1 ──────► S2 ──────────► S4
│ ▲
└──────► S3 ──────────────┘
b=0 d=0c
S1: { 做 S1; V(a); V(b); }
S2: { P(a); 做 S2; V(c); }
S3: { P(b); 做 S3; V(d); }
S4: { P(c); P(d); 做 S4; }六、互斥 vs 同步:信号量用法的三处不同(最常考对比)
| 对比项 | 互斥(夹住) | 同步(前后) |
|---|---|---|
| 信号量初值 | 1(只允许一个) | 0(还没发生)或资源数 |
| P/V 的位置 | 在临界区两侧对称包夹 | P 在"后"操作之前、V 在"前"操作之后 |
| 同一进程里成对出现 | 是(同一进程自己 P 自己 V) | 否(一个进程 V、另一个进程 P) |
| 语义 | "我要独占" | "我在等你" |
⚠️ 一句话区分:互斥的 P 与 V 在"同一个进程里成对";同步的 P 与 V 在"两个进程之间配对"。
七、P 顺序为什么不能颠倒(本节第二大考点)
同一进程里连续 P 两个信号量时,顺序至关重要。
c
/* ✅ 正确: 先申请"私有的/同步的"资源, 再申请"公有的互斥锁" */
P(empty); /* 先看还有没有空位 —— 若没有, 在这里睡, 不占着锁 */
P(mutex); /* 有空位了, 再抢锁 */
... 操作缓冲区 ...
V(mutex);
V(full);c
/* ❌ 错误: 先抢锁, 再等空位 */
P(mutex); /* 拿到锁 */
P(empty); /* 若无空位 -> 拿着锁睡着了! 别人永远拿不到这把锁 -> 死锁 */而 V 操作相反:
原因:P 会阻塞(可能"拿着东西睡着"),V 只会唤醒(做的是"加法",顺序不影响最终账目)。
示例
例 1:互斥信号量的 value 轨迹
两个进程 P1、P2 争用同一个互斥信号量
S(初值 1),执行序列为 P1 的 P → P2 的 P → P1 的 V → P2 被唤醒后继续。逐步写出value与等待队列长度。
逐步演算(按"先记账、再判断"的规则):
| 步 | 操作 | 记账动作 | 判断 | 结果 | 队列长度 |
|---|---|---|---|---|---|
| 1 | P1 执行 P(S) | 通过(进入临界区) | 0 | ||
| 2 | P2 执行 P(S) | 阻塞,进等待队列 | 1 | ||
| 3 | P1 执行 V(S) | 唤醒 P2(进就绪队列) | 0 | ||
| 4 | P2 被调度后继续 | (不再执行 P) | — | 进入临界区 | 0 |
⚠️ 第 3 步是本节最易错的一步:
V的判据是value <= 0(不是< 0)——因为加完之后value = 0恰好表示"原来的值 ≤ −1,队列里确实有人等"。- 唤醒后队列长度归 0,但
value仍是 0,不是变正数——因为这次V唤醒的人"接手"了刚被释放的那个资源(净效果:资源还在被人用着,只是换人了)。
继续推演(把第 4 步也做完):P2 进入临界区后,如果 P2 再执行一次 P(S),则 V(S)(退出临界区),则 value ∈ {0, -1} 之间摆动——这就是"互斥信号量的 value 永远不会超过 1"的原因。
例 2:前趋图的同步轨迹
前趋图:
、 、 、 。四个信号量 a、b、c、d初值均为 0。写出正确的 PV 代码,并给出一种合法执行的 value 轨迹。
代码(照第五节的"箭头起点 V、终点 P"规则):
c
semaphore a = 0, b = 0, c = 0, d = 0;
/* 进程 1 */ { 做 S1; V(a); V(b); }
/* 进程 2 */ { P(a); 做 S2; V(c); }
/* 进程 3 */ { P(b); 做 S3; V(d); }
/* 进程 4 */ { P(c); P(d); 做 S4; }一种合法执行的轨迹(按 S1 → S2 → S3 → S4 的推进次序):
| 步 | 操作 | 该信号量 value | 判断 | 结果 |
|---|---|---|---|---|
| 1 | S1 的 V(a) | 通过(释放一个许可) | ||
| 2 | S1 的 V(b) | 通过 | ||
| 3 | S2 的 P(a) | 通过(不用等,直接做 S2) | ||
| 4 | S2 的 V(c) | 通过 | ||
| 5 | S3 的 P(b) | 否 | 通过(做 S3) | |
| 6 | S3 的 V(d) | 否 | 通过 | |
| 7 | S4 的 P(c) | 否 | 通过 | |
| 8 | S4 的 P(d) | 否 | 通过 → 做 S4 |
关键结论:每个同步信号量的 value 只在 0 与 1 之间跳动——因为"一次 V 配一次 P",许可数不会累积。
⚠️ 反过来说:如果 S2 抢在 S1 之前跑,则 P(a) 会让 V(a) 把 value 加到 0(
例 3:三个人抢一把锁——value = -2 的来历
互斥信号量
S初值 1,三个进程 P1、P2、P3 依次执行P(S),然后 P1 执行一次V(S)。
| 步 | 操作 | value | 队列长度 | 结果 |
|---|---|---|---|---|
| 1 | P1 P(S) | 0 | 通过 | |
| 2 | P2 P(S) | 1 | 阻塞 | |
| 3 | P3 P(S) | 2 | 阻塞 | |
| 4 | P1 V(S) | 1 | 唤醒一个(P2 或 P3,由队列 FIFO 决定) |
两条结论:
value = -2直接告诉你"有 2 个进程在等"——这就是"负值的绝对值 = 等待进程数"的直观来源。- 第 4 步唤醒后
value仍是 −1:被唤醒的人接手了资源,队列里还剩 1 人。"value 加一"与"队列减一"是同一件事的两面。
例 4:P 顺序颠倒导致的死锁(两个经典场景)
场景 A:生产者-消费者里 P(mutex) 写在前面
| 步 | 生产者 | 消费者 | mutex | empty | full | 说明 |
|---|---|---|---|---|---|---|
| 1 | P(mutex) → 0 | 0 | 0 | 3 | 生产者持锁 | |
| 2 | P(full) → 2 | 0 | 0 | 2 | 消费者拿到一个产品名额 | |
| 3 | P(empty) → −1 阻塞 | 0 | −1 | 2 | 生产者拿着锁去等空位! | |
| 4 | P(mutex) → −1 阻塞 | −1 | −1 | 2 | 消费者等锁 | |
| 5 | — | — | — | — | — | 双方阻塞 → 死锁 |
对照:把 P(empty) 提到最前面(正确顺序)
| 步 | 生产者 | 消费者 | mutex | empty | full |
|---|---|---|---|---|---|
| 1 | P(empty) → −1 阻塞(未持锁) | 1 | −1 | 3 | |
| 2 | P(full) → 2 | 1 | −1 | 2 | |
| 3 | P(mutex) → 0 | 0 | −1 | 2 | |
| 4 | 取走产品 | 0 | −1 | 2 | |
| 5 | V(mutex) → 1 | 1 | −1 | 2 | |
| 6 | V(empty) → 0 ≤ 0 → 唤醒生产者 | 1 | 0 | 2 |
→ 不死锁:生产者虽然在等空位,但"没有占着锁",所以消费者能顺利完成并唤醒它。
场景 B:两个进程以相反顺序申请两把锁
c
/* P1 */ P(S1); P(S2); ... V(S2); V(S1);
/* P2 */ P(S2); P(S1); ... V(S1); V(S2); ← 顺序相反 -> 可能各拿一把, 互相等 */若 P1 拿到 S1、P2 拿到 S2,双方都在等对方释放 → 死锁。这就是"环路等待条件"的具体形态(详见 os/16-deadlock.md)。
例 5:C 实现——记录型信号量的 P/V 与 value 轨迹
#include <stdio.h>
/* 记录型信号量: value + 等待队列长度 (队列用计数代替) */
typedef struct { int value; int wait; } semaphore;
static void P(semaphore *S, const char *who) {
S->value--; /* 先记账 */
if (S->value < 0) { /* 减完是负的 -> 资源已抢光 */
S->wait++;
printf("%s P: value=%2d 队列=%d 阻塞\n", who, S->value, S->wait);
} else {
printf("%s P: value=%2d 队列=%d 通过\n", who, S->value, S->wait);
}
}
static void V(semaphore *S, const char *who) {
S->value++; /* 先记账 */
if (S->value <= 0) { /* 加完仍 <= 0 -> 有人在等 */
S->wait--;
printf("%s V: value=%2d 队列=%d 唤醒一个等待者\n", who, S->value, S->wait);
} else {
printf("%s V: value=%2d 队列=%d 通过\n", who, S->value, S->wait);
}
}
int main(void) {
semaphore S = {1, 0};
printf("互斥信号量初值 1; 序列 P1.P -> P2.P -> P3.P -> P1.V\n");
P(&S, "P1");
P(&S, "P2");
P(&S, "P3");
V(&S, "P1");
printf("--- value < 0 时, 等待队列长度 = -value = %d ---\n", -S.value);
return 0;
}
c 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
互斥信号量初值 1; 序列 P1.P -> P2.P -> P3.P -> P1.V
P1 P: value= 0 队列=0 通过
P2 P: value=-1 队列=1 阻塞
P3 P: value=-2 队列=2 阻塞
P1 V: value=-1 队列=1 唤醒一个等待者
--- value < 0 时, 等待队列长度 = -value = 1 ---三个要点:
V的判据写成<= 0(不是< 0)——这是记录型信号量的标准写法,写错一个符号整道大题全错。- 最后一行"队列长度 = −value = 1":
value = -1与"队列里 1 个人"是同一个事实。 - 本程序用"计数"代替了 PCB 队列链表(
wait字段),只保留教材关心的会计语义——真实内核里L是 PCB 指针队列。
例 6:Python——PV 轨迹全表与死锁检测
def pv_trace(ops, init):
"""按"先记账、再判断"逐条演算; 返回 (操作, 信号量, value, 队列长度, 结果)"""
val = dict(init)
rows = []
for op, s in ops:
if op == 'P':
val[s] -= 1
note = '阻塞(进队)' if val[s] < 0 else '通过'
else:
val[s] += 1
note = '唤醒一个等待者' if val[s] <= 0 else '通过'
rows.append((op, s, val[s], max(0, -val[s]), note))
return rows
print('=== 例 1: 互斥 s=1, 序列 P P V ===')
for i, r in enumerate(pv_trace([('P', 's'), ('P', 's'), ('V', 's')], {'s': 1}), 1):
print(' %d. %s(%s): value=%2d 队列=%d [%s]' % (i, r[0], r[1], r[2], r[3], r[4]))
print('=== 例 3: 三人抢锁 s=1, 序列 P P P V ===')
for i, r in enumerate(pv_trace([('P', 's'), ('P', 's'), ('P', 's'), ('V', 's')], {'s': 1}), 1):
print(' %d. %s(%s): value=%2d 队列=%d [%s]' % (i, r[0], r[1], r[2], r[3], r[4]))
print('=== 例 2: 前趋图 a=b=c=d=0, 依序 8 步 ===')
for i, r in enumerate(pv_trace([('V', 'a'), ('V', 'b'), ('P', 'a'), ('P', 'b'),
('V', 'c'), ('V', 'd'), ('P', 'c'), ('P', 'd')],
{'a': 0, 'b': 0, 'c': 0, 'd': 0}), 1):
print(' %d. %s(%s): value=%2d [%s]' % (i, r[0], r[1], r[2], r[4]))
print('')
print('=== 规则自检 ===')
print(' 1. 互斥信号量初值恒为 1; 同步信号量初值恒为 0; 资源计数信号量初值 = 资源数')
print(' 2. value<0 时 队列长度 = -value ; value=0 表示"无资源且无人等"')
print(' 3. P 判据 value<0 阻塞, V 判据 value<=0 唤醒 (一个用 < 一个用 <=)')
print(' 4. 多个 P 必须"先同步后互斥"; 多个 V 顺序任意')
python 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
输出对照:例 1 得 P1: value=0 通过 → P2: value=-1 阻塞(队列 1) → P1 V: value=0 唤醒;例 3 得 0 / -1 / -2 与队列 0 / 1 / 2,V 后 value=-1 队列=1;例 2 得八个信号量 value 1,1,0,0,1,1,0,0。全部与手算逐位一致。
⚠️ 口径提醒:"队列长度"在
value > 0时记为 0(没有等待者,不是"负的等待者")。有些教材把等待队列长度直接写成-value,在value > 0时会得到负数——这是错的,本文一律取max(0, -value)。
考点
考点
1. 必背结论
- 记录型信号量 =
value+ 等待队列L;P是"先减再判",V是"先加再判"。 value > 0:可用资源数;value = 0:资源用完且无人等;value < 0: = 等待进程数。P的判据是value < 0(阻塞);V的判据是value <= 0(唤醒)——一个用<、一个用<=,这是最常被写错的一处。- 互斥信号量初值恒为 1;同步(前趋)信号量初值恒为 0;资源计数信号量初值 = 资源数。
- 互斥:P/V 在同进程内成对包夹临界区;同步:一个进程
V、另一个进程P,跨进程配对。 - 前趋图通用做法:每条箭头配一个初值 0 的信号量,箭头起点
V、终点P。 - 多个 P 的顺序:必须先"同步/资源类",后"互斥类"(否则会"拿着锁去睡觉"→ 死锁)。
- 多个 V 的顺序可任意交换。
block/wakeup是原语;wakeup只把进程搬进就绪队列,不立刻运行。- 信号量满足"让权等待"(相比之下,
os/12-sync.md的软件/硬件方法都是忙等)。
2. 高频陷阱
- 把
V的判据写成value < 0:错。必须<= 0——因为value从 −1 加到 0 时,队列里确实有人在等。 - 认为
V之后value一定变大(变正):错。V之后常常仍是 0 或负数——被唤醒者"接手"了那个资源。 - 把"互斥信号量初值"写成 0:错,初值必须是 1(0 意味着"初始就没有资源",谁都进不去)。同步信号量才用 0。
- 说"互斥的 P/V 必须分在两个进程里":错。互斥是"同一进程自己 P、自己 V";"一个进程 P、另一个进程 V"的那种是同步。
- 只检查"P/V 数量相等"就以为对了:错。P 顺序错误照样死锁(例 4 的两个场景,P/V 数量都是配对的)。
- 忘了"信号量的 P/V 必须原子执行":信号量自己就是临界资源(多个进程同时
value--会算错账)。 - 把
P的名字记成"先判断后减":错。记录型信号量是"先减后判"——这个顺序决定了value = -1才表示"1 个人在等"(若先判后减,账目口径会整体偏移 1)。 - 在答案里漏写"信号量初值":408 的 PV 大题按"初值 + 配对 + 顺序"三处给分,不写初值直接丢分。
- 把"唤醒"写成"立刻运行":错。被唤醒者进就绪队列,等调度(回顾
os/10-process.md)。 - 认为"信号量越大越好":错。信号量的
value初值由"允许并发的数量"决定——互斥恒为 1,写成 2 就破坏了互斥。
3. 解题模板("PV 操作设计题",必背流程)
① 数资源: 题目里有几类需要"独占"的东西? -> 每类一个 mutex, 初值 1
② 数同步点: 有几个"必须先后"的关系? -> 每条箭头一个信号量, 初值 0
③ 写每个进程的代码骨架:
P(同步/资源信号量) -> P(互斥信号量) -> 操作 -> V(互斥信号量) -> V(同步信号量)
口诀: "先私后公"(P 序), V 序随意
④ 自检三条:
- 每个信号量的 P 与 V 总次数是否相等? (总量守恒)
- 互斥信号量是否只有 1 个 P-1 个 V 夹住临界区?
- 有没有"持锁等资源"的 P 顺序? (有则改)
⑤ 若题目问 value: 从初值出发, 逐条按"先记账再判断"列轨迹表4. 与相邻章节的接口
os/12-sync.md(同步与互斥):信号量是"满足让权等待"的方案——它把自旋换成阻塞队列,四条准则里唯一被"补齐"的就是这一条。os/14-classic.md(经典同步问题):生产者-消费者、读者-写者、哲学家进餐都是本篇规则的应用题;那里的empty/full就是"资源计数信号量"。os/15-monitor.md(管程):管程把 P/V 藏进了语言语法——wait/signal不再需要程序员配初值。os/16-deadlock.md(死锁):"P 顺序不当"是死锁的第一个例子;那里的四个必要条件与银行家算法都建立在本篇的"持锁等待"之上。os/11-scheduling.md(调度):P阻塞会触发调度(运行 → 阻塞),V唤醒的进程要排队等调度(阻塞 → 就绪)。ds/与lang/:P/V在内核里就是"改一个整数 + 挂链/摘链",并发安全靠原子指令或关中断(见os/12-sync.md的 TS/Swap)。
小结
- 信号量 =
value+ 等待队列;P先减后判(< 0阻塞)、V先加后判(<= 0唤醒)——两个判据一个用<、一个用<=。 value三种含义:正数=可用资源数、0=用完但无人等、负数=等待进程数。例 3 的-2就是"两人在等"。- 初值三口径:互斥恒为 1、同步恒为 0、资源计数 = 资源数。
- P/V 的位置不对称:P 必须"先同步/资源、后互斥"(反了会"持锁睡觉"→ 死锁);**V 顺序随便。**这一点在例 4 用"生产者-消费者"的两张对照表演完了。
- 前趋图的通用解法:每条箭头一个初值 0 的信号量,起点
V、终点P——例 2 的八个 value 只在 0/1 之间跳。 - 信号量是第一个"满足让权等待"的机制——它把"忙等"换成"睡着等唤醒",这也是它取代
os/12-sync.md全部软件方案的原因。
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。