3.6 monitor
信号量提供一个十分方便的机制,如果合适地应用,可以通过多线程的实现来获得最大并行性。然而,这只是“如果”。由于它提供十分细粒度且横跨多个线程的程序逻辑并发控制机制,因此高效地使用信号量机制是十分困难的。在面向对象编程时代,信号量已经变得不如从前流行。
幸运的是,存在另一个称为monitor
的机制。monitor
是一个对象或者模块,可以供多个线程进行安全访问。为了完成这一功能,某个时刻只允许一个线程执行一个monitor
的任意一个公有的方法。为了阻塞一个线程或者通知一个线程继续执行(建立关键区的两个基础功能),monitor
将提供一个称为条件变量的机制。
一个条件变量通常只允许在monitor
内部访问,它包含一个队列并支持两个操作。
wait
操作:如果一个线程在一个条件变量上执行等待操作,它将会被阻塞并被放置在条件变量队列中。
signal
操作:如果线程在一个条件变量上执行通知操作,将会有一个相应队列中的线程被释放(即变为可用)。如果队列为空,则信号被忽略。
等待和通知操作类似信号量的功能,但是存在一些不同。在条件变量上的等待操作导致线程的无条件阻塞,而信号量上的等待操作依赖其取值。另外,条件变量信号可以被忽略,而信号量的释放操作总会将其加一。
条件变量总是与一个条件相关联,亦即指出线程应该阻塞还是继续执行的一组规则。这也是monitor
较之信号量的最大优势之一。由于任何复杂的条件可以不借助一系列相互交织的信号量获取轻松处理,就像我们在读者–写者问题中看到的一样。
作为一个简单的示例,考虑一个执行银行账户的交易处理的monitor
类。withdraw
和deposit
方法的伪码如下。
为了保证只有一个线程在AccountMonitor
实例中运行,两个方法的入口都被一个互斥量锁定。在出口处被释放(第19行)互斥量,或者当一个线程将要被阻塞时(例如发现没有足够的资金可供取出),并且被放置在条件变量的队列中(第12行)时,释放互斥量。当资金被存入账户后,阻塞的线程被唤醒。
当线程被唤醒时发现存入的资金仍然不足时怎么办?如果有多个被阻塞线程,并且可能至少有一个线程的取款条件可以满足,但是并不一定是被唤醒的线程,此时该如何
处理?
上面描述的程序逻辑显然没有覆盖这种情况。接下来的讨论将会解答这一问题。
1.线程立即运行。由于一个monitor
不能存在两个执行其方法的活动线程,因此这将自动导致发出唤醒信号的线程挂起。一旦被唤醒的线程推出monitor
,线程就将执行。这种方法与Hoare monitor
规范一致,Hoare
是在19世纪70年代最早提出monitor
概念的科学家。
2.唤醒线程将处于等待状态,直到信号发出线程退出monitor
。由于这一延迟,直到线程获得monitor
的控制权,其等待的条件才可能被修改。这就需要一次重新检查,将控制等待的if语句变为一个while
语句。这种方法遵循Lampson-Redell monitor
规范,它较之Hoare monitor
具有一些优势,包括具有超时等待能力以及唤醒所有等待条件变量的线程的能力。这些实现都靠等待的线程重新运行时对条件的再次检查。
为了区别行为的不同之处,信号的通知操作称为notify
,通知条件变量的所有等待线程的操作称为notify all
。monitor
实现的主要部分(包括Qt和Java中的实现)都遵循Lampson-Redell
规范,因为额外的功能和内部更简单实现同时存在。使用这种monitor
,可以完成银行账户monitor
示例,从而解决上述两个问题。我们将在研究Qt中的monitor
机制后重写代码。
Qt提供QWaitCondition
类来支持条件变量,它包含以下方法。
wait,强制线程阻塞。为了避免程序员分别加锁和解锁控制monitor
入口的互斥量操作,互斥量的引用将会被传递给自动执行这些操作的方法。wakeOne
,唤醒一个线程。wakeAll
:唤醒所有阻塞线程。所有线程都将在montior
内部顺序执行。
Qt提供更为方便的类,它简化了控制入口的互斥量管理:QMutexLocker
。这个类的实例应该在每个monitor
方法的最开始创建,它负责对控制入口的互斥量加锁,这也是为何向其构造函数传递它的一个引用。这一看上去冗余的操作的优势在于monitor
方法可以在多个程序位置终止,而不需要显式的互斥量解锁操作。当QMutexLocker
类的析构函数被调用时(方法终止时),互斥量将被解锁,减少开销并且消除潜在的编程错误。
代码清单3-18展示了重写新的银行账户monitor
类的方法。
可以简单区分monitor
方法的三个部分,其中两个是可选的:
1.入口(可选):检测条件是否满足线程继续执行。
2.中间:操作monitor
状态,并且执行需要互斥的任意操作。
3.出口(可选):通知其他线程可以进入monitor
或者继续执行。
请读者在代码清单3-18中找出这三个部分的代码。
由于在关键位置包含对应的程序逻辑,因此Monitor
提供的互斥机制极大地简化了多线程应用的设计,并且使得其更易于理解和修改。
已经证明Monitor
和信号量是等价的,所有使用信号量的程序可以转变为使用monitor的程序,反之亦然。解决方案的复杂性是问题的独特特性。
根据关键区的位置基于monitor
的解决方案可以使用两种可能的模式。一种选择是将关键区放置于monitor
内部,另一种选择是使用monitor
获取和释放关键区的入口。每种方法都有各自的优势和劣势,这将在下面几节进行介绍。
3.6.1 设计方法1:monitor
内部的关键区
将一个关键区放入monitor
内部是一种完美的方案,由于每个时刻只允许一个线程执行。但是这种策略只在关键区相对来说较短时有效,否则会很低效,并且在极端情况时可能会将一个多线程程序的效果变为一个串行程序。上面展示的这个银行账户问题实例遵循这一设计方式,由于deposit
和withdraw
方法的执行时间是可以忽略的,因此这是可行的。
另一个可以应用这一设计方式的场景是使用monitor
进行控制台或者文件输出的顺序化。
这个例子说明了这种方法的致命缺点:如果输出到不同文件时又该如何处理?如果使用以下代码是否能正常工作?
答案依赖于writeOut
函数的调用频率以及使用的输出流的数目。尽管在原理上这种方法是次优的。一种更好的解决方案是为每一个输出流分配一个不同的monitor
对象或者按照下一节介绍的方式设计一个monitor
。
3.6.2 设计方法2:monitor
控制关键区的入口
在这种方法中,monitor
作为一个互斥量使用,亦即包含一对方法,一个用来允许进入关键区(获得允许),另一个用来通知离开关键区(释放允许)。尽管这看上去像是一种模仿互斥量的加锁解锁序列的过于复杂方案,但实际上monitor
可以支持较之互斥量关键区入口更细粒度的控制。
例如,考虑“吸烟者问题”:它包含三个吸烟者和一个代理者线程。每个吸烟者连续制作香烟并吸完。为了制作香烟,吸烟者需要三种原材料:烟草、纸和火柴。这三个吸烟者中的每一个都只有一种原材料并且数量是无限的,亦即一个只拥有纸,一个只拥有烟草,而最后一个只拥有火柴。代理者线程拥有这三种原材料且数量是无限的,并且只随机选择两种类型的原材料放到桌子上。随后通知这两种资源的可用性并阻塞。缺少这两种原材料的吸烟者可以从桌子上获取并制作香烟,随后需要一个时间随机的窗口用于制作香烟。一旦完成香烟制作,吸烟者就通知代理者循环这一过程。
需要强调的是,这个问题定义较之典型的问题定义更为复杂。在典型的定义中,代理者只通知需要的吸烟者开始制作香烟。而在这里需要吸烟者自己判断是否能继续执行。
代码清单3-19展示了这一问题的解决方案。
这一解决方案包含三个类:一个Somker
类,用其所需要的原材料类型来进行标识(第3~5行枚举了原材料);一个Agent类,包含一个预定义的迭代次数(在构造函数中定义);一个Monitor
类,用于允许其他两个类进行通信。这一解决方案的关键之处如下。Monitor
提供了以下两组公用方法。
吸烟者线程中用到的canSomke和finishedSomking
对,对应get permit和helease permit
类型的函数。Agent
线程中用到newIngredient和finishSim
方法。Agent
调用Monitor提供的newIngredient方法,传递可供使用的原材料类型。Monitor
唤醒所有的等待线程(第31行)然后强制Agent
线程阻塞,直到某个吸烟者完成执行(第32行)。
Agent线程负责程序终止。在迭代次数指定之后,调用monitor的finishSim
方法。这将翻转monitor
内部的一个布尔类型的标志(exitf
,第52行),并唤醒所有在条件变量w上等待的线程。
当Monitor
的canSomke
方法返回值非零时,每个Smoker
线程执行一个负责终止的循环。后一种方法返回exitf
标志的值。
这个程序的一个运行示例如下: