《现代操作系统》读书笔记之——进程间通信

    很多时候,进程需要和其他的进程进行通信。比如shell中的管道命令:ps -ef | grep nginx,一个命令的输出,作为另一个进程的输入,这就是进程间通信(Interprocess Communication)。

    进程间通信主要需要解决三个问题:

    1.一个进程如何给另一个进程传递信息

    2.如何确保进程之间不互相干扰、妨碍

    3.当进程间出现依赖关系时,该如何处理。

    尽管这里讨论的是进程之间的通信,但其实对于线程来说,他们之间的通信需要解决后两个问题。由于多个线程处在相同的进程,因此也处在同一个地址空间中,所以第一个问题自然很好解决。但是第二个、第三个问题还是存在的,当然解决的方案其实与进程间通信在处理这两个问题上采取的方案也是类似的。下面的内容会涉及上面的三个问题。

    1.竞争条件(Race Condition)

    在一些操作系统中,多个进程会共享一部分内存,每个进程都可以对他们进行读写操作。共享的内存有可能再内存中,也可能是一个共享的文件。为了看看进程间通信之间的竞争条件,举个简单的例子加以说明:打印池。假如一个进程想打印一个文件,于是他将文件名输入打印池目录中。有一个负责打印的进程——打印机守护进程——每隔一段时间会查看一下打印池目录中有没有需要打印的文件。有的话就打印,没有拉到。

    打印机目录的示意图如下:

    图中的每个小格子可以存放一个待打印的文件名(实际上应该是需要打印的文件的指针,这里只是为了说明问题做的假设)。同样,还需要假设两个共享的变量:一个叫out存储下一个轮到打印的文件的文件名;另一个叫in存储上图中下一个可以存放待打印文件文件名的小空格。这两个变量可能被存储再一个文件中,而这个文件共享给了所有的进程。

    上图所示的时刻,单元1、2、3已经空了,也就是说,之前存在里面的文件已经打印了。而5-9号空格还是空的,也就是说接下来需要打印的文件依次存放在下面的单元中。这一时刻,变量in存储的应该是5。假设这时候,进程A读取变量in,得到的值是5。于是,进程A将这个值存储到他的局部变量next_free_slot中,这时候,恰好CPU时间片到了,进程A重新回到可运行状态,而此时进程B获得了时间片,开始运行,它也有文件需要打印,那么它读取in,获得的值是5,于是它也将5这个值存储进他的局部变量next_free_slot中。然后它根据这个变量的值,将它需要打印的文件的文件名,假设是ccc.txt存储进单元格5中。等待打印。然后进程B开始干别的事情了。又过了一段时间,进程B的CPU时间片用完了。而进程A又获得了CPU时间片。它开始继续运行,先读取next_free_slot的值,得到的是5,于是,可怕的事情发生了,它把自己需要打印的文件名写入单元格5,也就是说,覆盖掉了进程B之前放置的需要打印的文件ccc.txt。进程B可能一直在等待打印的输出,但是永远都等不到了。

 2.临界区(Critical Regions)

    如何避免竞争条件?避免出现这种麻烦,或者说在任何涉及到共享内存、文件或者其他一切共享资源的情况下的处理策略是防止多于一个进程在同一时刻读写共享数据。换句话说,互斥(Mutual Exclusion)。 

  对于大多数的操作系统来说,选择合适的原语来实现互斥是一个主要的涉及手段。临界区(Critical Region/Critical Section)是指进程中那些访问共享内存并且可能导致竞争状态的部分。要防止竞争状态的出现,我们可以理解成要防止两个需要共享同一块内存的进程不要同时进入自己的临界区。

    但是仅仅如此是不够的,需要坚持下面的四个原则:

  • 不允许两个进程同时进入各自的临界区
  • 不要对任何关于CPU速度和CPU数量的假设
  • 不允许运行在自己临界区外的进程阻塞别的进程
  • 不允许任何进程无休止的等待进入自己的临界区

    上面描述的是一种抽象的解决问题的思路,可以简单的使用一个图作为例子来描述使用临界区则个概念来体现互斥操作。

   3.实现互斥的几种方案之——禁用中断(Disable Interrupt)

    在单处理器系统中,这时最为简单的方案。也就是当进程进入临界区后,禁用中断,等它离开临界区再开启。CPU能够切换进程的前提是可以发生时钟中断或者其他中断。这就意味着,当一个进程进入临界区,他就不可能被剥夺CPU使用权,直到它离开临界区。

    这种方案显然没有吸引力,其缺点有两点:

  • 给用户进程权利去禁用中断是不明智的,如果有进程禁用了中断并且再也不开启,那系统最后将会死掉。
  • 如果多个CPU,当一个进程禁用中断,只是它正在使用的CPU会被影响,而此时,另外的CPU上的进程可能还是会访问临界区,进入修改共享内存的内容 

   尽管如此,对于操作系统的内核来说,这却是一个很方便的技巧。当内核正在更新就绪进程表时,它可能会在几个指令的时间内禁用中断。

   此外,现在使用这种方式实现互斥越累越少了,毕竟,现在的机器都是多核的。

    4.实现互斥的几种方案之——锁变量(Lock Variables)

    这种方案的原理是设计一个所变量,初始化为0,某个进程要进入临界区,先查看该变量的值,如果是0,则可以进入临界区,并且将该变量的值设置为1,等它离开临界区再将这个变量设置为0.简而言之,这个变量为0代表没有进程正处在临界区,1代表有进程处在临界区。

    但是这个方案存在的问题和前面举例的那个打印机问题一样。

    5.实现互斥的几种方案之——严格变更(strict alternation)

    这个方案的基本原理如下面的两段代码所示:

 

 1 while(TRUE) {
2 while(turn != 0) {
3 }
4 critical_region();
5 turn = 1;
6 noncritical_region();
7 }
8
9 while(TRUE) {
10 while(turn != 1) {
11 }
12 critical_region();
13 turn = 0;
14 noncritical_region();
15 }

    整型变量turn,用来跟踪到底那个进程当前可以进入自己的临界区。对于进程0来说,不断检测turn是否等于0,如果是则它可以进入自己的临界区,如果turn不等于0,那么它就继续等待,并且持续的检测这个变量的值。如果进程0进入了临界区,当它出来之后,它将turn设置为1,也就是轮到进程1(另外的一个进程)进入临临界区了。

    对于进程1来说,它执行的是第二段代码,但是原理一样。对于进程来说,必须不断的检查一个变量的值来判断是否轮到自己进入临界区,这种情况叫做忙等待(busy waiting)。这种情况是应该被避免的,因为它很浪费CPU的时间。使用忙等待实现的锁,叫做自旋锁(spin lock)。

    这种方案还有一个问题。如果两个进程执行的速度差距很大。我们假设进程0执行速度远远快于进程1。一开始,turn的值为0,进程0进入临界区,很快,进程0离开临界区,将turn设置为1,这时候,进程1开始进入临界区,而此时进程0可能会已经离开非临界区,并且又持续监测turn的值。进程1一离开临界区,将turn再次设置为0,进程0又进入临界区,而进程1进入非临界区。由于进程0执行速度很快,它很快再次执行完临界区的代码,将turn设置为1,然后执行非临界区代码。由于进程0非常快,进程1很慢,很可能进程1还在第一次的非临界区,这时,进程0已经执行完第二次非临界区的代码,而此时turn还是它刚才设置的值——0,而其实另一个进程,进程1,并没有在临界区,而是在非临界区挣扎。这种情况下,进程0却只能干等。

    所以,很明显,这种方案非常不适合多个进程之间执行速度差距很大的情况。而我们这一节的标题叫做strict alternation,举个例子说,就是像前面提到的打印池,strict alternation不允许一个进程一次提交大于1个需要打印的文件。这可能也是为了不要是进城之间的执行速度差距过大吧。

    6.实现进程互斥的几种方案之——Peterson方案

    这个方案的历史我们这里略过不说,其基本原理如下面的实例代码:

 1 #define FALSE 0
