管程
信号量功能完备,但有一个工程上的硬伤:P/V 操作散落在各个进程的代码里。写少一个 V 就会有进程永远醒不过来,把两个 P 写反顺序就会死锁,而且这些错误极难排查——出问题的地方和写错的地方往往隔着好几个进程。
管程的思路是:把共享数据和操作它的代码封装起来,让互斥由编译器自动保证,程序员根本不用写 P/V。
机制
管程的组成
一个管程由四部分组成:
- 局部于管程的共享数据结构
- 对该数据结构进行操作的一组过程(函数)
- 对局部于管程的共享数据设置初始值的语句
- 管程的名字
用类比的话说,它非常像面向对象里的一个类:数据是私有成员,过程是公有方法,外部只能通过方法访问数据。
三个基本特征
① 局部于管程的数据只能被管程内的过程访问。 外部代码无法直接触碰这些共享数据。
② 进程只有通过调用管程内的过程,才能进入管程访问共享数据。 入口被收窄到了几个明确的函数上。
③ 每次仅允许一个进程在管程内执行某个内部过程。
第③条是最关键的一条——它就是互斥。而实现互斥的责任在编译器:
管程的互斥由编译器负责实现,程序员不需要编写任何进入区和退出区的代码。
这是管程相对信号量的全部优势所在。用信号量时,互斥要靠程序员自觉地在每个临界区前后配对写 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会释放管程使用权(否则会锁死管程) - 一个管程可有多个条件变量
- 管程需要语言/编译器支持,信号量不需要
链接
- 🏠 返回总览:操作系统第 2 章:进程与线程总览
- ⬅️ 上一节:2.3.5 经典同步问题
- ➡️ 下一节:2.4.1 死锁的概念
- 📖 名词库:第 2 章名词库