8.1.4 多处理机调度

在探讨多处理机调度之前,需要确定调度的对象是什么。过去,当所有进程都是单个线程的时候,调度的单位是进程,因为没有其他什么可以调度的。所有的现代操作系统都支持多线程进程,这让调度变得更加复杂。

线程是内核线程还是用户线程至关重要。如果线程是由用户空间库维护的,而对内核不可见,那么调度一如既往的基于单个进程。如果内核并不知道线程的存在,它就不能调度线程。

对内核线程来说,情况有所不同。在这种情况下所有线程均是内核可见的,内核可以选择一个进程的任一线程。在这样的系统中,发展趋势是内核选择线程作为调度单位,线程从属的那个进程对于调度算法只有很少的(乃至没有)影响。下面我们将探讨线程调度,当然,对于一个单线程进程(single-threaded process)系统或者用户空间线程,调度单位依然是进程。

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

进程和线程的选择并不是调度中的惟一问题。在单处理机中,调度是一维的。惟一必须(不断重复地)回答的问题是:“接下来运行的线程应该是哪一个?”而在多处理机中,调度是二维的。调度程序必须决定哪一个进程运行以及在哪一个CPU上运行。这个在多处理机中增加的维数大大增加了调度的复杂性。

另一个造成复杂性的因素是,在有些系统中所有的线程是不相关的,而在另外一些系统中它们是成组的,同属于同一个应用并且协同工作。前一种情形的例子是分时系统,其中独立的用户运行相互独立的进程。这些不同进程的线程之间没有关系,因此其中的每一个都可以独立调度而不用考虑其他的线程。

后一种情形的例子通常发生在程序开发环境中。大型系统中通常有一些供实际代码使用的包含宏、类型定义以及变量声明等内容的头文件。当一个头文件改变时,所有包含它的代码文件必须被重新编译。通常make程序用于管理开发工作。调用make程序时,在考虑了头文件或代码文件的修改之后,它仅编译那些必须重新编译的代码文件。仍然有效的目标文件不再重新生成。

make的原始版本是顺序工作的,不过为多处理机设计的新版本可以一次启动所有的编译。如果需要10个编译,那么迅速对9个进行调度而让最后一个在很长的时间之后才进行的做法没有多大意义,因为直到最后一个线程完毕之后用户才感觉到工作完成了。在这种情况下,将进行编译的线程看作一组,并在对其调度时考虑到这一点是有意义的。

1.分时

让我们首先讨论调度独立线程的情况。稍后,我们将考虑如何调度相关的线程。处理独立线程的最简单算法是,为就绪线程维护一个系统级的数据结构,它可能只是一个链表,但更多的情况下可能是对应不同优先级一个链表集合,如图8-12a所示。这里16个CPU正在忙碌,有不同优先级的14个线程在等待运行。第一个将要完成其当前工作(或其线程将被阻塞)的CPU是CPU 4,然后CPU 4锁住调度队列(scheduling queue)并选择优先级最高的线程A,如图8-12b所示。接着,CPU 12空闲并选择线程B,参见图8-12c。只要线程完全无关,以这种方式调度是明智的选择并且其很容易高效地实现。

阅读 ‧ 电子书库
图 8-12 使用单一数据结构调度一个多处理机

由所有CPU使用的单个调度数据结构分时共享这些CPU,正如它们在一个单处理机系统中那样。它还支持自动负载平衡,因为决不会出现一个CPU空闲而其他CPU过载的情况。不过这一方法有两个缺点,一个是随着CPU数量增加所引起的对调度数据结构的潜在竞争,二是当线程由于I/O阻塞时所引起上下文切换的开销(overhead)。

在线程的时间片用完时,也可能发生上下文切换。在多处理机中它有一些在单处理机中不存在的属性。假设某个线程在其时间片用完时持有一把自旋锁。在该线程被再次调度并且释放该锁之前,其他等待该自旋锁的CPU只是把时间浪费在自旋上。在单处理机中,极少采用自旋锁,因此,如果持有互斥信号量的一个线程被挂起,而另一个线程启动并试图获取该互斥信号量,则该线程会立即被阻塞,这样只浪费了少量时间。

