292 lines
16 KiB
Markdown
292 lines
16 KiB
Markdown
进程管理(4):信号量与管程
|
||
========================
|
||
|
||
在[进程管理(3):同步互斥](chp17.md)中,我们提到,为了解决同步互斥问题,操作系统会提供一些高级抽象方法供应用进程调用,这样应用进程就不需要自己利用繁琐的软件方法来解决同步互斥了。存在三种高级抽象方法,分别是锁,信号量与条件变量,其中锁也在上面那篇中讨论过了,这里主要是讨论信号量与条件变量。同步互斥的层次结构如下图所示:
|
||
|
||

|
||
|
||
## 信号量
|
||
|
||
> 为什么要引入信号量?
|
||
|
||
前面我们已经用锁机制方便的解决了临界区的互斥访问问题了,但是锁机制似乎并不能适用于更一般的情况。例如操作系统某些资源是有多个的,可以让多个进程共同访问,只有当访问的进程多于资源数量的时候才对进程访问加以限制。很明显,对于这种情况,使用锁机制的结果是一次只能有一个进程访问资源,这无疑是对资源的一种浪费。
|
||
|
||
此外,锁机制只能解决进程间的互斥访问问题,并不能推广到进程间的同步问题。
|
||
|
||
因此,有必要引入一个新的机制,既可以合理地对多个资源进行分配,又可以同时兼顾同步与互斥问题。这就是引入信号量机制的原因。
|
||
|
||
> 什么是信号量?
|
||
|
||
信号量是操作系统提供的一种协调共享资源访问的方法。它采用一种简明的方法来协调多个资源的访问,即采用信号量来表示当前剩余的系统资源数量。如果有一个进程请求某个资源,则将该资源的信号量递减;反之,如果某个进程释放了资源,就将该资源的信号量递增。这里对信号量的操作都是被操作系统封装起来的,因此可以看到,信号量更多的是一种抽象数据类型,由一个整型变量和两个基本操作组成,通常用`P`操作和`V`操作来表示递减和递增,分别对应了荷兰语里面的`尝试减少`和`增加`。由于`PV`操作的对象,即`sem`变量实质上也是一个共享变量,对它的访问是需要互斥进行的,因此这里的`PV`操作都需要是原子操作。
|
||
|
||
```cpp
|
||
class Semaphore{
|
||
private:
|
||
int sem;
|
||
|
||
public:
|
||
void up();
|
||
void down();
|
||
}
|
||
```
|
||
|
||
> 信号量的实现
|
||
|
||
为了实现信号量,主要就是需要实现它的两个成员函数`down`和`up`。
|
||
|
||
当一个进程请求资源时,首先应该检查`sem`当前的值,如果`sem > 0`,表示系统还有剩余的资源,则执行`--sem`并且将资源分配给请求进程;如果`sem <= 0`,则表示当前系统已经没有这种资源了,仍然执行`--sem`,表示新增了一个进程在等待这个资源。由于当前进程得不到它请求的资源,此时应该释放CPU的控制权,调度其他进程进入运行状态。为了在资源空闲时可以唤醒当前进程,可以对每个信号量设置一个等待队列,并且将等待该信号量的进程加入等待队列中。
|
||
|
||
当一个进程释放了它占用的资源时,首先应该执行`++sem`表示将一个资源归还给了操作系统。此时如果`sem <= 0`,则表示还有其他进程在等待这个资源,所以应该从等待队列中挑选一个进程,将其唤醒;否则,如果`sem > 0`,表示当前已经有空闲的资源了,所以没有进程在等待队列中,直接退出就可以了。
|
||
|
||
根据这里的分析,给出上面`Semaphore`类的伪代码实现:
|
||
|
||
```cpp
|
||
class Semaphore{
|
||
private:
|
||
int sem;
|
||
WaitQueue q;
|
||
|
||
public:
|
||
void up();
|
||
void down();
|
||
}
|
||
|
||
void Semaphore::down(){
|
||
--sem;
|
||
if(sem < 0){
|
||
add current process to q;
|
||
schedule();
|
||
}
|
||
}
|
||
|
||
void Semaphore::up(){
|
||
++sem;
|
||
if(sem <= 0){
|
||
pick a process in q;
|
||
wakeup_proc();
|
||
}
|
||
}
|
||
```
|
||
|
||
需要指出的是,上面的代码只是示意而已,实际的实现中还有一些细节的部分在这里没有体现。在[lab7 report](lab7_report.md)里面会有具体的`P`,`V`操作的代码。
|
||
|
||
### 信号量的应用
|
||
|
||
> 利用信号量实现临界区的互斥访问
|
||
|
||
参照前面提过的锁机制,为了利用信号量实现临界区的互斥访问,只需要利用信号量来实现这样一个锁。具体说来,为每个临界区构造一个信号量,其初值为1,表示一次只能有一个进程访问临界区资源。
|
||
|
||
进程在进入临界区之间,首先需要获得锁,即对应了信号量的`P`操作;进程退出临界区之后,需要释放锁,让其他进程也可以进入,这对应了信号量的`V`操作。因此,只需要成对地使用`P`操作和`V`操作,就实现了利用信号量对临界区的互斥访问。其代码如下:
|
||
|
||
```cpp
|
||
mutex = new Semaphore(1);
|
||
|
||
mutex->down();
|
||
critical_section();
|
||
mutex->up();
|
||
```
|
||
|
||
和锁机制相比较,由于信号量引入了等待队列,等待进入临界区的进程可以不用占用CPU,因此可以提高CPU的使用率。另一方面,引入了等待队列后,可以实现等待信号量的进程先进先出,在一定程度上保证了调度的公平性,而锁机制则不能做到这点,进程获得临界区资源只能是随机的。
|
||
|
||
> 利用信号量实现进程间同步
|
||
|
||
进程间同步,即两个或多个进程之间的某些操作,需要具有一定的先后次序。例如进程1的`prev()`函数必须先于进程2的`next()`函数执行,如果进程2运行到了`next()`函数处时,`prev()`还没有执行,则进程2只能等待。下面就叙述如何利用信号量实现这种关系。
|
||
|
||
从信号量的观点来看,进程2等待的`prev`函数执行,实际上也是等待某种资源,而这种资源只有通过进程1才可以释放,在此之前这种资源的数量都是零。于是可以形成下面的代码:
|
||
|
||
```cpp
|
||
condition = new semaphore(0);
|
||
|
||
//for process 1
|
||
process1(){
|
||
prev();
|
||
condition->up();
|
||
}
|
||
|
||
//for process 2
|
||
process2(){
|
||
condition->down();
|
||
next();
|
||
}
|
||
```
|
||
|
||
可以看到,为了实现两进程之间的同步,信号量必须成对地出现在两个不同的进程中,并且其位置也要相互匹配。
|
||
|
||
> 利用信号量实现生产者-消费者问题
|
||
|
||
生产者-消费者问题是指,存在某个有限大小的缓冲区,以及若干个生产者和一个消费者。每个生产者一次可以将一个单位的数据存放在缓冲区中,而消费者一次可以从缓冲区中读出一个单位的数据。每个生产者之间以及生产者与消费者之间,每次都只能有一个进程访问缓冲区。当缓冲区满时,生产者将不能生产数据;相应的,缓冲区为空时,消费者将不能读出数据。下面讨论如何利用信号量机制来实现该问题。
|
||
|
||
通过上面的问题描述,可以抽象出该问题中存在的若干同步互斥关系。
|
||
|
||
+ 任意时刻只能有一个进程进入缓冲区进行访问,即互斥访问缓冲区。
|
||
+ 缓冲区满时,生产者必须等待消费者。
|
||
+ 缓冲区空时,消费者必须等待生产者。
|
||
|
||
可见,问题的关键,就在于利用信号量实现上面三组同步互斥关系。首先,为了实现对缓冲区的互斥访问,需要设置一个二进制信号量`mutex`;为了指示生产者等待消费者的关系,设置二进制信号量`emptybuffer`,表示当前空闲缓冲区的大小;相应的,也设置信号量`fullbuffer`表示当前已被占用的缓冲区的大小。这样,根据上面叙述的利用信号量实现同步互斥的模式,可以形成下面的代码:
|
||
|
||
```cpp
|
||
mutex = new semaphore(1);
|
||
emptybuffer = new semaphore(n); //buffers are all empty in the begining
|
||
fullbuffer = new semaphore(0);
|
||
int count = 0;
|
||
|
||
//for producer
|
||
produce(){
|
||
emptybuffer->down();
|
||
mutex->down();
|
||
//produce
|
||
mutex->up();
|
||
fullbuffer->up();
|
||
}
|
||
|
||
//for consumer
|
||
consume(){
|
||
fullbuffer->down();
|
||
mutex->down();
|
||
//consume
|
||
mutex->up();
|
||
emptybuffer->up();
|
||
}
|
||
```
|
||
|
||
需要注意的是,无论是生产者还是消费者,一开始的两个`P`操作的顺序是不能颠倒的,否则可能出现这样一种情况,比如生产者成功进入了临界区,却发现没有空闲的缓冲区可供写入了,于是就等待`emptybuffer`信号。然而,此时消费者也无法进入被占用的缓冲区,在等待`mutex`信号。系统就进入了死锁状态。
|
||
|
||
## 条件变量与管程
|
||
|
||
### 管程的基本概念
|
||
|
||
信号量机制已经可以很优雅地实现多个进程之间的同步互斥问题了,但是信号量的使用还是存在一些其他问题,例如前面利用信号量实现进程之间的同步,需要不同进程的`P V`操作互相匹配,这使得进程的独立性下降而进程之间的耦合性增强。一旦程序员在编程过程中,忽略或者弄错了这种匹配关系,由于进程调度的不确定性,这种错误要检查起来是非常困难的。
|
||
|
||
管程就是为了解决这个问题的。所谓管程,实际上就是把多个进程要同步互斥访问的这些资源全部封装起来,由操作系统统一进行管理,它是一种用于多进程互斥访问共享资源的程序结构,因为采用了面向对象方法,所以简化了进程之间的同步控制。
|
||
|
||
在管程的内部,实际上是使用`条件变量`来代表某一类资源,一个管程内部往往有多个条件变量。这里的条件变量,其实就类似于信号量机制中的信号量。每个条件变量都有两个操作,即`wait`操作和`signal`操作——一个进程进入管程后,需要请求某一类的资源,如果不能成功获得资源,则需要调用`wait(cond_var)`表示该进程在等待这个条件变量;当进程使用完资源以后,调用`signal(cond_var)`来释放条件变量。可见,`wait`和`signal`操作分别对应了信号量的`P`操作和`V`操作,当然它们也有一定的区别,比如管程的`wait`操作一定会使进程进入等待状态,而`P`操作则未必。
|
||
|
||
和信号量一样,为了使得进程可以等待某一个条件变量,每个条件变量也都设置了一个等待队列。但是和信号量不一样的是,条件变量并不管理空闲资源的数量,而只是维护了等待当前条件变量的进程个数,这样,可以给出条件变量的类定义:
|
||
|
||
```cpp
|
||
class condvar{
|
||
private:
|
||
int num_waiting;
|
||
WaitingQueue q;
|
||
public:
|
||
void wait();
|
||
void signal();
|
||
}
|
||
```
|
||
|
||
这里的条件变量,本质上也是属于进程之间的共享资源,对它们的访问也需要互斥地进行。因此,任一时刻至多只能有一个进程可以进入管程执行管程代码,为此,管程还需要一个互斥访问锁。这样,就可以给出管程的类定义了:
|
||
|
||
```cpp
|
||
class monitor{
|
||
private:
|
||
lock mutex;
|
||
condvar *cv;
|
||
public:
|
||
//monitor routines
|
||
}
|
||
```
|
||
|
||
管程的组成如下图所示:
|
||
|
||

