管程,生产者消费者

有了信号量和互斥量之后,进程间通信看来就很容易了,实际是这样的吗?答案是否定的。考察图2-28点击打开链接中向缓冲区放入数据项以及从中删除数据项之前的down操作。假设将生产者代码中的两个down操作交换一下次序,将使得mutex的值在empty之前而不是在其之后被减1。如果缓冲区完全满了,生产者将阻塞,mutex值为0。这样一来,当消费者下次试图访问缓冲区时,它将对mutex执行一个down操作,由于mutex值为0,则消费者也将阻塞。两个进程都将永远地阻塞下去,无法再进行有效的工作,这种不幸的状况称作死锁(dead lock)。

指出这个问题是为了说明使用信号量时要非常小心。一处很小的错误将导致很大的麻烦。这就像用汇编语言编程一样,甚至更糟,因为这里出现的错误都是竞争条件、死锁以及其他一些不可预测和不可再现的行为。

为了更易于编写正确的程序,Brinch Hansen (1973)和Hoare(1974)提出了一种高级同步原语,称为管程(monitor)。在下面的介绍中我们会发现,他们两人提出的方案略有不同。一个管程是一个由过程、变量及数据结构等组成的一个集合,它们组成一个特殊的模块或软件包。进程可在任何需要的时候调用管程中的过程,但它们不能在管程之外声明的过程中直接访问管程内的数据结构。图2-33展示了用一种抽象的、类Pascal语言描述的管程。这里不能使用C语言,因为管程是语言概念而C语言并不支持它。

 
  
  

管程有一个很重要的特性,即任一时刻管程中只能有一个活跃进程,这一特性使管程能有效地完成互斥。管程是编程语言的组成部分,编译器知道它们的特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用。典型的处理方法是,当一个进程调用管程过程时,该过程中的前几条指令将检查在管程中是否有其他的活跃进程。如果有,调用进程将被挂起,直到另一个进程离开管程将其唤醒。如果没有活跃进程在使用管程,则该调用进程可以进入。

进入管程时的互斥由编译器负责,但通常的做法是用一个互斥量或二元信号量。因为是由编译器而非程序员来安排互斥,所以出错的可能性要小得多。在任一时刻,写管程的人无须关心编译器是如何实现互斥的。他只需知道将所有的临界区转换成管程过程即可,决不会有两个进程同时执行临界区中的代码

尽管管程提供了一种实现互斥的简便途径,但还需要一种办法使得进程在无法继续运行时被阻塞。在生产者-消费者问题中,很容易将针对缓冲区满和缓冲区空的测试放到管程过程中,但是生产者在发现缓冲区满的时候如何阻塞呢?

解决的方法是引入条件变量(condition variables)以及相关的两个操作:wait和signal当一个管程过程发现它无法继续运行时(例如,生产者发现缓冲区满),它会在某个条件变量上(如full)执行wait操作。该操作导致调用进程自身阻塞,并且还将另一个以前等在管程之外的进程调入管程。前面介绍pthread时已经看到条件变量及其操作了。

另一个进程,比如消费者,可以唤醒正在睡眠的伙伴进程,这可以通过对其伙伴正在等待的一个条件变量执行signal完成为了避免管程中同时有两个活跃进程,我们需要一条规则来通知在signal之后该怎么办。Hoare建议让新唤醒的进程运行,而挂起另一个进程。Brinch Hansen则建议执行signal的进程必须立即退出管程,即signal语句只可能作为一个管程过程的最后一条语句。我们将采纳Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。如果在一个条件变量上有若干进程正在等待,则在对该条件变量执行signal操作后,系统调度程序只能在其中选择一个使其恢复运行

第三种方法,让发信号者继续运行,并且只有在发信号者退出管程之后,才允许等待的进程开始运行。

条件变量不是计数器,条件变量也不能像信号量那样积累信号以便以后使用。所以,如果向一个条件变量发送信号,但是在该条件变量上并没有等待进程,则该信号会永远丢失。换句话说,wait操作必须在signal之前。这条规则使得实现简单了许多。实际上这不是一个问题,因为在需要时,用变量很容易跟踪每个进程的状态。一个原本要执行signal的进程,只要检查这些变量便可以知道该操作是否有必要。

