Skip to content

条件变量

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);  // 否则才放入口队列的人

waitsignal 的实现:

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)程序员手写 cwaitcsignal
条件判断靠信号量的计数隐式表达必须自己维护共享变量count)并显式判断
P/V 顺序约束有:互斥的 P 必须放最后无:不存在"抱着 mutex 阻塞"的问题

最后一行是管程真正的收益cwait 一定会释放管程,所以"持锁阻塞"这个死锁来源在机制层面被消除了;而信号量方案里它只能靠程序员自觉。

一个容易漏掉的点

inoutcount 三个变量都是管程内部的共享数据,它们的互斥由管程保证。若把 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 语义后留下的通用告诫。

考点速记

  1. 管程把共享数据与操作集中封装互斥由编译器保证,程序员只写同步逻辑。⚠️它不只能实现互斥——配上条件变量后同样能实现同步
  2. ⚠️封装性是互斥成立的前提管程中定义的变量只能被管程内的过程访问,否则外部直接改动就绕过了互斥。
  3. 任何时候只能有一个进程在管程中执行。
  4. ⚠️管程是由编程语言支持的同步机制(编译器负责插入互斥代码),这与信号量"由 OS 提供原语"不同。
  5. 条件变量的由来是一步推导在管程内阻塞却不释放管程 ⇒ 能改条件的人进不来 ⇒ 死锁。所以 x.wait() 必须阻塞自己并让出管程
  6. ⚠️条件变量与信号量的分界是有没有"值"V 总会改变状态、信号被记录x.signal无人等待时是空操作、信号丢失所以必须另外维护共享变量来判断条件。
  7. 管程内有三种队列入口队列紧急队列各条件变量各自的队列;出管程时紧急队列优先
  8. wait 醒来不需要重新申请管程使用权——使用权由 signal原地移交。⚠️ 这正是 Hoare 语义下"被唤醒时条件必然成立"、可以用 if 的根源;而 Mesa 语义下唤醒者继续执行,条件可能又被改掉,所以必须用 while 重新检查

这一节在真题里被考过的形式

只出过一道题,四个选项恰好把管程的三条正确性质和一条误解摆在一起。

  • 判断关于管程的四条叙述,选错误的(2016-32)。错项是"管程只能用于实现进程的互斥"。⚠️ 管程配上条件变量后同样能实现同步(速记第一条)——事实上条件变量存在的全部理由就是为了做同步。另三条都对:管程是由编程语言支持的进程同步机制(第四条)、任何时候只能有一个进程在管程中执行(第三条)、管程中定义的变量只能被管程内的过程访问(第二条,而且这一条正是互斥能成立的前提)。

复习优先级必须拿满,但内容不多。 把速记第一到四条那四句性质记住, 唯一那道题就是送分。第六条(条件变量的信号会丢失)是它与信号量最本质的差别, 在写代码题时会用到;第八条(Hoare 用 if、Mesa 用 while)属于理解层面, 408 至今没考过,读懂即可。

易错:认为管程只能实现互斥。配上条件变量同样能实现同步——那正是条件变量存在的理由。

易错:认为管程的互斥要程序员自己写。由编译器保证,程序员只写同步逻辑。

易错:认为管程中的变量可以被外部访问。只能被管程内的过程访问——这是互斥成立的前提。

易错:把条件变量当成信号量用,指望 signal 的信号被记住。无人等待时它是空操作,信号丢失

易错:认为 x.wait() 阻塞时会一直占着管程。必须让出管程,否则能改条件的人进不来。

教材出处
  • 管程的定义与四个组成部分:汤小丹《计算机操作系统》2.4.5 节,印刷 p58——"管程由四部分组成:①管程的名称;②局部于管程的共享数据结构说明;③对该数据结构进行操作的一组过程;④对局部于管程的共享数据设置初始值的语句"。
  • 管程与进程的六点不同条件变量 x.waitx.signal 的语义:同书印刷 p59——x.wait "将自己插入到 x 条件的等待队列上,并释放管程";x.signal "如果没有,继续执行原进程,而不产生任何结果。这与信号量机制中的 signal 操作不同"。同页给出 P/Q 谁等谁的两种处理方式。
  • Hoare 与 Hansen 的选择:同书印刷 p60——"Hoare 采用了第一种处理方式,而 Hansan 选择了两者的折中,他规定管程中的过程所执行的 signal 操作是过程体的最后一个操作"。
  • 管程版生产者-消费者的环形缓冲写法:同书 2.5.1 节,印刷 p62—p63——putbuffer[in]=x; in=(in+1)%N; count++;getx=buffer[out]; out=(out+1)%N; count--;,初始化 {in=0; out=0; count=0;}
  • 入口/紧急队列与 waitsignal 的实现:孙钟秀、费翔林《操作系统教程》(第 6 版)6.4.2 节"霍尔管程",印刷 p221—p222——给出 mutexnextnext_count 三个量、外部过程的 if(next_count>0) V(next) else V(mutex) 形式,以及 waitsignal 两个过程的完整代码(wait 中先释放管程再 P(x_sem)signalV(x_sem)P(next))。同书印刷 p220 说明条件变量"没有与条件变量关联的值,也不能像信号量那样积累供以后使用"。

相关知识

信号量生产者-消费者问题同步与互斥基本概念

真题练习

相关真题(2题)