管程

信号量功能完备,但有一个工程上的硬伤:P/V 操作散落在各个进程的代码里。写少一个 V 就会有进程永远醒不过来,把两个 P 写反顺序就会死锁,而且这些错误极难排查——出问题的地方和写错的地方往往隔着好几个进程。

管程的思路是:把共享数据和操作它的代码封装起来,让互斥由编译器自动保证,程序员根本不用写 P/V。

机制

管程的组成

一个管程由四部分组成:

  1. 局部于管程的共享数据结构
  2. 对该数据结构进行操作的一组过程(函数)
  3. 对局部于管程的共享数据设置初始值的语句
  4. 管程的名字

用类比的话说,它非常像面向对象里的一个类:数据是私有成员,过程是公有方法,外部只能通过方法访问数据。

三个基本特征

① 局部于管程的数据只能被管程内的过程访问。 外部代码无法直接触碰这些共享数据。

② 进程只有通过调用管程内的过程,才能进入管程访问共享数据。 入口被收窄到了几个明确的函数上。

③ 每次仅允许一个进程在管程内执行某个内部过程。

第③条是最关键的一条——它就是互斥。而实现互斥的责任在编译器:

管程的互斥由编译器负责实现,程序员不需要编写任何进入区和退出区的代码。

这是管程相对信号量的全部优势所在。用信号量时,互斥要靠程序员自觉地在每个临界区前后配对写 P(mutex)/V(mutex);用管程时,只要把共享数据放进管程,互斥就自动成立了,没有写错的机会。

条件变量

只有互斥还不够,还需要同步——进程可能因为条件不满足(如缓冲区已满)而必须等待。

但这里有个死结:进程已经在管程里了(占着互斥权),如果它直接原地等待,别人根本进不来,也就永远不可能满足它等的那个条件。它会把整个管程锁死。

解法是条件变量(condition variable),配两个操作:

condition x;
 
x.wait();     // 调用者阻塞,并【释放管程的使用权】,让别的进程能进来
x.signal();   // 唤醒一个在 x 上等待的进程

x.wait() 的关键在于释放管程使用权这一步。进程把自己挂到条件变量 x 的等待队列上,同时让出管程,其他进程于是可以进入管程去改变条件、并最终调用 x.signal() 把它唤醒。

一个管程内可以有多个条件变量,分别对应不同的等待原因。比如生产者-消费者管程里通常有 notFull 和 notEmpty 两个。

用管程写生产者-消费者

对照 2.3.5 的信号量写法,可以看出管程省掉了多少东西:

monitor ProducerConsumer {
    item buffer[N];
    int count = 0;                  // 共享数据,外部访问不到
    condition notFull, notEmpty;    // 两个等待原因
 
    void insert(item x) {
        if (count == N) notFull.wait();     // 满了就等
        buffer[count++] = x;
        notEmpty.signal();                  // 通知消费者
    }
 
    item remove() {
        if (count == 0) notEmpty.wait();    // 空了就等
        item x = buffer[--count];
        notFull.signal();                   // 通知生产者
        return x;
    }
}
 
// 使用方
producer() { while(1) { item x = produce(); ProducerConsumer.insert(x); } }
consumer() { while(1) { item x = ProducerConsumer.remove(); consume(x); } }

注意 insert 和 remove 里完全没有互斥相关的代码——没有 P(mutex),没有 V(mutex)。互斥由编译器在进入和离开管程过程时自动加上。程序员只需要关心业务逻辑和同步条件。

边界

signal 与 V 操作的区别

疑问点:以下两个命题的正误判断

C. 管程中 signal 操作的作用和信号量机制中的 V 操作相同 D. 管程是被进程调用的,管程是语法范围,无法创建和撤销

C 错误,D 正确。

C 错在”相同”上,二者有一个本质差异:V 有记忆,signal 没有。

V 操作总是执行 S.value++。即使此刻没有任何进程在等待,这个”+1”也被记录下来了——将来某个进程执行 P 时会直接通过,相当于把这次释放”存”了起来。

x.signal() 则不同:如果此刻没有进程在条件变量 x 上等待,它什么也不做,这个信号直接丢失。 将来才来等待的进程不会因为之前发生过一次 signal 而受益。

这个差异有个形象的说法:V 是往账户里存钱,signal 是当面喊一嗓子——没人在场就白喊了。