wait和signal操作看起来像前面提到的sleep和wakeup,而后者存在严重的竞争条件。有个很关键的区别:sleep和wakeup之所以失败是因为当一个进程想睡眠时另一个进程试图去唤醒它使用管程则不会发生这种情况。对管程过程的自动互斥保证了这一点如果管程过程中的生产者发现缓冲区满,它将能够完成wait操作而不用担心调度程序可能会在wait完成之前切换到消费者。甚至,在wait执行完成而且把生产者标志为不可运行之前,根本不会允许消费者进入管程

尽管类Pascal是一种想象的语言,但还是有一些真正的编程语言支持管程,不过它们不一定是Hoare和Brinch Hansen所设计的模型。其中一种语言是Java。Java是一种面向对象的语言,它支持用户级线程,还允许将方法(过程)划分为类。只要将关键词synchronized加入到方法声明中,Java 保证一旦某个线程执行该方法,就不允许其他线程执行该对象中的任何synchronized方法。

使用Java管程解决生产者-消费者问题的解法如图2-35所示。该解法中有4个类。外部类(outer class)ProducerConsumer创建并启动两个线程,p和c。第二个类和第三个类producer和consumer分别包含生产者和消费者的代码。最后,类our_monitor是管程,它有两个同步线程,用于在共享缓冲区中插入和取出数据项。与前面的例子不同,我们在这里给出了insert和remove的全部代码。

在前面所有的例子中,生产者和消费者线程在功能上与它们的等同部分是相同的。生产者有一个无限循环,该无限循环产生数据并将数据放入公共缓冲区中;消费者也有一个等价的无限循环,该无限循环从公共缓冲区取出数据并完成一些有趣的工作。

该程序中比较意思的部分是类our_monitor,它包含缓冲区、管理变量以及两个同步方法。当生产者在insert内活动时,它确信消费者不能在remove中活动,从而保证更新变量和缓冲区的安全,且不用担心竞争条件。变量count记录在缓冲区中数据项的数量。它的取值可以取从0到N-1之间任何值。变量lo是缓冲区槽的序号,指出将要取出的下一个数据项。类似地,hi是缓冲区中下一个将要放入的数据项序号。允许 lo = hi,其含义是在缓冲区中有0个或N个数据项。count的值说明了究竟是哪一种情形。

Java中的同步方法与其他经典管程有本质差别:Java没有内嵌的条件变量。反之,Java提供了两个过程wait和notify ,分别与sleep和wakeup等价,不过,当它们在同步方法中使用时,它们不受竞争条件约束。理论上,方法wait可以被中断,它本身就是与中断有关的代码。Java需要显式表示异常处理。在本文的要求中,只要认为go_to_sleep就是去睡眠即可。

通过临界区互斥的自动化,管程比信号量更容易保证并行编程的正确性。但管程也有缺点。我们之所以使用类Pascal和Java,而不像在本书中其他例子那样使用C语言,并不是没有原因的。正如我们前面提到过的,管程是一个编程语言概念,编译器必须要识别管程并用某种方式对其互斥做出安排。C、Pascal以及多数其他语言都没有管程,所以指望这些编译器遵守互斥规则是不合理的。实际中,如何能让编译器知道哪些过程属于管程,哪些不属于管程呢?

在上述语言中同样也没有信号量,但增加信号量是很容易的:读者需要做的就是向库里加入两段短小的汇编程序代码,以执行up和down系统调用。编译器甚至用不着知道它们的存在。当然,操作系统必须知道信号量的存在,或至少有一个基于信号量的操作系统,读者仍旧可以使用C或C++ (甚至是汇编语言,如果读者乐意的话)来编写用户程序,但是如果使用管程,读者就需要一种带有管程的语言。

   
   

图2-35   用Java语言实现的生产者-消费者问题的解法

与管程和信号量有关的另一个问题是,这些机制都是设计用来解决访问公共内存的一个或多个CPU上的互斥问题的。通过将信号量放在共享内存中并用TSL或XCHG指令来保护它们,可以避免竞争。如果一个分布式系统具有多个CPU,并且每个CPU拥有自己的私有内存,它们通过一个局域网相连,那么这些原语将失效。这里的结论是:信号量太低级了,而管程在少数几种编程语言之外又无法使用,并且,这些原语均未提供机器间的信息交换方法。所以还需要其他的方法。



  • 8
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值