2 #define TRUE 1
3 #define N 2
4
5 int turn;
6 int interested[N];
7
8 void enter_region(int process) {
9 int other;
10 other = 1 - process;
11 interested[process] = TRUE;
12 turn = process;
13 while(turn==process && intrested[other] == TRUE)
14 }
15 void leave_region(int process) {
16 intrested[process] = FALSE;
17 }

    进程在进入 临界区之前,需要执行enter_region函数,并将自己的进程号作为参数传递进去。当进程离开临界区,需要执行和leave_region函数。

    我们具体看看原理:对于进程0和进程1。一开始谁都没有在临界区,现在进程0调用enter_region函数。首先,进程0将对应于自己的数组元素intrested[0]设置为TRUE,并且将turn的值设置为自己的进程号,也就是0。然后这一步很关键,做一个循环的检查,如果轮到了自己(turn==process)但是另一个进程也很感兴趣(intrested[other]==TRUE)。如果使这样的话,那么什么也不做,等着。否则的话,真正开始进入临界区。

    如果进程0执行完了临界区代码,那么就将intrested[0]设置为FALSE,也就是说表示自己此刻不需要再进入临界区了。

    这种方案会不会发生两个进程都在干等着,最后死锁呢?我们看看。假设进程0和进程1几乎同时执行到turn=process代码,假设进程0先执行这条语句,紧接着进程1也执行这条语句,从而擦除之前进程0设置的值。那么接下来进程0就只能在那个while循环里面等着了,而进程1则真的进入自己的临界区。这也是为什么我在上面说这个里层的while循环很重要了。

 

    7.实现进程互斥的几种方案之——TSL指令

    前面介绍了几种方案,都是通过软件的方式实现互斥,下面的这种方式需要借助硬件设计的帮助来实现互斥。这一点在多CPU电脑的设计中尤其普遍。这种方案需要引进一条指令:

 