正因如此,管程里的 wait 必须由条件来驱动(先判断条件,不满足才 wait),而不能依赖信号计数。

顺带还有第二个差异:执行 V 之后,执行者继续运行;而执行 signal 之后谁运行,取决于管程采用的语义(见下条)。

D 正确,而且它的说法值得逐字拆开看。

  • “管程是被进程调用的”:对。管程是被动的代码模块,只有进程主动调用它的过程时,其中的代码才会执行。
  • “管程是语法范围”:对。这是 D 的题眼。管程是程序设计语言的一种构造,是编译期的概念,类似于一个类的定义或一个作用域。
  • “无法创建和撤销”:对。这里说的是——管程不是一个会被调度的执行实体,因此不存在”创建一个管程进程""撤销一个管程”这种说法。

这个选项之所以读起来别扭,是因为它的措辞很容易让人下意识地把管程当成进程来理解,于是觉得”怎么会无法创建和撤销”。一旦意识到管程是语法范围而非执行实体,整句话就顺了。

signal 之后谁运行:Hoare 语义与 Mesa 语义

x.signal() 唤醒了一个进程,但此刻管程里已经有一个进程了(就是调用 signal 的那个)。第③条特征要求管程内只能有一个进程执行,所以必须有人让路。两种约定:

Hoare 语义:唤醒者立即阻塞,让被唤醒者马上运行。被唤醒者醒来时条件一定成立(因为唤醒者刚设置好条件就立刻让出了,中间没人插手)。

Mesa 语义:唤醒者继续运行,被唤醒者只是从”等待”变为”就绪”,要等唤醒者离开管程后才能真正执行。

Mesa 语义有一个重要后果:被唤醒者真正运行时,条件可能已经又不成立了——因为在它就绪到运行之间,可能有第三个进程进来把资源抢走了。

因此在 Mesa 语义下,wait 必须写在 while 循环里,而不是 if:

while (count == N) notFull.wait();     // Mesa:醒来必须重新检查
// 而不是
if (count == N) notFull.wait();        // Hoare 才可以这样写

关联对照:Java 的 synchronized 就是管程

Java 中每个对象都内置一个监视器锁(monitor)。synchronized 方法或代码块对应”进入管程”,Object.wait() / notify() / notifyAll() 对应条件变量的 wait / signal。二者是同一套机制。

而 Java 采用的正是 Mesa 语义——这正好解释了 Java 并发编程里那条著名的铁律:wait() 必须放在 while 循环里,绝不能用 if。

这条规则通常被当作经验教条来背,但它的根源就在上面:Mesa 语义下被唤醒不等于条件成立,必须重新检查。

另外 Java 的 notifyAll() 之所以常被推荐,也与 signal 无记忆的特性有关——唤醒全部再让它们各自重新检查,比精确唤醒一个更不容易出错。这与 2.1.3 中”释放一台打印机只唤醒一个”的资源语义正好构成对照。

管程 vs 信号量

信号量管程
互斥由谁保证程序员手写 P/V编译器自动
代码分布P/V 散落在各进程共享数据与操作封装在一起
出错难度高(漏写、写反顺序)低
是否有记忆V 有记忆signal 无记忆
层次较低级的机制更高级的语言构造

管程是更高级的同步机制,但它需要编程语言的支持(编译器要能识别 monitor 关键字并生成互斥代码)。信号量则是操作系统层面提供的,任何语言都能用。

对照速查

管程三大特征内容
①局部数据只能被管程内的过程访问
②进程只能通过调用管程内的过程进入
③每次仅允许一个进程在管程内执行(互斥,由编译器保证)
V 操作x.signal()
无人等待时S.value++,信号被记住什么也不做,信号丢失
执行后执行者继续视 Hoare / Mesa 语义而定
Hoare 语义Mesa 语义
signal 后唤醒者阻塞继续运行
被唤醒者醒来时条件一定成立条件可能已不成立
wait 写法if 可行必须用 while
代表教材经典模型Java

考点

  • 管程的三大特征,尤其第③条互斥由编译器保证
  • signal 与 V 不同:V 有记忆,signal 无人等待时信号丢失
  • 管程是语法范围,不是执行实体,不会被创建和撤销
  • 条件变量 wait 会释放管程使用权(否则会锁死管程)
  • 一个管程可有多个条件变量
  • 管程需要语言/编译器支持,信号量不需要

链接