|
||
|
||
这样,当一个进程想要访问管程代码时,首先需要请求管程的互斥访问锁,获得了这个锁后方可进入管程,并且阻止其他进程的进入。一旦进程请求某个资源失败,它将进入等待状态并且放弃CPU的使用权,但是除此以外,它还需要释放管程锁以使其他进程也可以进入管程;当该进程终于获得这个资源时,它将从等待状态进入运行态,并且重新请求管程锁以继续执行。下面给出`wait`和`signal`的伪代码实现:
|
||
|
||
```cpp
|
||
void condvar::wait(){
|
||
num_waiting++;
|
||
q.enqueue(curr_proc);
|
||
curr_proc.state = SLEEPING;
|
||
mutex.release(); // release lock so that other processes can enter the monitor
|
||
schedule();
|
||
mutex.acquire(); // re-acquire lock
|
||
}
|
||
|
||
void condvar::signal(){
|
||
if(num_waiting > 0){
|
||
--num_waiting;
|
||
pick up one process from the waiting queue
|
||
wakeup(proc);
|
||
}
|
||
}
|
||
```
|
||
|
||
需要注意的是,上面给出的`wait`和`signal`操作只是示意而已,在具体的应用中会根据策略的不同而具有不同的实现方式。但不管怎么样,它们的内涵都是一样的。常见的两种策略有`Hansen`管程和`Hoare`管程,它们之间的区别将在下面涉及。
|
||
|
||
### 利用管程实现生产者消费者问题
|
||
|
||
根据前面对生产者-消费者问题的分析,为了实现缓冲区的互斥访问,并不需要额外的变量,使用管程的互斥访问锁就可以了。对于生产者而言,如果缓冲区满了,则需要等待`缓冲区有空`条件,为此设置`notFull`条件变量;对于消费者而言,如果缓冲区为空,则需要等待`缓冲区非空`条件,为此设置`notEmpty`条件变量。此外,还需要一个额外的变量`count`来表示当前缓冲区被占用的个数。具体的代码如下:
|
||
|
||
```cpp
|
||
void monitor_init(){
|
||
int count = 0; // buffers are empty in the beginning
|
||
lock mutex;
|
||
cond_var notFull;
|
||
cond_var notEmpty;
|
||
notFull.num_waiting = 0;
|
||
notEmpty.num_waiting = 0;
|
||
}
|
||
|
||
// for producer
|
||
void producer(){
|
||
mutex.acquire();
|
||
while(count == n)
|
||
notFull.wait();
|
||
produce();
|
||
++count;
|
||
notEmpty.signal();
|
||
mutex.release();
|
||
}
|
||
|
||
// for consumer
|
||
void consumer(){
|
||
mutex.acquire();
|
||
while(count == 0)
|
||
notEmpty.wait();
|
||
consume();
|
||
count--;
|
||
notFull.signal();
|
||
mutex.release();
|
||
}
|
||
```
|
||
|
||
当时看到这里,我不禁产生一些疑问——这里的管程访问代码还不是用户自己写的吗,为什么说管程代码是由操作系统管理的呢?实际上,用户编写用户程序的时候,只是写了实质性的操作代码,类似于上面的`produce()`和`consume()`处的代码,前后的请求锁、对资源的请求与释放,释放锁都是由编译器添加的,是毋须由程序员关注的。
|
||
|
||
### `Hansen`管程和`Hoare`管程
|
||
|
||
管程的实现策略有两种,分别是`Hansen`管程和`Hoare`管程,它们的主要区别在于进程调度策略的不同。
|
||
|
||
假定首先有一个进程进入管程执行管程的例程,它在执行过程中需要等待某个资源,因此该进程进入阻塞状态,并且放弃了CPU的使用权。此后,第二个进程得以进入管程,该进程首先是释放了第一个进程请求的资源,第一个进程因而得以被唤醒。在`Hansen`管程的语意下,第二个进程将继续执行,直到它因为等待某个资源或者执行完毕,而让出管程的访问权限,此后第一个进程才有可能被调度。`Hansen`管程执行的流程如下图所示:
|
||
|
||