1 TSL RX,LOCK

     这条指令的含义是,读取内存单元LOCK中的内容到寄存器RX中,并且为内存单元LOCK重新设置一个非0值。TSL指令的操作被设计为不可分的,也就是说,整个读写操作都完成之前,其他进程是没办法访问LOCK这个内存单元的。这一点是通过锁定内存总线(lock memory bus)来实现的。

     前面我们提到了禁用中断的方式。这种锁定内存总线的方式和前者有很大的差别,前面我们提到过,禁用中断只对当前的CPU有效,对于其他CPU是无效的,因此是无法实现多CPU情况下的互斥。而锁定内存总线则不同,一旦锁定内存总线,其他CPU上的进程也无法访问LOCK内存,直到整个TSL指令执行完成。 使用TSL指令实现互斥的方法如下面的代码所示,可能是汇编代码吧微笑

 

1 enter_region:
2 TSL REGISTER,LOCK | 将LOCK的值复制进寄存器
3 CMP REGISTER,#0 | 比较并判断REGISTER的值是不是0
4 JNE enter_region | 如果非0,那么设置锁成功,循环返回到调用处,进入临界区
5 RET
6
7 leave_region:
8 MOVE LOCK,#0 | 将LOCK的值设置为0
9 RET | 返回

    其实这个方案的思路和前面的Peterson方案很类似,具体的说明都在注释里面了。当一个进程需要进入其临界区的话,执行enter_region,依然是忙等待直到这个锁是可获得的。然后,进程获取该锁。需要注意的是,和所有的使用忙等待方式的互斥解决方案一样,进程都必须在正确的时候调用enter_region和leave_region,如果有进程作弊,那这个方法是不会有用的。

     TSL指令还有一个类似的指令XCHG,这个指令能够自动交换两个地址的内容。使用XCHG的方案几乎和TSL方案一样。所欲的Intel X86CPU都使用XCHG指令提供底层同步。使用XCHG实现的代码如下:

 

 1 enter_region:
