Appearance
条件变量
2026 大纲 二(三)5 条件变量。
PV 太容易写错了
上一节的信号量什么都能做,但它有个很现实的毛病:太容易写错。
P 和 V 是散落在代码各处的裸操作——少写一个 V 就永久阻塞, 多个 P 的顺序放反就死锁(互斥的 P 必须放最后), 而这些错误编译器一个都查不出来,往往要等到线上偶发才暴露。
根子在于同步逻辑和业务逻辑混在一起,而且没有任何东西强制你把它们配对。
管程的思路是把它们分开:把共享数据和操作这些数据的过程集中封装成一个模块, 规定任何时刻只能有一个进程在管程内执行——而这个互斥由编译器保证, 不用程序员写。于是程序员只需要关心"什么条件下该等、什么条件下该叫醒别人"。
⚠️ 这里有一处关键:封装性是互斥成立的前提。 管程里定义的变量只能被管程内的过程访问——如果外面能直接改, 那"任何时刻只有一个进程在管程内"就保护不了它。
但只有互斥还不够,还差一步推导出条件变量:
进程进了管程,发现条件不满足(比如缓冲区空了)需要等 ⇒ 可它占着管程在等 ⇒ 能把条件改成满足的那个人(生产者)根本进不来 ⇒ 死锁。
所以必须有一种"等待时释放管程"的机制,这就是条件变量。 x.wait() 阻塞自己并让出管程,x.signal() 唤醒一个等在 x 上的进程。
⚠️ 条件变量与信号量最大的分界是有没有"值": V 总会改变状态、信号被记录下来;而 x.signal 在无人等待时是空操作、信号直接丢失。 所以用条件变量时必须另外维护共享变量来判断条件——不能指望信号本身携带信息。
一、管程的定义
Monitor monitor_name { /* ① 管程名 */
share variable declarations; /* ② 共享变量说明 */
cond declarations; /* 条件变量说明 */
public: /* ③ 能被进程调用的过程 */
void P1(...) { ... }
void P2(...) { ... }
{
initialization code; /* ④ 初始化代码 */
}
}管程里包含了面向对象的思想:封装于管程内部的数据结构仅能被封装于管程内部的过程访问,管程外的过程都不能访问它;反过来,管程内部的过程也仅能访问管程内的数据结构。进程要访问临界资源只能通过管程间接访问,而管程每次只准许一个进程进入。
条件变量的说明形式是 condition x, y;,每个条件变量保存一个链表,记录因该条件变量而阻塞的所有进程。对它只有两个操作:
| 操作 | 含义 |
|---|---|
x.wait | 正在调用管程的进程因 x 条件需要被阻塞,则把自己插入 x 条件的等待队列,并释放管程,直到 x 条件变化。此时其它进程可以使用该管程 |
x.signal | 正在调用管程的进程发现 x 条件发生了变化,则重新启动一个因 x 条件而阻塞的进程;若有多个则任选其一;若没有,则继续执行原进程,不产生任何结果 |
二、管程内的三种队列
霍尔管程的 wait 与 signal 实现代码:使用权究竟怎么原地移交(想弄清"醒来后为何不用再 P(mutex)"时展开)
孙钟秀教材给出的霍尔管程实现把这件事写死了。每个管程配一组变量:
c
semaphore mutex; /* 初值 1:进管程用的互斥信号量 —— 它的等待队列就是「入口等待队列」 */
semaphore next; /* 初值 0:发出 signal 的进程阻塞自己 —— 它的等待队列就是「紧急等待队列」 */
int next_count; /* 初值 0:在 next 上等待的进程数 */每个条件变量 x 再配一对:
c
semaphore x_sem; /* 初值 0:因条件 x 阻塞的进程在此排队 */
int x_count; /* 初值 0:在 x_sem 上等待的进程数 */任何调用管程过程的外部代码都组织成这个形式:
P(IM.mutex); // 进管程:拿不到就进入口队列
<过程体>;
if (IM.next_count > 0) V(IM.next); // 出管程:紧急队列非空 → 优先放它
else V(IM.mutex); // 否则才放入口队列的人wait 与 signal 的实现:
wait(x_sem, x_count, IM) {
x_count++; // 我要去 x 的队列上等
if (IM.next_count > 0) V(IM.next); // ← 释放管程:优先交给紧急队列
else V(IM.mutex); // 否则交给入口队列
P(x_sem); // ← 在这里睡下
x_count--; // ← 醒来后直接往下走,不再 P(mutex)
}
signal(x_sem, x_count, IM) {
if (x_count > 0) { // 只有真有人等才动作,否则什么也不做
IM.next_count++;
V(x_sem); // ← 唤醒一个等待者
P(IM.next); // ← 自己去紧急队列排队,把管程让出去
IM.next_count--;
}
}wait 里释放管程用的是 V(mutex) 或 V(next),而醒来之后并没有对应的 P(mutex)——被唤醒者不是"重新去申请管程",而是从 signal 的执行者手里直接接管:V(x_sem) 唤醒它之后紧接着 P(IM.next) 把自己挂起,管程的使用权就在这一对操作之间原地移交。这也正是 Hoare 语义"条件必然成立"的实现依据:从 signal 到被唤醒者继续执行,管程一刻也没有对外开放过,刚被确认的条件不可能被第三方改掉。
三、用管程解决生产者-消费者问题
这和信号量方案解决的是同一个问题——同一个环形缓冲池、同样的"满则生产者等、空则消费者等",区别只在用什么工具表达同步。buffer[N] 仍按环形队列使用,in 指向下一个写入位置、out 指向下一个读出位置,count 记录当前产品数。
Monitor producerconsumer {
item buffer[N];
int in, out, count;
condition notfull, notempty;
public:
void put(item x) {
if (count >= N) cwait(notfull); // 缓冲池满,等
buffer[in] = x;
in = (in + 1) % N; // 环形推进
count++;
csignal(notempty); // 通知消费者
}
void get(item x) {
if (count <= 0) cwait(notempty); // 缓冲池空,等
x = buffer[out];
out = (out + 1) % N; // 环形推进
count--;
csignal(notfull); // 通知生产者
}
{ in = 0; out = 0; count = 0; } // 初始化
} PC;void producer() { void consumer() {
item x; item x;
while (TRUE) { while (TRUE) {
produce an item in x; PC.get(x);
PC.put(x); consume the item in x;
} }
} }对着这段代码可以数出管程方案"少写了什么、还得写什么":
| 信号量方案 | 管程方案 | |
|---|---|---|
| 互斥 | 程序员手写 P(mutex)/V(mutex),位置写错就死锁 | 编译器自动生成,程序员看不见 |
| 同步 | 程序员手写 P(empty)/V(full) 等 | 程序员手写 cwait/csignal |
| 条件判断 | 靠信号量的计数隐式表达 | 必须自己维护共享变量(count)并显式判断 |
| P/V 顺序约束 | 有:互斥的 P 必须放最后 | 无:不存在"抱着 mutex 阻塞"的问题 |
最后一行是管程真正的收益:cwait 一定会释放管程,所以"持锁阻塞"这个死锁来源在机制层面被消除了;而信号量方案里它只能靠程序员自觉。
一个容易漏掉的点
in、out、count 三个变量都是管程内部的共享数据,它们的互斥由管程保证。若把 count 挪到管程外部,封装性一破,互斥立刻失效——这就是"封装是互斥能成立的前提"的具体含义。
四、Hoare 与 Mesa 的分歧
进程 P 执行 x.signal 唤醒了因 x 阻塞的进程 Q 时,两个进程都想在管程内执行,而管程只能容一个。教材给出的两种处理方式是:P 等待,直至 Q 离开管程或等待另一条件;或Q 等待,直至 P 离开管程或等待另一条件。Hoare 采用第一种,于是被唤醒者接着跑、发信号者进紧急等待队列。
Mesa 语义下则必须把 if 改成 while:
// Mesa 管程
void put(item x) {
while (count >= N) // 用 while 而非 if
cwait(notfull);
...
}因为 signal 的进程继续留在管程里执行,被唤醒的进程只是回到就绪、稍后再竞争进入管程;等它真正进来时,唤醒它的那个条件可能已被第三个进程改掉(比如另一个生产者抢先把空位填了)。if 只检查一次,就会带着不成立的条件往下走。
这条判据可以直接迁移
"被唤醒 ≠ 条件仍然成立"是所有实际系统(Java 的 wait/notify、POSIX 的 pthread_cond_wait)都采用 Mesa 语义后留下的通用告诫。
考点速记
- 管程把共享数据与操作集中封装,互斥由编译器保证,程序员只写同步逻辑。⚠️它不只能实现互斥——配上条件变量后同样能实现同步。
- ⚠️封装性是互斥成立的前提:管程中定义的变量只能被管程内的过程访问,否则外部直接改动就绕过了互斥。
- 任何时候只能有一个进程在管程中执行。
- ⚠️管程是由编程语言支持的同步机制(编译器负责插入互斥代码),这与信号量"由 OS 提供原语"不同。
- 条件变量的由来是一步推导:在管程内阻塞却不释放管程 ⇒ 能改条件的人进不来 ⇒ 死锁。所以
x.wait()必须阻塞自己并让出管程。 - ⚠️条件变量与信号量的分界是有没有"值":
V总会改变状态、信号被记录;x.signal在无人等待时是空操作、信号丢失。所以必须另外维护共享变量来判断条件。 - 管程内有三种队列:入口队列、紧急队列、各条件变量各自的队列;出管程时紧急队列优先。
wait醒来不需要重新申请管程使用权——使用权由signal方原地移交。⚠️ 这正是 Hoare 语义下"被唤醒时条件必然成立"、可以用if的根源;而 Mesa 语义下唤醒者继续执行,条件可能又被改掉,所以必须用while重新检查。
这一节在真题里被考过的形式:
只出过一道题,四个选项恰好把管程的三条正确性质和一条误解摆在一起。
- 判断关于管程的四条叙述,选错误的(2016-32)。错项是"管程只能用于实现进程的互斥"。⚠️ 管程配上条件变量后同样能实现同步(速记第一条)——事实上条件变量存在的全部理由就是为了做同步。另三条都对:管程是由编程语言支持的进程同步机制(第四条)、任何时候只能有一个进程在管程中执行(第三条)、管程中定义的变量只能被管程内的过程访问(第二条,而且这一条正是互斥能成立的前提)。
复习优先级:必须拿满,但内容不多。 把速记第一到四条那四句性质记住, 唯一那道题就是送分。第六条(条件变量的信号会丢失)是它与信号量最本质的差别, 在写代码题时会用到;第八条(Hoare 用 if、Mesa 用 while)属于理解层面, 408 至今没考过,读懂即可。
易错:认为管程只能实现互斥。配上条件变量同样能实现同步——那正是条件变量存在的理由。
易错:认为管程的互斥要程序员自己写。由编译器保证,程序员只写同步逻辑。
易错:认为管程中的变量可以被外部访问。只能被管程内的过程访问——这是互斥成立的前提。
易错:把条件变量当成信号量用,指望
signal的信号被记住。无人等待时它是空操作,信号丢失。
易错:认为
x.wait()阻塞时会一直占着管程。必须让出管程,否则能改条件的人进不来。
教材出处
- 管程的定义与四个组成部分:汤小丹《计算机操作系统》2.4.5 节,印刷 p58——"管程由四部分组成:①管程的名称;②局部于管程的共享数据结构说明;③对该数据结构进行操作的一组过程;④对局部于管程的共享数据设置初始值的语句"。
- 管程与进程的六点不同、条件变量
x.wait/x.signal的语义:同书印刷 p59——x.wait"将自己插入到 x 条件的等待队列上,并释放管程";x.signal"如果没有,继续执行原进程,而不产生任何结果。这与信号量机制中的 signal 操作不同"。同页给出 P/Q 谁等谁的两种处理方式。 - Hoare 与 Hansen 的选择:同书印刷 p60——"Hoare 采用了第一种处理方式,而 Hansan 选择了两者的折中,他规定管程中的过程所执行的 signal 操作是过程体的最后一个操作"。
- 管程版生产者-消费者的环形缓冲写法:同书 2.5.1 节,印刷 p62—p63——
put中buffer[in]=x; in=(in+1)%N; count++;,get中x=buffer[out]; out=(out+1)%N; count--;,初始化{in=0; out=0; count=0;}。 - 入口/紧急队列与
wait/signal的实现:孙钟秀、费翔林《操作系统教程》(第 6 版)6.4.2 节"霍尔管程",印刷 p221—p222——给出mutex/next/next_count三个量、外部过程的if(next_count>0) V(next) else V(mutex)形式,以及wait/signal两个过程的完整代码(wait中先释放管程再P(x_sem),signal中V(x_sem)后P(next))。同书印刷 p220 说明条件变量"没有与条件变量关联的值,也不能像信号量那样积累供以后使用"。