为了避免这种异常情况,一些系统采用智能调度(smart scheduling)的方法,其中,获得了自旋锁的线程设置一个进程范围内的标志以表示它目前拥有了一个自旋锁(Zahorjan等人,1991)。当它释放该自旋锁时,就清除这个标志。这样调度程序就不会停止持有自旋锁的线程,相反,调度程序会给予稍微多一些的时间让该线程完成临界区内的工作并释放自旋锁。

调度中的另一个主要问题是,当所有CPU平等时,某些CPU更平等。特别是,当线程A已经在CPU k上运行了很长一段时间时,CPU k的高速缓存装满了A的块。若A很快重新开始运行,那么如果它在CPU k上运行性能可能会更好一些,因为k的高速缓存也许还存有A的一些块。预装高速缓存块将提高高速缓存的命中率,从而提高了线程的速度。另外,TLB也可能含有正确的页面,从而减少了TLB失效。

有些多处理机考虑了这一因素,并使用了所谓亲和调度(affinity scheduling)(Vaswani和Zahorjan,1991)。其基本思想是,尽量使一个线程在它前一次运行过的同一个CPU上运行。创建这种亲和力(affinity)的一种途径是采用一种两级调度算法(two-level scheduling algorithm)。在一个线程创建时,它被分给一个CPU,例如,可以基于哪一个CPU在此刻有最小的负载。这种把线程分给CPU的工作在算法的顶层进行,其结果是每个CPU获得了自己的线程集。

线程的实际调度工作在算法的底层进行。它由每个CPU使用优先级或其他的手段分别进行。通过试图让一个线程在其生命周期内在同一个CPU上运行的方法,高速缓存的亲和力得到了最大化。不过,如果某一个CPU没有线程运行,它便选取另一个CPU的一个线程来运行而不是空转。

两级调度算法有三个优点。第一,它把负载大致平均地分配在可用的CPU上;第二,它尽可能发挥了高速缓存亲和力的优势;第三,通过为每个CPU提供一个私有的就绪线程链表,使得对就绪线程链表的竞争减到了最小,因为试图使用另一个CPU的就绪线程链表的机会相对较小。

2.空间共享

当线程之间以某种方式彼此相关时,可以使用其他多处理机调度方法。前面我们叙述过的并行make就是一个例子。经常还有一个线程创建多个共同工作的线程的情况发生。例如当一个进程的多个线程间频繁地进行通信,让其在同一时间执行就显得尤为重要。在多个CPU上同时调度多个线程称为空间共享(space sharing)。

最简单的空间共享算法是这样工作的。假设一组相关的线程是一次性创建的。在其创建的时刻,调度程序检查是否有同线程数量一样多的空闲CPU存在。如果有,每个线程获得各自专用的CPU(非多道程序处理)并且都开始运行。如果没有足够的CPU,就没有线程开始运行,直到有足够的CPU时为止。每个线程保持其CPU直到它终止,并且该CPU被送回可用CPU池中。如果一个线程在I/O上阻塞,它继续保持其CPU,而该CPU就空闲直到该线程被唤醒。在下一批线程出现时,应用同样的算法。

在任何一个时刻,全部CPU被静态地划分成若干个分区,每个分区都运行一个进程中的线程。例如,在图8-13中,分区的大小是4、6、8和12个CPU,有两个CPU没有分配。随着时间的流逝,新的线程创建,旧的线程终止,CPU分区大小和数量都会发生变化。

阅读 ‧ 电子书库
图 8-13 一个32个CPU的集合被分成4个分区,两个CPU可用

必须进行周期性的调度决策。在单处理机系统中,最短作业优先是批处理调度中知名的算法。在多处理机系统中类似的算法是,选择需要最少的CPU周期数的线程,也就是其CPU周期数×运行时间最小的线程为候选线程。然而,在实际中,这一信息很难得到,因此该算法难以实现。事实上,研究表明,要胜过先来先服务算法是非常困难的(Krueger等人,1994)。