2 MOVE REGISTER,#1
3 XCHG REGISTER,LOCK
4 CMP REGISTER,#0
5 JNE enter_region
6 RET
7
8 leave_region:
9 MOVE LOCK,#0
10 RET

    8.休眠与唤醒 

 

    上面提到了几种实现互斥的方案,本质上说,他们都是一种采用忙等待的方式,并且不断地检查当前条件是否允许其运行。这种方式不仅仅会浪费CPU时间,还有一个很大的问题。我们假设一个电脑有两个进程运行,一个是H,具有高的优先级,另一个是L,具有低优先级。现在假设情况是这样的:只要H处在ready状态,CPU就会调度H运行。现在假设某一时刻,阴差阳错也好,还是什么也好,L进入了临界区,但是运行到中途,时间片到期了,这时候H处在ready状态,但是由于L还处在临界区,那其实H也无法运行,而L又没有时间片。结果就是,大家就这么干耗着。这种情况被叫做优先级反转问题(Priority Inversion Problem)。

 

    下面看两个最基本的进程间通信的原语。sleep和wakeup,sleep是一个系统调用,他可以使调用者阻塞直到有另一个进程唤醒它,wakeup则有一个参数,就是被唤醒的进程编号。

 

    9.生产者-消费者问题

 

    作为上面的一对原语的使用例子,介绍生产者-消费者问题。或者叫做有限缓冲问题(bounded-buffer problem):两个进程共享一个大小固定的缓冲区,其中生产者(Producer)将产生信息放入缓冲区的某个单元,消费者(Consumer)则取出缓冲区中的消息。(这么问题可以演化成m个生产者、n个消费者的问题,但这里仅以1个生产者1个消费者为例)。

 

    如果生产者要将信息放进缓冲,而此时缓冲区已经满了,或者说消费者需要拿出信息而此时缓冲区是空的时,问题就来了。第一情况下,我们可以让生产者sleep,等消费者拿出了消息,有了空的缓存单元时,让消费者wakeup生产者。后一种情况也是类似的。为了避免出现类似于前面的打印池目录出现的问题,我们需要一个count变量,来记录当前缓冲区目前有几个消息。生产者在生产消息时,先要检测一下count是否等于缓冲区的大小N;同样,消费者在消费消息的时候,需要检测count是不是0.

 

    解释这个问题的代码如下:

 

 1 #define N 100
2 int count 0;
3
4 void producer(void) {
5 int item;
6 while(TRUE) {
7 item = produce_item();
8 if(count == N) {
9 sleep();
10 }
11 insert_item(item);
12 count = count + 1;
13 if(count==1) {
14 wakeup(consumer);
15 }
16 }
17 }
18
19 void consumer(void) {
20 int item;
21 while(TRUE) {
22 if(count == 0) {
23 sleep();
24 }
25 item = remove_item();
26 if(count == N - 1) {
27 wakeup(producer);
28 }
29 consume_item(item);
30 }
31
32 }

    但尽管如此,还是有可能有问题。因为对count的访问是无限制的,换句话说,不是原子性的。我们假设下面的情况。一开始,buffer是空的,消费者监测count,发现是0,这时,消费者进程时间片到期,消费者进程变成runnable状态,生产者这时候被调度运行,它检测count,发现<N,于是生产一个消息,并且将它放进buffer。现在count等于1,于是生产者调用wakeup(consumer),问题就出在这了,这时候消费者进程是处在Runnable状态,wakeup对它来说是没有作用的。接着又轮到消费者运行了,消费者之前检查过的count值为0,消费者一看,就以为buffer中还是没有消息,于是,sleep自己。最终的某个时刻,生产者最终会将buffer填满,于是自己也沉沉睡去,但是生产者等不到消费者的唤醒,因为他们两个都在sleep。
    解决这个问题的一种临时办法是设置一个wakeup waiting bit标记位。
 

 

    10.信号Semaphore

 

    Dijkstra于1965年提出了信号量的概念。信号量是一种变量类型,它允许值为0,代表目前没有wakeup等待的进程,或者一个正整数,代表当前wakeup等待的进程数目。Dijkstra提出信号的信号类型有两种操作:up和down操作(Dijkstra提出的是P和V操作)。up和down操作其实是sleep和wakeup原语的一种实现。

 

    对于down操作来说,它先检查值是否大于0,如果是,则值减1,并且继续操作;如果不是,进程就会被休眠,此刻down操作就无法完成。检查值、减少值,或者可能的sleep被打包成一个原子操作,也就是不可分的。它保证一旦一个信号操作开始,除非它完成或者sleep了,都则,别的进程都无法接触这个信号。

 

    对于up操作来说,它将值加1,如果当前这个信号有一个或者多个进程在这个信号上由于之前的down操作未完成(当时值为0)休眠了,那么系统会随机的选择一个进程,并且唤醒它。如果有多个进程在该信号上休眠,那么执行一次up操作后信号的量的值依然是0,但是在这个信号上休眠的进程就少了一个。增加值,并且唤醒休眠在该信号的操作也是不可分的。up操作是不会造成阻塞的。

 

    11.使用信号来解决生产者-消费者问题

 

    用信号量来解决生产者消费者问题,涉及到三个信号量:full——代表有多少个buffer单元是满的,empty——有多少个buffer单元是空的,mutex——用来确保生产者消费者不能同时访问一个buffer单元。其原理代码如下:

 

 1 #defone N 100   /*buffer的大小*/
