3.7 经典问题中的monitor
3.7.1 重新考虑生产者–消费者问题
生产者–消费者问题中缓冲区的管理通常是轻量级的。然而,为了完整性,需要考虑基于monitor的解决方案,并考虑前面介绍的不同的设计方法。
3.7.1.1 生产者–消费者:在monitor内部管理缓冲区
在这种情况下,monitor仅需要为生产者和消费者公开put和get函数。特别是将之与信号量进行比较时,代码清单3-20所展示的解决方案可以清楚地显示monitor架构的表达能力显得更为简洁。
生产者和消费者代码简化为最短(第67~72行以及第98~102行),但是缓冲区管理的所有复杂细节都包含在Monitor的put
(第20~29行)和get
(第32~43行)方法内部。代码清单3-20与代码清单3-10在生产者和消费者终止(亦即给定需要处理的资源的总数)上有许多相同的特征,模板类的使用也使得其可以支持各种特殊情况。
与代码清单3-10中的代码不同,这里的生产者和消费者并不了解共享缓冲区的内部工作机制,也不通过信号量进行互相通知。所有的通信都在Monitor类内部隐式实现。
Monitor类使用两种等待条件——full
和empty
,当队列满时(第23、24行)阻塞生产者线程,当队列空时(第35~36行)阻塞消费者线程。注意,Monitor类使用大量变量,而这些变量在代码清单3-10的线程间共享并用于避免竞争条件。现在在两类线程间的唯一共享变量是Monitor实例,并通过initClass
方法使得两类线程获得这一共享变量。
3.7.1.2 生产者–消费者:在monitor外插入/抽取缓冲区
如果在共享缓冲区中添加或者移除资源需要花费一定的时间(例如需要拷贝对象而不仅是引用),则使用第二种设计方法可以有效提高性能。从monitor中获取和释放一个permit
意味着run
方法将会花费较之上一设计中更长的时间。
这种思路是,生产者和消费者将会使用一对函数,首先获得缓冲区位置的互斥访问,然后释放给monitor继续使用。
代码清单3-21中的解决方案的关键之处如下。
Monitor
类提供以下两对方法。
Pdroducer
线程使用canPut和donePutting
方法
Consumer
线程使用canGet和doneGetting
方法
这些方法的主体主要包括代码清单3-20中put
和get
方法的两部分(平分)。canPut
函数和canGet
函数返回指向缓冲区位置的指针,该位置可以用来存储资源的索引。通过in
和out
指针而不是count
计数器的加一,实际上防止返回的位置被Monitor进一步修改。
在canPut
和canGet
函数返回之后,Producer
和Consumer
线程可以充分利用时间来存储或者提取一个资源。Monitor
此时可以继续服务于其他线程。
一旦调用donePutting
或doneGetting
方法,计数器加一或者减一,并且一个等待的Producer
或者Consumer
线程通过empty
或者full
等待条件通知。
3.7.2 重新考虑读者–写者问题
基于monitor的解决方案也可以移植到读者–写者问题。应用monitor而不是信号量,为一个线程或者其他类型的一个线程分配优先级变得十分简单。问题的特征使得必须使用第二种设计方法,亦即每个线程都将获得访问资源的允许,并且在完成任务后释放这一允许。
线程无须考虑优先级,或者不用考虑其他位于关键区的线程的存在性。这一功能嵌入在monitor的方法中。
在下一节将要介绍的三种解决方案中,Reader
和Writer
线程执行固定数目的操作,如下所示:
下几节中将要分析monitor实现的具体细节。
3.7.2.1 优先考虑读者的解决方案
为了赋予读者线程优先级,必须维护等待的读者线程readerWaiting
,并且在readerWaiting
大于零时阻止一个写者进入关键区。
代码清单3-22所展示的解决方案的重要之处如下。
monitor维护两个等待条件:用于唤醒等待写者的wq和用于唤醒等待读者的rq。
在读者线程内部关键区中维护一个计数器,在第29行加一,而一旦读者线程离开时将在第44行减一。如果计数器为0,信号将会被发送给所有等待的写者线程(第46行)。
如果有读者线程位于其关键区内,则写者线程会阻塞,否则写者线程将进入关键区(第35行)。
最后一部分实现优先级到读者线程的转移,并在finishedWriting
方法中管理等待条件队列:只在没有等待的读者线程时才唤醒一个写者(第53~56行)。
3.7.2.2 优先考虑写者的解决方案
为了赋予写者线程优先级,必须维护等待写者队列writersWaiting
,并且当writersWaiting
大于零时,阻止读者线程进入其关键区。
代码清单3-22和代码清单3-23中的两个类是近似等价的。其区别仅在于控制关键区的入口和退出关键区的队列管理上。在前一解决方案中,如果有写者线程等待在其关键区入口,第22行将强制读者阻塞。将优先级转交给写者线程在代码清单3-23中的第52~55行实现,一个写者离开其关键区时将会唤醒一个等待的写者线程(前提是有一个写者进程的优先级高于几个读者进程)。但是如果没有等待的写者线程,所有的读者线程都将被唤醒(第55行)。
3.7.2.3 公平方案
为读者–写者问题设计一个公平的解决方案是一项具有挑战性的任务。尽管这还未在之前的章节中考虑过,为了满足在关键区入口的请求,从一个等待条件队列中释放线程的顺序是实现一个先入先出顺序的关键。
Qt文档中有关wakeOne
和wakeAll
的方法写道:“线程被唤醒的顺序依赖于操作系统的具体调度策略,不能预先控制或者预测。”
值得注意的是,这并不是Qt实现的错误。一旦一组线程重新就绪,其执行的顺序就是操作系统的职责,不能直接控制。一个等价的问题会对任意monitor实现带来困难。
Qt文档提供了一个关于如何处理这一问题的启示:“如果想唤醒指定线程,典型的解决方案是使用一个不同的等待条件,并使不同的线程等待不同的条件。”
代码清单3-24展示的解决方案恰好展示了这一特征:被强制阻塞的线程等待不同的条件,允许显式精确控制唤醒哪一个线程以及按照何种顺序唤醒。一个固定的等待条件数组按照循环队列模式分配和管理(in和out
索引以及一个用于记录使用数目的counter
)。附加一个布尔型数组(writeflag
)允许区分何种类型的线程被阻塞在每种等待条件上。如果所有的等待条件都被使用,线程将会被强制移出并加入到一个通用的等待条件(quefull
)。这其实是与完全公平的解决方案唯一的不同之处(由于线程离开队列时不按先入先出队列),但这是一个微妙的折中。
代码清单3-24中代码的其他一些关键点如下。
所有请求进入关键区的线程首先检查等待条件队列的状态,以及在其关键区执行的线程的种类。
只要没有写者线程在队列中并且等待条件队列为空,读者线程就允许执行canRead(counter==0)
。如果后一种情况为假(第30行),这意味着在服务顺序中至少有一个写者位于这一读者之前。
如果等待条件队列为空并且没有其他写者或者读者线程位于关键区内,写者线程就允许执行canWrite
(第48行)。
如果第30行和第48行的条件为真,使用c数组中的一个元素阻塞线程,writeflag
中的对应元素被设置/重置,以表示有一个读者/写者线程被阻塞。
当调用finishWriting
,并且等待条件队列为非空(counter>0
)时,位于队列头的线程类型(out指针指向)被检测(第80行)。如果第一个元素是读者,队列中的所有读者线程或者直到遇到一个写者线程之前的读者线程被唤醒(第82~86行)。否则,第一个写者线程被唤醒(第91~93行)。
以这种细粒度方法管理尝试进入关键区的线程能力,可能导致多种可能性,它们超过这种简单的读者–写者场景。例如,可以用任意目标函数判断将要执行哪一个线程(例如,基于优先级、资金因素等)。一个恰当的例子是满足客户请求的多线程DBMS系统。这可能通过客户的级别、请求的优先级或者任意其他标准集进行排序。