在这个简单的分区模型中,一个线程请求一定数量的CPU,然后或者全部得到它们或者一直等到有足够数量的CPU可用为止。另一种处理方式是主动地管理线程的并行度。管理并行度的一种途径是使用一个中心服务器,用它跟踪哪些线程正在运行,哪些线程希望运行以及所需CPU的最小和最大数量(Tucker和Gupta,1989)。每个应用程序周期性地询问中心服务器有多少个CPU可用。然后它调整线程的数量以符合可用的数量。例如,一台Web服务器可以5、10、20或任何其他数量的线程并行运行。如果它当前有10个线程,突然,系统对CPU的需求增加了,于是它被通知可用的CPU数量减到了5个,那么在接下来的5个线程完成其当前工作之后,它们就被通知退出而不是给予新的工作。这种机制允许分区大小动态地变化,以便与当前负载相匹配,这种方法优于图8-13中的固定系统。

3.群调度(Gang Scheduling)

空间共享的一个明显优点是消除了多道程序设计,从而消除了上下文切换的开销。但是,一个同样明显的缺点是当CPU被阻塞或根本无事可做时时间被浪费了,只有等到其再次就绪。于是,人们寻找既可以调度时间又可以调度空间的算法,特别是对于要创建多个线程而这些线程通常需要彼此通信的线程。

为了考察一个进程的多个线程被独立调度时会出现的问题,设想一个系统中有线程A0 和A1 属于进程A,而线程B0 和B1 属于进程B。线程A0 和B0 在CPU 0上分时;而线程A1 和B1 在CPU 1上分时。线程A0 和A1 需要经常通信。其通信模式是,A0 送给A1 一个消息,然后A1 回送给A0 一个应答,紧跟的是另一个这样的序列。假设正好是A0 和B1 首先开始,如图8-14所示。

阅读 ‧ 电子书库
图 8-14 进程A的两个异步运行的线程间的通信

在时间片0,A0 发给A1 一个请求,但是直到A1 在开始于100ms的时间片1中开始运行时它才得到该消息。它立即发送一个应答,但是直到A0 在200ms再次运行时它才得到该应答。最终结果是每200ms一个请求-应答序列。这个结果并不好。

这一问题的解决方案是群调度(gang scheduling),它是协同调度(co-scheduling)(Outsterhout,1982)的发展产物。群调度由三个部分组成:

1)把一组相关线程作为一个单位,即一个群(gang),一起调度。

2)一个群中的所有成员在不同的分时CPU上同时运行。

3)群中的所有成员共同开始和结束其时间片。

使群调度正确工作的关键是,同步调度所有的CPU。这意味着把时间划分为离散的时间片,如图8-14中所示。在每一个新的时间片开始时,所有的CPU都重新调度,在每个CPU上都开始一个新的线程。在后续的时间片开始时,另一个调度事件发生。在这之间,没有调度行为。如果某个线程被阻塞,它的CPU保持空闲,直到对应的时间片结束为止。

有关群调度是如何工作的例子在图8-15中给出。图8-15中有一台带6个CPU的多处理机,由5个进程A到E使用,总共有24个就绪线程。在时间槽(time slot)0,线程A0 至A6 被调度运行。在时间槽1,调度线程B0 、B1 、B2 、C0 、C1 和C2 被调度运行。在时间槽2,进程D的5个线程以及E0 运行。剩下的6个线程属于E,在时间槽3中运行。然后周期重复进行,时间槽4与时间槽0一样,以此类推。

阅读 ‧ 电子书库
图 8-15 群调度

群调度的思想是,让一个进程的所有线程一起运行,这样,如果其中一个线程向另一个线程发送请求,接受方几乎会立即得到消息,并且几乎能够立即应答。在图8-15中,由于进程的所有线程在同一个时间片内一起运行,它们可以在一个时间片内发送和接受大量的消息,从而消除了图8-14中的问题。