2 typedef int semaphore;
3 semaphore mutex = 1;
4 semaphore full = 0;
5 semaphore empty = N;
6
7 void producer(void) {
8 int item;
9 while(TRUE) {
10 item = produce_item();
11 down(&empty);
12 down(&mutex);
13 insert_item(item);
14 up(&mutex);
15 up(&full);
16 }
17 }
18
19 void consumer(void) {
20 int item;
21 wile(TRUE) {
22 down(&full);
23 down(&mutex);
24 item = remove_item();
25 up(&mutex);
26 up(&empty);
27 consume_item(item);
28 }
29 }

 

    像mutex这样,最开始初始化为1,并且用于多个进程之间来保证他们中一次只有一个能进入其临界区的信号被叫做二元信号量(Binary Semaphore)。如果每个进程在进入临界区之前进行一次down操作,而在离开临界区之后做一次up操作,那么互斥就可以得到保证。

 

    在上面的代码中,使用信号量有两种不同的方式:第一种方式,用来保证互斥,mutex的使用就是为了确保同一时刻只有一个进程进入其临界区;第二种方式,用来确保同步,full和empty的使用确保当buffer已经满的时候producer不再运行而当buffer为空的时候consumer不再运行。

 

12.互斥量(mutex)

 

    当信号量(Semaphore)的计数功能不再需要,信号量简化之后就成为一种新的变量互斥量(mutex)。互斥量在处理共享资源和代码之间的互斥访问方面非常有用。互斥量实现起来简单高效,这一点对于用户空间的线程库非常有用。

 

    互斥是那种只有两种状态,但每次只能处在其中一种状态的变量。这两种状态分别是锁定和非锁定状态。因此,只需要一个比特就能表示互斥量的两种状态,例如,0代表非锁定和1表示锁定。互斥量有两个相关的操作:mutex_lock和mutex_unlock。

 

    mutex_lock:当线程需要进入临界区时,它调用mutex_lock操作,若当前mutex未锁定,则mutex_lock调用成功,若mutex已经锁定,则调用线程会阻塞,直到当前获得mutex锁的那个线程离开临界区,使得mutex解锁。

 

    mutex_unlock:当线程离开临界区,需要解除对mutex的锁定,这时候线程调用mutex_unlock。mutex很简单,因此在用户空间可以很简单的通过TSL和XCHG指令实现,其原理的代码如下:

 

mutex_lock:
TSL REGISTER,MUTEX
CMP REGISTER,#0
JZE ok<span style="white-space:pre"> </span>| 如果mutex为0,则证明mutex目前未锁定,于是返回
CALL thread_yield
JMP mutex_lock<span style="white-space:pre"> </span>| 再次尝试
ok: RET