|
||
|
||
以上面的生产者-消费者问题为例,此时生产者的代码就和上面给出的一样,需要注意的是这行代码:
|
||
|
||
```c
|
||
......
|
||
while(count == n)
|
||
notFull.wait();
|
||
......
|
||
```
|
||
|
||
这是因为,在`Hansen`管程的语意下,被阻塞的进程需要和其他尚未进入管程的进程,同时竞争管程访问的权限`mutex`。因此,当被阻塞进程被唤醒后,也许有一个另外的生产者进程已经得到执行,使得当前缓冲区再次满了,被唤醒的进程需要再次判断它请求的资源是否为空闲。
|
||
|
||
而在`Hoare`管程的语意下,第二个进程释放第一个进程的资源后,将第一个进程唤醒,然后自己立刻进入阻塞状态,此时第一个进程将得到调度执行。该语意的实质是保证已经进入管程的阻塞进程优先于尚未进入管程的进程执行。因此,当第一个进程执行完毕后,它不会释放锁,而是直接将CPU的控制权转交给第二个进程。`Hoare`管程的执行流程如下图:
|
||
|
||

|
||
|
||
在`Hoare`管程的语意下,`wait`操作和`signal`操作都需要做一定的修改。具体说来,在`signal`操作中,唤醒一个等待进程后,不释放管程锁,将自身加入`signal queue`中进入等待状态,此时只有被唤醒的进程可以得到管程的使用权。一个进程执行管程例程结束后,首先检查是否有处于`signal queue`状态的进程,如果有,也不释放管程锁,直接选择其中一个唤醒即可。`hoare`管程的具体代码在[lab7 report](lab7_report.md)中有所体现。
|
||
|
||
此时,生产者-消费者问题中的生产者代码将作出如下改动:
|
||
|
||
```c
|
||
void producer(){
|
||
......
|
||
if(count == n)
|
||
notFull.wait();
|
||
......
|
||
}
|
||
```
|
||
|
||
这是因为进程被唤醒后,一定可以优先得到管程的使用权,因此不需要像上面那样对条件做循环判断。
|