管程
管程的原理与操作过程
1. 为什么需要管程?
在多进程并发环境中,为了保证数据的一致性和正确性,需要对共享资源进行访问控制。传统的信号量机制虽然可以实现互斥和同步,但信号量操作(P/V操作)分散在各个进程中,这使得程序的正确性难以验证,容易出现以下问题:
-
死锁(Deadlock):进程之间相互等待资源而都无法继续执行。
-
活锁(Livelock):进程不断改变状态却无法向前推进。
-
饥饿(Starvation):某个进程长时间无法获得所需资源。
-
P/V操作的误用:程序员可能忘记P操作,导致无法互斥;或者忘记V操作,导致资源无法释放,造成死锁。
为了解决这些问题,Hoare 和 Brinch Hansen 提出了管程的概念。管程将共享数据及其所有对这些数据进行操作的过程(函数)封装在一个模块中,提供了一种更高级别的同步机制。
2. 管程的组成
管程是一个由共享数据结构和一组操作这些数据结构的函数(过程)组成的模块。它通常包括:
-
共享数据(Shared Data):管程内部定义的共享变量,这些变量只能由管程内部的过程访问。
-
一组过程(Procedures):用于操作共享数据的函数。这些过程是管程的对外接口。
-
初始化代码(Initialization Code):用于对管程内部的共享数据进行初始化。
-
条件变量(Condition Variables):用于进程同步。当一个进程发现它需要的条件不满足时,可以通过在条件变量上执行
wait
操作来阻塞自己,并释放管程的互斥权;当其他进程改变了条件使得该进程可以继续执行时,可以通过在条件变量上执行signal
操作来唤醒它。
3. 管程的特性
管程的核心特性是一次只允许一个进程在管程中执行。这确保了对共享数据的互斥访问。具体来说:
-
互斥性:任何时刻,管程中至多只有一个进程在执行其内部的过程。这通过管程入口处隐含的互斥锁来实现。当一个进程进入管程时,它会获得这把锁;当它离开管程或执行
wait
操作时,会释放这把锁。 -
封装性:共享数据被封装在管程内部,只能通过管程提供的过程来访问,外部进程无法直接访问管程内部的共享数据。
-
同步性:通过条件变量实现进程间的同步。
-
wait(condition)
:当一个进程调用wait
操作时,它会阻塞自己并加入到指定条件变量的等待队列中,同时释放管程的互斥权。 -
signal(condition)
:当一个进程调用signal
操作时,它会唤醒指定条件变量等待队列中的一个(通常是最先等待的那个)阻塞进程。如果没有进程在该条件变量上等待,则signal
操作不起作用。
-
4. 管程实现互斥和同步的过程
-
进入管程:进程通过调用管程的某个过程来请求进入管程。
-
获取互斥权:如果管程当前没有其他进程执行,则该进程获得管程的互斥权并进入管程执行其过程。如果管程当前有其他进程执行,则该进程在管程的入口处等待。
-
执行过程:进程在管程内执行操作共享数据的过程。
-
条件不满足时等待:如果在执行过程中发现某个条件不满足(例如,消费者发现缓冲区为空),进程会调用
wait(condition)
操作。此时,该进程会被阻塞,加入到对应的条件变量等待队列中,并释放管程的互斥权,以便其他进程可以进入管程。 -
条件满足时唤醒:当其他进程进入管程并修改了共享数据,使得某个条件满足(例如,生产者向缓冲区放入数据),它会调用
signal(condition)
操作。如果该条件变量的等待队列中有阻塞进程,则唤醒其中一个进程。 -
离开管程:进程执行完管程内部的过程,或者被唤醒后继续执行并完成操作,就会离开管程并释放互斥权。
条件变量的两种语义(了解,常作为选择题考点)
对于 signal
操作唤醒一个等待进程时的处理方式,主要有两种不同的语义:
-
Hoare 管程语义:当一个进程执行
signal
操作时,它立即放弃管程的控制权,将被唤醒的进程立即获得管程的控制权并执行。这要求被唤醒的进程能够立即利用被signal
操作改变的条件。 -
Mesa 管程语义(或 Hansen 管程语义):当一个进程执行
signal
操作时,它继续在管程中执行,直到它离开管程或执行另一个wait
操作。被唤醒的进程只是从条件变量的等待队列中转移到管程的入口等待队列中,等待获得管程的控制权。这意味着被唤醒的进程在真正执行前需要重新检查条件,因为在它被唤醒到真正获得管程控制权之间,可能其他进程已经改变了条件。Mesa 管程在实际操作系统中更常用,因为它实现起来相对简单,但需要等待的进程在被唤醒后再次检查条件(通常通过while
循环)。
示例:生产者-消费者问题
这里我们使用管程来解决经典的生产者-消费者问题。
问题描述:有一个固定大小的缓冲区,生产者向缓冲区中放入数据,消费者从缓冲区中取出数据。要求生产者和消费者并发执行,但不能同时访问缓冲区,且生产者不能在缓冲区满时生产,消费者不能在缓冲区空时消费。
代码段
monitor ProducerConsumer {
// 共享数据
item buffer[N]; // 缓冲区,大小为 N
int count = 0; // 缓冲区中当前数据项的数量
int in = 0; // 生产者下次放入数据的位置
int out = 0; // 消费者下次取出数据的位置
// 条件变量
condition notFull; // 缓冲区不满的条件
condition notEmpty; // 缓冲区不空的条件
// 生产者放入数据
procedure insert(item x) {
if (count == N) { // 缓冲区已满
wait(notFull); // 阻塞并等待 notFull 条件
}
buffer[in] = x;
in = (in + 1) % N;
count++;
signal(notEmpty); // 唤醒等待 notEmpty 条件的消费者
}
// 消费者取出数据
procedure remove() returns item {
if (count == 0) { // 缓冲区为空
wait(notEmpty); // 阻塞并等待 notEmpty 条件
}
item x = buffer[out];
out = (out + 1) % N;
count--;
signal(notFull); // 唤醒等待 notFull 条件的生产者
return x;
}
// 初始化代码(可选,有些语言自动初始化)
// init() {
// count = 0;
// in = 0;
// out = 0;
// }
}
// 生产者进程
producer() {
while (true) {
item newItem = produce_item();
ProducerConsumer.insert(newItem); // 调用管程的 insert 过程
}
}
// 消费者进程
consumer() {
while (true) {
item consumedItem = ProducerConsumer.remove(); // 调用管程的 remove 过程
consume_item(consumedItem);
}
}
图示解析(概念性):
+------------------------------------+
| 生产者-消费者问题 |
| |
| 共享数据: 缓冲区[], 计数器, 入, 出 |
| |
| 过程: |
| - 插入(项目 x) |
| - 移除() 返回项目 |
| |
| 条件变量: |
| - notFull (缓冲区满时的等待队列) |
| - notEmpty (缓冲区空时的等待队列) |
| |
| [隐式互斥锁] |
+------------------------------------+
^ ^ ^
| | |
生产者进程 消费者进程 (每次只有一个进程在内部)
工作流程简述:
-
当生产者尝试
insert
数据时,如果count == N
(缓冲区满),则它会在notFull
条件变量上等待,同时释放管程的互斥权。 -
当消费者尝试
remove
数据时,如果count == 0
(缓冲区空),则它会在notEmpty
条件变量上等待,同时释放管程的互斥权。 -
当生产者成功
insert
一个数据后,它会signal(notEmpty)
,如果此时有消费者在notEmpty
上等待,则唤醒它。 -
当消费者成功
remove
一个数据后,它会signal(notFull)
,如果此时有生产者在notFull
上等待,则唤醒它。