mutex_unlock:
MOVE MUTEX,#0<span style="white-space:pre"> </span>| 解锁:将mutex设置为0
RET

    这个代码和前面讲到的enter_region的代码非常类似,但是却有一个很关键的区别:在enter_region中,如果进程暂时不能进入临界区,那么他一直测试条件,看能否进入,直到时间片用完为止。

 

    而在用户线程中,根本没有时钟中断来使得运行过长的线程停下来,结果就是一个使用上述的忙等待方式企图获取锁的线程会时钟循环,因为别的获得锁的线程根本没机会运行。

 

    而在mutex_lock中则不同,如果当前mutex锁定,他就阻塞,让调度机调度其他的线程运行,从而更加充分的利用CPU资源。

 

    由于thread_yield仅仅是在用户空间对线程调度器的调用,因此,不需要内核支持,这样实现起来是很简单的。

 

    除了mutex_lock和mutex_unlock之外,可能还需要其他的一些特性,例如mutex_trylock,该调用会尝试获得锁,但是如果不成功就以失败状态返回,然后该干嘛干嘛,至少并不阻塞。

 

    由于线程之间共享一个内存空间,所以对于多个线程来说,共享mutex不是什么难事。但是之前提到的Peterson方案、信号量方案等主要是对于进程来说的。他们之间并不共享一份内存空间。那怎么保证多个进程之间共享变量,例如之前提到的turn呢?至少有这么几种办法:

 

  • 将共享数据类型,例如信号量,存储在内核,然后只允许通过系统调用来访问
  • 大部分现代操作系统(Windows和Unix)都支持使多个进程共享一部分内存空间
  • 实在不行多个进程还能共享文件吧

    如果说多个进程共享大部分内存空间,那么进程和线程的区别就不是那么的明显,但还是存在的。比如说,两个进程还是有自己单独的打开的文件等独有的属性,但这些属性对于多个线程来说是共享的。

 

    13.Pthread库中的互斥量

 

    Pthread库中同步的方法的原理还是互斥量。如果一个线程需要进入临界区,那么它需要尝试锁住相关的互斥量。如果互斥量未锁定,它进入临界区并且立马锁定互斥量,并借此防止别的线程进入临界区。

 

    Pthread的几个主要的调用如下所示,他们的作用都在注释中显示:

 

Pthread_mutex_init        //创建mutex
Pthread_mutex_destroy //销毁mutex
Pthread_mutex_lock //锁定mutex
Pthread_mutex_trylock //尝试锁定mutex
Pthread_mutex_unlock //解锁mutex

    Pthread库在实现同步方面还需要使用到另外一种机制:条件变量(Condition Variable)。条件变量往往和互斥量一起使用来确保同步。回想一下生产者-消费者问题。当生产者先检查缓冲区是否已经满了,这可以通过mutex实现,而不需要其他线程的干扰,但是如果发现已经满了,那么就需要一种机制让它阻塞以及之后被唤醒。这就需要通过条件变量来实现了。

 

    与条件变量相关的库调用如下所示:

 

Pthread_cond_init        //创建条件变量
Pthread_cond_destroy //销毁条件变量
Pthread_cond_wait //阻塞并且等待信号
Pthread_cond_signal //发信号唤醒另一个线程
Pthread_cond_broadcast //广播唤醒多个线程

    互斥量和条件变量总是一起被使用。使用的基本模式如下:一个线程先锁定mutex,然后等待条件变量(换句话说,就是它需要的资源)知道另一个线程接下来可以给它发信号以便继续。

 

    还是以只有一个单元的缓冲区生产者-消费者问题为例看看Pthread的使用:

 

#include <stdio.h>
#include <pthread.h>
#define MAX 1000000000
pthread_mutex_t the_mutex;
pthread_cond_t condc,condp;
int buffer = 0;

void *producer(void *ptr) {
int i;
for(i = 0; i <= MAX; i++) {
pthread_mutex_lock(&the_mutex);
while(buffer != 0) {
pthread_cond_wait(&condp,&the_mutex);
}
buffer = 1;
pthread_cond_signal(&condc);
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}

void *consumer(coid *ptr) {
int i;
for(i = 0; i <= MAX; i++) {
pthread_mutex_lock(&the_mutex);
while(buffer == 0) {
pthread_cond_wait(&condc,&the_mutex);
}
buffer = 0;
pthread_cond_signal(&condp);
pthread_mutex_unlock(&the_mutex);
}
pthread_exit(0);
}

int main(int argc,char *argv) {
pthread_t pro,con;
pthread_mutex_init(&the_mutex,0);
pthread_cond_init(&condc);
pthread_cond_init(&condp);
pthread_create(&con,0,consumer,0);
pthread_create(&pro,0,producer,0);
pthread_join(pro,0);
pthread_join(con,0);
pthread_cond_destroy(&condc);
pthread_cond_destroy(&condp);
pthread_mutex_destroy(&the_mutex);
}

    14.监视器(Monitor)

      在上一篇博客中提到了那个信号量的例子。对于生产者来说,如果将两个down操作的顺序颠倒一下,后果就会很严重。我们先把那个代码回顾一遍:

 

#defone N 100   /*buffer的大小*/
typedef int semaphore;
semaphore mutex = 1;
semaphore full = 0;
semaphore empty = N;

void producer(void) {
int item;
while(TRUE) {
item = produce_item();
down(&empty);<span style="white-space:pre"> </span>//看这里
down(&mutex);<span style="white-space:pre"> </span>//看这里
insert_item(item);
up(&mutex);<span style="white-space:pre"> </span>//看这里
up(&full);
}
}

void consumer(void) {
int item;
wile(TRUE) {
down(&full);
down(&mutex);
item = remove_item();
up(&mutex);
up(&empty);
consume_item(item);
}
}

    如果这两个操作颠倒了,换句话说生产者其实还没生产出来就跑去通知消费者了。假设这时候buffer已经满了,那么生产者会阻塞,下面的up(&mutex)操作根本就没执行。好了,轮到消费者了,消费者上来先down(&full),也就是消费掉一个消息,接着要down(&mutex)。但是这时候一看,mutex已经是0,然后一直等,等到晕倒了(阻塞)。于是乎,大家就一直等下去。这就是死锁。 

 

    所以说,在使用信号量解决类似的问题上,程序猿必须非常小心。一不注意,就会出事。鉴于这样的情况,又有人提出了新的办法,他们是Brinch Hansen和Hoare。他们提出了一种高层的同步原语——监视器(monitor)。监视器是一组过程(procedures)、变量(variables)、数据结构打包在一个特殊的模块或者包中。进程可以随时调用里面的过程,但是不能直接访问过程暴露给进程之外的内部数据结构。下面是一个监视器的例子,它是用一种假设的语言写的。

 

monitor example
integer i;
condition c;
procedure producer()

end;

procedure consumer()

end;
end monitor;

    监视器有一个明显的特点就是同一时刻只允许一个进程进入其中。由于监视器属于语言本身的一部分,因此监视器保证互斥往往是由语言的编译器来实现的。这样的话程序员可以基本不关注监视器如何安排互斥。但是还有一个问题就是,当进程无法继续时如何使它阻塞。这个问题通过条件变量的引进来解决。条件变量有两个相关的操作:wait和signal。当一个监视器过程发现自己无法运行,那么他对某个条件变量进行一次wait操作,例如,对full这个条件变量进行wait。wait操作会使得调用线程阻塞,从而允许别的进程进入监视器。

 

    而另外一个进程,也就是消费者,可以通过给生产者需要的那个条件变量发信号来使得生产者从阻塞中醒来。当执行完signal之后该怎么继续下去呢?有三种方案:

 

  • 让被唤醒的进程执行;
  • 执行了signal操作的进程必须立刻离开监视器
  • 让执行signal操作的进程继续进程,等该进程离开监视器后再由被唤醒的进程进入监视器。

    这里的条件变量并不是计数器,它不会保存数值以便后面使用,这一点与信号量是不同的。如果对一个没有被wait的条件变量执行signal操作,那么这个signal将会永远消失。因此必须保证wait必须在signal之前。

 

    使用监视器处理生产者、消费者问题的框架如下面的代码:

 

 1 monitor ProducerConsumer
2 condition full,empty;
3 integer count;
4
5 procedure insert(item:integer)
6 begin
7 if count = N then wait(full);
8 insert_item(item);
9 count:=count+1;
10 if count = 1 then signal(empty);
11 end;
12
13 function remove:integer
14 begin
15 if count = 0 then wait(empty)
16 remove = remove_item;
17 count = count - 1;
18 if count = N - 1 then signal(full);
19 end;
20
21 count := 0;
22 end monitor;
23
24 procedure producer
25 begin
26 while true do
27 begin
28 item = produce_item;
29 ProducerConsumer.insert(item);
30 end;
31 end;
32
33 procedure consumer
34 begin
35 while true do
36 item = ProducerConsumer.remove;
37 consume_item(item)
38 end;

    实际上,上面是采用一种假象的语言来模拟的。实际上Java语言采用了类似的设计思路,实现同步synchronized。一旦某个线程正在执行synchronized方法,此时,该对象中的其他线程不允许调用其中的任何同步方法。下面看看Java中如何解决生产者消费者问题:

 

package net.jerryblog.concurrent;

public class
ProducerConsumer {
static final int N = 100; // constant giving the buffer size
static producer p = new producer(); // instantiate a new producer thread
static consumer c = new consumer(); // instantiate a new consumer thread
static our_monitor mon = new our_monitor(); // instantiate a new monitor

public static void main(String args[ ]) {
p.start(); // start the producer thread
c.start(); // start the consumer thread
}

static class producer extends Thread {
public void run( ) { // run method contains the thread code
int item;
while(true) { // producer loop
item = produce_item();
mon.insert(item);
}
}
private int produce_item(){


} // actually produce
}

static class consumer extends Thread {
public void run() { // run method contains the thread code
int item;
while(true) { // consumer loop
item = mon.remove();
consume_item (item);
}
}
private void consume_item (int item) { } // actually consume
}

static class our_monitor { // this is a monitor
private int buffer[ ] = new int[N];
private int count = 0, lo = 0, hi = 0; // counters and indices

public synchronized void insert (int val) {
if(count == N) go_to_sleep(); //if the buffer is full, go to sleep
buffer [hi] = val; // insert an item into the buffer
hi = (hi + 1) % N; // slot to place next item in
count = count + 1; // one more item in the buffer now
if(count == 1) notify( ); // if consumer was sleeping, wake it up
}

public synchronized int remove( ) {
int val;
if(count == 0) go_to_sleep( ); // if the buffer is empty, go to sleep
val = buffer [lo]; // fetch an item from the buffer
lo = (lo + 1) % N; // slot to fetch next item from
count = count - 1; // one few items in the buffer
if(count == N - 1) notify(); // if producer was sleeping, wake it up
return val;
}
private void go_to_sleep() {
try{
wait( );
}catch (InterruptedException exc) {

}

}
}
}

   15.消息传递机制

    消息传递是另外一种进程间通信的方式。消息传递机制使用两个原语:send和receive。这一点和信号量很类似。send和receive是系统调用,而不是语言层次的设计。

 

1 send(destination,&message);
2 receive(source,&message);

    前面一个调用将消息发送给指定的接收者,后者从一个发送者处接收消息。如果没有消息则阻塞或者说返回错误代码。

 

    消息传递系统再设计上有一些信号量或者监视器等所不曾碰到的问题,尤其是当通信进程处在不同的机器上。比如,当消息在网络传输中丢失了。因此一旦接受者接收到了消息,必须返回一个回执(Acknowledgement)。如果说发送者没有收到这个回执消息,那么他会重新发送。

 

    那么这又有一个新的问题,怎么区别发送者发送的两次消息是一个消息,只不过由于没有收到回执从新发送了一次?因此给消息一个唯一的序列号,如果接受者接收到的两次消息是一个序列号,那证明是从新发送。
    使用消息传递机制实现的生产者消费者问题代码如下:

 

 1 #define N 100     /* number of slots in the buffer */
2 void producer(void)
3 {
4 int item;
5 message m; /* message buffer */
6
7 while (TRUE) {
8 item = produce_item( ); /* generate something to put in buffer */
9 receive(consumer, &m); /* wait for an empty to arrive */
10 build_message (&m, item); /* construct a message to send */
11 send(consumer, &m); /* send item to consumer */
12 }
13 }
14
15 void consumer(void) {
16 int item, i;
17 message m;
18
19 for (i = 0; i < N; i++) send(producer, &m); /* send N empties */
20 while (TRUE) {
21 receive(producer, &m); /* get message containing item */
22 item = extract_item(&m); /* extract item from message */
23 send(producer, &m); /* send back empty reply */
24 consume_item(tem); /* do something with the item */
25 }
26 }

16.屏障(Barrier)
    最后要说的一种同步机制是针对一组进程设计的。假设这样的一组情况:有的应用程序会被分成很多不同的阶段(Phase),并且有一个规定,必须所有的进程都执行完一个阶段才能进入下一个阶段。这样就可以通过在每个阶段的末尾放置一个屏障来实现。如图:

 


posted @ 2012-02-18 11:29  wawlian  阅读(2885)  评论(1编辑  收藏  举报