Scheduler

本文解决了这一难题。首先,我们认为内核线程的性能在本质上比用户级线程差;在用户级管理并行性对于高并行计算性能至关重要。接下来,我们认为,将用户级线程与其他系统服务集成时遇到的问题是由于当代多处理器操作系统缺乏对用户级线程的内核支持;内核线程是支持用户级并行管理的错误抽象。最后,我们描述了一个新的内核接口和用户级线程包的设计、实现和性能,它们一起提供与内核线程相同的功能,而不会损害用户级并行管理的性能和灵活性优势。

1. 介绍

并行计算的有效性在很大程度上取决于用来表示和控制程序内并行性的原语的性能。如果创建和管理并行性的成本很高,即使是粗粒度的并行程序也会表现出较差的性能,如果创建和管理并行性的成本较低,即使是细粒度的程序也可以实现良好的性能。
构建并行程序的一种方法是在一组传统的类unix进程之间共享内存,每个进程由一个地址空间和该地址空间中的一个顺序执行流组成。但不幸的是,由于这些进程是在单处理器环境中设计的,所以它们对于通用并行编程来说效率太低;它们只能够很好地处理粗粒度的并行。

传统的用于通用并行编程的进程的缺点导致了线程的使用,线程将顺序执行流的概念与传统进程的其他方面(如地址空间和I/O描述)分离开来。与传统进程相比,这种关注点分离带来了显著的性能优势。

1.1 问题

线程可以在用户级上支持,也可以在内核中支持。但是两种方法都不完全令人满意。

用户级线程由连接到每个应用程序的运行时库例程进行管理,因此线程管理操作不需要内核干预。结果可能是出色的性能:在PCR[25]和FastThreads[2]等系统中,用户级线程操作的成本是在过程调用成本的数量级内。用户级线程也很灵活;可以根据语言或用户的需要定制它们,而不需要修改内核。

用户级线程在传统进程的上下文中执行;实际上,用户级线程系统通常是在不修改底层操作系统内核的情况下构建的。线程包将每个进程视为“虚拟处理器”,并将其看做在其控制下执行的物理处理器来对待;每个虚拟处理器运行用户级代码,将线程从准备列表中取出并运行它们。但实际上,这些虚拟处理器是由底层内核在真实的物理处理器上多路复用的。“真实世界”操作系统活动(如多道程序、I/O和页错误)会扭曲虚拟处理器和物理处理器之间的等价性;在这些因素存在的情况下,构建在传统进程之上的用户级线程可能表现出较差的性能甚至不正确的行为。

多处理器操作系统,如Mach[2]、Topaz[22]和V[7],为每个地址空间的多个线程提供直接的内核支持。使用内核线程编程避免了用户级线程展现出的系统集成问题,因为内核直接将每个应用程序的线程调度到物理处理器上。不幸的是,与传统的UNIX进程一样,内核线程对于许多并行程序来说太过重量级。内核线程的性能虽然通常比传统进程好一个数量级,但通常比用户级线程最好的性能差一个数量级(例如,在没有多道程序和I/O的情况下)。因此,用户级线程最终被实现在Mach (C threads[8])和Topaz (workcrew[24])的内核线程之上。用户级线程构建在内核线程之上,就像它们构建在传统进程之上一样;它们有着完全相同的性能,它们也会遇到同样的问题。

因此,并行程序员面临着一个难题:使用用户级线程,这些线程具有良好的性能和正确的行为,前提是应用程序是单道程序控制的,没有I/O,或者使用内核线程,这些线程的性能较差,但没有这些限制。

1.2 我们的目标

本文中,我们解决了这个难题。我们将描述内核接口(kernel interface)和用户级线程包(user-level thread package),它们将内核线程的功能与用户级线程的性能和灵活性结合在一起。具体地说:

  1. 在一般情况下,当线程操作不需要内核干预时,我们的性能基本上与现有的最佳用户级线程管理系统(系统集成糟糕)所实现的性能相同。
  2. 在其他的情况下,当必须涉及内核时,例如在处理器重新分配或I/O上,我们的系统可以模拟内核线程管理系统的行为:
    -在有就绪线程的情况下,没有处理器空闲。
    -在低优先级线程运行时,没有高优先级线程等待处理器。
    -当线程陷阱到内核以阻塞(例如,由于页错误)时,线程运行所在的处理器可用于运行来自相同或不同地址空间的另一个线程。
  3. 我们的系统的用户级部分是结构化的,以简化特定于应用程序的定制。更改调度应用程序的线程的策略很容易,甚至可以提供不同的并发模型,如workers[16]、Actors[1]或Futures[10]。

在多道程序多处理器中实现这些目标的困难在于必要的控制和调度信息分布在内核和每个应用程序的地址空间之间。为了能够在应用程序之间分配处理器,内核需要访问用户级调度信息(例如,每个地址空间中有多少并行度)。为了能够管理应用程序的并行性,用户级支持软件需要意识到/知道通常对应用程序隐藏的内核事件(例如,处理器重新分配和I/O请求/完成)。

1.3 方法

我们的方法是为每个应用程序提供一个虚拟多处理器(virtual multiprocessor),它是一个专用物理机器的抽象。每个应用程序都确切地知道分配给它的处理器数量(以及是哪些处理器),并且他可以完全的控制运行在这些处理器上的线程。操作系统内核完全控制在地址空间中分配处理器,包括能够在应用程序执行期间更改分配给应用程序的处理器数量。

为了实现这一点,内核会将影响地址空间的每个内核事件通知地址空间线程调度器,允许应用程序完全了解其调度状态。每个地址空间中的线程系统将可以影响处理器分配决策的用户级线程操作的子集通知给内核,为大多数不需要反映到内核的操作保留良好的性能。

我们用来实现这些想法的内核机制称为调度器激活(Scheduler Activations)。一个调度器激活向量控制从内核到内核事件上的地址空间线程调度器;线程调度器可以使用激活来修改用户级线程数据结构,执行用户级线程,以及请求内核。

我们在DEC SRC Firefly多处理器工作站[22]上实现了设计原型。虽然调度器激活和内核线程之间的差异非常重要,但是它们之间的相似性非常大,因此我们实现的内核部分只需要相对简单地修改Topaz的内核线程,Topaz是Firefly上的本地操作系统。类似地,我们实现的用户级部分涉及对Fast-Threads(一种最初设计用于在Topaz内核线程之上运行的用户级线程系统)的相对直接的修改。
由于我们的目标是演示内核线程的确切功能可以在用户级提供,因此本文的表示假设用户级线程是程序员或编译器使用的并发模型。但是,我们强调,当在内核线程或进程之上的用户级实现其他并发模型时,会遇到与用户级线程相同的问题——在调度程序激活的基础上实现它们可以解决这些问题。

2.用户级线程:性能优势以及功能限制

在本节中,我们通过描述用户级线程相对于内核线程提供的优势以及在内核线程或进程提供的接口上构建用户级线程时出现的困难来使我们的工作更有激情。我们认为,用户级线程的性能本质上优于内核线程,而不是现有实现的工件。用户级线程在编程模型和环境方面具有灵活性的额外优势。此外,我们认为用户级线程缺乏系统集成并不是用户级线程本身固有的,而是内核支持不足的结果。

2.1 用户级线程管理的情况

很自然地,我们相信在用户级线程系统中发现的性能优化可以在内核中应用,从而产生与用户级线程一样高效的内核线程,并且不会在功能上妥协。不幸的是,在内核中管理线程有很大的内在成本:

  1. 访问线程管理操作的成本:对于内核线程,程序必须在每个线程操作上跨越额外的保护边界,即使是在相同地址空间的线程之间交换处理器时也是如此。这不仅涉及一个额外的内核陷阱,而且内核还必须复制和检查参数,以保护自己不受错误或恶意程序的影响。相比之下,调用用户级线程操作的代价可能非常便宜,特别是使用编译器技术来扩展代码内联并执行复杂的寄存器分配时。此外,安全性不会受到损害:将用户级线程系统的错误使用隔离到发生它的程序的空间边界。
  2. 通用性的成本:对于内核线程管理,所有应用程序都使用一个底层实现。为了达到一般目的,内核线程系统必须提供任何合理应用程序所需的任何特性;这给那些不使用特定特性的应用程序增加了开销。相反,用户级线程系统提供的功能可以与使用它的应用程序的特定需求紧密匹配,因为不同的应用程序可以与不同的用户级线程库链接。例如,大多数内核线程系统实现抢占优先级调度,尽管许多并行应用程序可以使用更简单的策略,如first-in-first-out[24]。

如果线程管理操作本质上是昂贵的,那么这些因素就不重要了。例如,内核陷阱开销和优先级调度并不是类unix进程的高成本开销的主要贡献者。然而,线程操作的成本可以控制在过程调用的数量级内。这意味着,内核实现所增加的任何开销,无论多么小,都将是重要的,编写良好的用户级线程系统将比编写良好的内核级线程系统具有更好的性能。

为了从数量上说明这一点,表中显示了用户级线程、内核级线程和类unix进程的示例实现的性能,它们都运行在类似的硬件上,即CVAX处理器。在CVAX Firefly上测定了Fast-Thread和Topaz内核线程;在CVAX单处理器工作站上测量Ultrix (DEC的UNIX衍生物)。(虽然这些实现都很好,但并不是“最优”的。因此,我们的测量是说明性的,而不是确定的)。

两个基准是Null Fork,创建、调度、执行和完成一个调用了null过程调用的进程/线程所花费的时间(换句话说,fork一个线程的开销),以及Signal-Wait,进程/线程向等待的进程/线程发送信号,然后等待一个条件的发生(即两个线程同步的开销)。每个基准测试在一个单处理器上执行,结果在多次重复中求平均值。作为比较,一个过程调用需要大约7μsec,在Firefly上内核陷阱差不多要花费大约19μsec。

这里写图片描述

表I显示,虽然在Ultrix进程管理和Topaz内核线程管理之间的成本有很大的差异,但是Topaz线程和FastThreads之间还有一个数量级的差异。尽管Topaz线程代码用汇编程序编写的许多关键路径进行了高度调优。

通常,选择在何处实现系统服务,需要在性能和灵活性之间需要权衡[26]。然而,用户级线程避免了这种折衷:它们同时提高了性能和灵活性。灵活性在线程系统中尤为重要,因为有许多并行编程模型,其中每个模型都可能需要线程系统内的专门支持。对于内核线程,支持多个并行编程模型可能需要修改内核,这增加了复杂性、开销和内核中出现错误的可能性。

2.2 在传统内核接口上构建的用户级线程的糟糕集成来源

不幸的是,事实证明很难实现像内核级线程与系统服务集成级别相同的用户级线程。但这并不是在用户级管理并行性时所固有的,而是由于现有系统缺乏内核支持。内核线程是支持用户级线程管理的不合适的/错误的抽象。导致困难的内核线程有两个相关的特征:

  1. 内核线程阻塞、恢复/唤醒(resume)和被抢占,,没有对用户级的通知。
  2. 内核线程相对于用户级线程状态进行调度。
    即使在单程序系统上,这些也会引起问题。一个用户级线程系统通常会创建就像系统中物理处理器一样多的内核线程作为“虚拟处理器”;每个线程都将用于运行用户级线程。但是,当用户级线程发出阻塞I/O请求或出现页面错误时,作为其虚拟处理器的内核线程也会阻塞。因此,当I/O挂起时,物理处理器会丢失到地址空间,因为在刚刚空闲的处理器上没有内核线程来运行其他用户级线程。

一个可行的解决方案可能是创建比物理处理器更多的内核线程;当一个内核线程因为内核中的用户级线程阻塞而阻塞时,可以使用另一个内核线程在该处理器上运行用户级线程。但是,当I/O完成或页面错误返回时,就会出现问题:可运行的内核线程比物理处理器要多,每个内核线程都在运行一个用户级线程。在决定给哪个内核线程分配处理器时,操作系统将隐式地选择给哪个用户级线程分配处理器。

在传统系统中,当可运行线程多于物理处理器时,操作系统可以使用某种时间分割来确保每个线程都可以被操作。然而,当用户级线程在内核线程上运行时,时间分割会导致问题。例如,内核线程可以在它所运行的用户级线程持有自旋锁时被抢占;访问锁的任何用户级线程都将自旋等待,直到锁的持有者被重新调度。Zahorjan等人的[28]表明,在自旋锁存在的情况下进行时间分割会导致性能下降。另一个例子是,运行一个用户级线程的内核线程可以被抢占,以允许另一个内核线程在其用户级调度器中运行。或者,运行高优先级用户级线程的内核线程可以被取消,以支持运行低优先级的用户级线程的内核线程。

在多道程序中,也有像I/O和页错误的完全相同的问题。如果系统中只有一个作业,它可以接收机器的所有处理器;如果另一个作业进入系统,操作系统应该抢占第一个作业的一些处理器,将其分配给新的作业[23]。然后,内核被迫选择第一个作业的哪个内核线程,从而隐式地选择在其余处理器上运行的哪个用户级线程。在地址空间中发生抢占处理器的需求也是由于作业间并行性的变化造成的;Zahorjan和McCann[27]表明,在地址空间中动态重新分配处理器,以响应并行性的变化,对于实现高性能非常重要。

虽然可以设计一个内核接口,以允许当内核需要选择时,用户级可以影响那个内核线程被调度,但是这个选择与用户级线程状态密切相关;内核和用户级之间的这种信息的通信将会消除使用用户级线程的许多性能和灵活性优势。

最后,确保构建在内核线程上的用户级线程系统的逻辑正确性是困难的。许多应用程序,特别是那些需要在多个地址空间之间进行协作的应用程序,都没有死锁,这是基于所有可运行线程最终都会接收处理器时间的假设。当应用程序直接使用内核线程时,内核通过在所有可运行线程之间做切片处理处理器来满足这个假设。但当用户级线程是在固定数量的内核线程多路复用,假设可能不会成立:因为一个内核线程会因为其运行的用户级线程阻塞而阻塞,即使有可运行的用户级线程和可用的处理器,应用程序也可能耗尽作为执行上下文的内核线程。

3. 对用户级并行管理的有效内核支持

第2节描述了当程序员使用内核线程来表示并行性时出现的性能差、灵活度低的问题,以及当在内核线程之上构建用户级线程时衍生出的在多编程和I/O环境下的不良行为。为了解决这些问题,我们设计了一个新的内核接口和用户级线程系统,将内核线程的功能与用户级线程的性能和灵活性结合起来。

操作系统内核为每个用户级线程系统提供了其专属的虚拟多处理器,它是对专用物理机器的抽象,除了内核可以在程序执行期间更改该机器中的处理器数量。这个抽象有几个方面:

  1. 内核将处理器分配到地址空间;内核可以完全控制给每个地址空间的虚拟多处理器提供多少个处理器。
  2. 每一个地址空间的用户级线程系统都可以完全的控制哪个线程可以运行在其分配的处理器上,就像应用程序在裸的物理机器上运行一样。
  3. 当内核改变分配给用户级线程系统的处理器数量时,内核将会通知用户级线程系统;当用户级的线程在内核中阻塞或醒来时(例如,在I/O上或在页错误上),内核也会通知线程系统。内核的角色是将事件向量传递给适当的线程调度程序,而不是自己解释这些事件。
  4. 当应用程序需要更多或更少的处理器时,用户级线程系统会通知内核。内核使用这些信息在地址空间中分配处理器。但是,用户级只会将用户级线程操作的子集通知给内核,这些操作可能会影响处理器分配决策。因此,性能不会受到影响;大多数线程操作不受与内核通信开销的影响。
  5. 应用程序程序员认为直接使用内核线程进行编程除了性能之外没有什么不同。我们的用户级线程系统对程序员透明地管理它的虚拟多处理器,为程序员提供一个标准的Topaz线程接口[4]。(不过,用户级运行时系统可以很容易地进行调整,以提供不同的并行编程模型)。

在本节剩下的部分中,我们将介绍如何将内核事件矢量化到用户级线程系统,应用程序提供什么信息以允许内核在作业之间分配处理器,以及如何处理用户级自旋锁。

3.1 将内核事件显式的矢量化到用户级线程调度器

内核处理器分配器和用户级线程系统之间的通信是按照调度程序激活(Scheduler Activations)的方式进行的。之所以选择“调度程序激活(Scheduler Activations)”一词,是因为每个矢量化的事件都会导致用户级线程系统重新考虑其调度决策,即在哪个线程上运行哪个处理器。

调度器激活有三个角色:

  1. 它作为运行时用户级线程的容器或执行上下文,其方式与内核线程完全相同。
  2. 它将内核升级纪检通知给用户级线程系统。
  3. 它在内核中提供空间,以便在内核停止线程时保存激活的当前用户级线程的处理器上下文(例如,线程在处理I/O或者其处理器被内核抢占的时候)。

调度器激活(Scheduler Activations)的数据结构与传统的内核线程非常相似。每个调度器激活都有两个执行堆栈——一个映射到内核,一个映射到应用程序地址空间。每个用户级线程在运行时都分配了它自己的用户级堆栈;当用户级线程调用内核时,它使用其激活的内核堆栈。用户级线程调度程序运行在激活的用户级堆栈上。此外,内核维护一个激活控制块(类似于线程控制块),以记录调度程序激活的线程在内核中阻塞或被抢占时的状态;用户级线程调度器维护一个记录,该记录所记录的是在每个调度器激活中运行的用户级线程。
PS:upcall指的是来自内核调用的通知称为“upcall”。
当程序启动时,内核创建一个调度器激活,将其分配给处理器,并在一个固定的入口点,upcalls进入到应用程序地址空间。用户级线程管理系统接收upcall,并将该激活用作初始化自身并运行主应用程序线程的上下文。当第一个线程执行时,它可能会创建更多的用户线程并请求更多的处理器。在这种情况下,内核将为每个处理器创建一个额外的调度器激活,并使用这些调度器激活,upcal到用户级,以告诉它新的处理器已经可用。然后,用户级在该激活的上下文中选择并执行一个线程。

类似地,当内核需要将内核时间通知给用户级时,内核创建一个调度器激活,将其分配给处理器,upcalls到用应用程序地址空间。一旦启动upcall,激活就类似于传统的内核线程——它可以用来处理事件、运行用户级线程,并陷阱到内核以及在内核中阻塞。

调度器激活和内核线程之间的关键区别在于,一旦内核停止了激活的用户级线程,内核永远不会直接恢复该线程。相反,将创建一个新的调度器激活,以通知用户级线程系统线程已经停止。然后,用户级线程系统从旧的激活中删除线程的状态,告诉内核可以重用旧的激活,最后决定在处理器上运行哪个线程。相比之下,在传统的系统中,当内核停止一个内核线程(甚至在该内核线程上下文中还运行着一个用户级线程)时,内核从不通知用户级该事件。稍后,内核将直接恢复内核线程(以及隐含的用户级线程),同样不需要通知用户级。通过使用调度器激活,内核可以保持不变,即始终有与分配给地址空间的处理器相同数量的运行中的调度程序激活(用于运行用户级线程的容器)。

这里写图片描述

表二列出了使用调度器激活,将内核时间从内核矢量化到用户级;每个upcall的参数都在括号中,用户级线程系统采取的操作则是斜体的部分。注意,事件是在内核被迫必须做出调度决策的时间点上进行矢量化的。在实践中,这些事件以组合的形式发生;当发生这种情况时,将发出一个upcall,传递需要处理的所有事件。

作为使用调度器激活的一个示例,图1演示了在I/O请求/完成时发生的情况。注意这是不常见的情况;在正常操作中,可以在无需内核干预的情况下,创建、运行和完成线程。图1中的每个窗格反映了不同的时间步骤。直线箭头表示调度程序激活,s形箭头表示用户级线程,每个窗格右侧的用户级线程簇/集群表示就绪列表。

这里写图片描述

在时间T1,内核为引用程序分配了两个处理器,在每一个处理器上,内核像用户级代码发起upcalls,要求从就绪列表中移除一个线程,并运行它。在时间T2,其中一个用户级线程(thread 1)在内核阻塞。为了将这个时间通知给用户级,内核使用一直运行线程1的处理器,并在新的调度器激活的上下文中执行一个upcall。然后,用户级线程调度器可以使用处理器从准备列表中取出另一个线程并开始运行它。

在时间T3时候,I/O操作完成。再一次,内核必须将该时间通知给用户级,但是这个通知需要一个处理器的参与。内核会抢占在该地址空间中处于运行中状态的处理器,并使用它发起upcall。(如果当I/O完成时,没有处理器分配各该地址空间,那么这个upcal必须一直等待,直到内核为其分配了一个处理器)。这个upcall会通知用户级两件事:I/O完成以及抢占。upcal会在用户级线程系统中执行代码(1)将之前阻塞的线程放入到就绪列表中,(2)将被抢占的线程放入就绪列表中。此时,可以丢弃调度程序激活A和B。最后,在T4时刻,upcall从准备列表中取出一个线程并开始运行它。

当用户级线程在内核中阻塞或被抢占时,恢复它所需的大多数状态都已经在用户级中了—即线程的堆栈和控制块。但是,线程的寄存器状态由低级内核例程保存,例如中断和页错误处理程序;内核将此状态作为upcall的一部分传递给用户级,以通知抢占和/或I/O完成的地址空间。

由于多道程序设计,我们使用完全相同的机制将处理器从一个地址空间重新分配到另一个地址空间。例如,假设内核决定将一个处理器从一个地址空间中取出,并将其提供给另一个地址空间。内核通过向处理器发送一个中断,停止旧的激活,然后使用处理器以一个新的激活向新的地址空间执行upcall。内核不需要事先获得旧地址空间的许可就可以窃取它的处理器;这样做会违反地址空间优先级的语义(例如,新的地址空间可能具有比旧地址空间更高的优先级)。但是,仍然必须要通知旧地址空间发生了抢占。内核通过对仍然在旧地址空间中运行的另一个处理器执行另一个抢占来实现这一点。第二个处理器用于使用新的调度器激活对旧地址空间执行upcall,通知地址空间两个用户级线程已经停止。然后,用户级线程调度器可以完全控制在其剩余的处理器上运行哪些线程。(当最后一个处理器被从地址空间中抢占时,我们可以简单地跳过通知地址空间的抢占,而是延迟通知,直到内核最终将它重新分配为一个处理器。通知允许用户级知道已经分配了哪些处理器,以防它显式地管理缓存局部性)。

上面的描述在几个次要的方面过于简单化了。首先,如果线程具有优先级,那么除了上面描述的优先级外,可能还需要进行额外的抢占。在图1的示例中,假设线程3的优先级低于线程1和线程2。在这种情况下,用户级线程系统可以要求内核抢占线程3的处理器。然后内核将使用该处理器进行upcall,允许用户级线程系统将线程3放到就绪列表中,并运行线程2。用户级可以知道请求额外的抢占,因为它确切地知道每个处理器上运行的线程是哪个。

其次,虽然我们将内核描述为停止和保存用户级线程的上下文,但是内核与应用程序的交互完全是通过调度程序激活来实现的。应用程序可以在调度器激活之上构建任何其他并发模型;内核的行为在任何情况下都是完全相同的。特别是,内核不需要了解用于表示用户级并行性的数据结构。

第三,即使在用户级线程管理器中没有运行用户级线程时发生抢占或页面错误,调度器激活也能正常工作。在这种情况下,线程管理器的状态被内核保存。后续的upcall,在具有专属/私有堆栈的新激活中,允许(可重入的)线程管理器在有用户级线程运行时以一种方式恢复,而当没有用户线程运行时以另一种方式回复。例如,如果被抢占的处理器在空闲循环中,则不需要任何操作;如果在upcall期间,它正在处理事件,可以使用用户级上下文切换来继续处理事件。对内核来说,唯一增加的复杂之处是,将页错误通知给程序的upcall可能会反过来在相同的位置上还出现页错误;内核必须检查这个,当它发生时,延迟后续的调用,直到页面错误完成为止。

最后,当I/O完成时,在内核中阻塞的用户级线程可能仍然需要在内核模式中进一步执行。如果是这样,内核将暂时恢复线程,直到它再次阻塞或者到达离开内核的点。当出现后一种情况时,内核会通知用户级,将用户级线程的寄存器状态作为upcall的一部分传递给用户级线程。

3.2 通知内核用户级事件以影响处理器分配

上一小节描述的机制独立于内核用于在地址空间中分配处理器的策略。然而,合理的分配策略必须基于每个地址空间中可用的并行性。在这一小节中,我们展示了对于既尊重优先级又保证在可运行线程存在的情况下处理器不会空闲的策略,可以有效地传递这些信息。大多数内核线程系统都满足这些约束;据我们所知,它们不是由构建在内核线程之上的任何用户级线程系统来满足的。

关键的观察是,用户级线程系统不需要将会每一个线程操作都通知给内核,只需要告诉可以影响内核处理器分配决策的操作子集即可。相比之下,当内核线程被直接用于并行时,即使内核下一个要运行的最佳线程——一个在最小化开销和保持缓存上下文的同时尊重优先级的线程——–位于相同的地址空间中,处理器也会陷阱到内核。

在我们的系统中,地址空间在其所有的可运行线程数超过处理器数时候,或者处理器数超过可运行线程数时,会转换到一个状态,并通知内核 。如果一个应用程序有额外的线程运行,处理器分配器没有重新分配它额外的处理器,那么系统中的所有处理器都必须是繁忙的。创建更多的并行性不能违反约束。类似地,如果应用程序已经通知内核它有空闲处理器,并且内核没有删除它们,那么系统中必须没有其他工作。额外的闲置处理器不需要通知给内核。(此方法的扩展处理的是线程,而不是地址空间,具有全局意义的优先级)。

表III列出了当这些状态转换时,地址空间向内核发起的内核调用。例如,当一个地址空间通知内核它需要更多的处理器时,内核就会搜索已经注册了空闲处理器的地址空间。如果没有找到,就不会发生任何事情,但是如果将来某个处理器变得空闲,地址空间可能最终会得到一个处理器。这些通知仅仅是提示:如果内核给了一个地址空间一个处理器,当它到达那里时不再需要它,那么地址空间仅仅是用更新的信息将处理器返回给内核。当然,用户级线程系统必须将其通知串行化到到内核,因为顺序很重要。

这种方法的一个明显缺点是,应用程序可能不会诚实的在向操作系统报告它们的并行性。这个问题并不是多处理器独有的:一个不诚实或行为不端的程序也会在多道程序单处理器上消耗不公平的资源比例。在内核级或用户级线程系统中,可以使用多级反馈来鼓励应用程序为处理器分配决策提供诚实的信息。处理器分配器会比较照顾使用更少处理器的地址空间,并惩罚那些使用较多的处理器的地址空间。这鼓励地址空间在其他地方需要处理器时放弃处理器,因为优先级意味着处理器很可能在需要时返回。另一方面,如果整个系统的线程比处理器少,那么空闲处理器应该留在不久的将来最有可能创建工作的地址空间中,以避免在创建工作时处理器重新分配的开销。

许多产品化的单处理器操作系统也做类似的事情。平均响应时间,特别是交互性能,是通过优先照顾那些剩余服务最少的作业来提高的,通常是依据它们所所累计节省的时间来减小起作业的优先级。我们期望在多道程序多处理器中使用类似的策略来实现相同的目标;可以很容易地调整此策略,以鼓励诚实地报告空闲处理器。

3.3 临界区

我们还没有解决的一个问题是,当用户级线程被阻塞或抢占时,该用户线程可能处在临界区内。有两种可能的不良影响:性能差(例如,因为其他线程继续测试被抢占的线程持有的应用程序级自旋锁) 和死锁 (例如,被抢占的线程可能持有就绪列表锁;如果是这样,如果upcall试图将被抢占的线程放置到就绪列表中,就会发生死锁)。即使临界区不受锁的保护,也会出现问题。例如,FastThreads使用每个处理器(实际上是per-activation)的线程控制块的无锁列表来改进延迟;访问这些自由列表也必须以原子方式进行。预防【Prevention】和恢复【recovery】是处理不合时宜的抢占问题的两种方法。通过预防,可以通过使用内核和用户级之间的调度和锁定协议避免不合时宜的抢占。预防有许多严重的缺点,特别是在多道程序设计环境中。预防要求内核需要对处理器分配器进行控制(至少是暂时的),而这违反了地址空间优先级的语义。预防与我们将在第4.3节中描述的临界区的有效实现是不一致的。最后,在出现页面错误的情况下,预防需要将所有可能在临界区中被触碰的虚拟页“锁定”到物理内存;标识这些页面可能很麻烦。

相反,我们采用基于恢复【recovery】的解决方案。当upcall通知用户级线程系统某个线程已被抢占或未被阻塞时,线程系统将检查该线程是否在临界区中执行。(当然,在这个检查必须在获取锁之前做)。如果是,线程将通过用户级上下文切换暂时继续。当这个继续的线程退出临界区时,它将控制权交还给原始的upcall,同样是通过用户级上下文切换。此时,将用户级线程放回就绪列表是安全的。如果激活在处理内核事件的过程中被被抢占,我们将使用相同的机制来继续激活。
这种技术没有死锁问题。通过继续运行锁持有者,我们确保一旦获得锁,它最终总是会被释放,即使是在存在处理器抢占或页面错误的情况下。此外,该技术支持任意用户级自旋锁,因为当抢占发生时,用户级线程系统总是被通知,从而允许它继续自旋锁的持有者。虽然正确性不受影响,但当自旋锁持有者发生页面错误时,处理器时间可能会被自旋浪费;解决这个问题的方法是在旋转一段时间后放弃处理器。

4. 实现

我们通过修改Topaz (它是DEC SRC Firefly 多处理器工作站的本地操作系统)和FastThreads(它是用户级线程包),实现了第3节中描述的设计。
我们修改了Topaz内核线程管理例程来实现调度器激活。在以前的Topaz中阻塞、恢复或抢占线程的地方,它现在执行upcall以允许用户级执行这些操作(参见表II)。此外,我们修改了Topaz,以便将处理器显式分配到地址空间;在以前,Topaz遗忘性的将线程调度到它所属于的地址空间。我们还保持了对账代码的兼容性;现在的Topaz应用可以像以前那样运行。

FastTHread被修改成处理upcalls,唤醒被中断的临界区,并且将处理器分配决策所需的信息提供给Topaz(参照表III).

总之,我们向FastThreads添加了几百行代码,向Topaz添加了大约1200行代码。(相比之下,内核线程的原始Topaz实现超过了4000行代码)。大多数新的Topaz代码关注的是实现处理器分配策略(下面讨论),而不是调度程序激活本身。

我们的设计在分配处理器到地址空间和将线程调度到处理器的策略选择上是“中立的”。当然,我们需要为原型实现选择一些策略;在后面的小节中,我们将简要描述这些特性以及一些性能增强和调试注意事项。

4.1 处理器分配策略

我们选择的处理器分配策略,与Zahorjan和McCann的动态策略很相似。策略“空间共享”处理器,在尊重优先级的同时,保证如果有工作要做,没有处理器空闲。处理器被平均分配到最高优先级的地址空间中;如果某些地址空间不需要它们共享的所有处理器,那么这些处理器将被均匀地分配给其余的地址空间。空间共享减少处理器重新分配的数量;只有当可用处理器的数量不是需要它们的地址空间(具有相同的优先级)的整数倍时,才能对处理器进行时间分割。

我们的实现使地址空间可以使用内核线程,而不是要求每个地址空间都使用调度器激活。继续支持Topaz内核线程是必要的,以保持与现有的Topaz应用程序的二进制兼容性。在我们的实现中,使用内核线程的地址空间与使用调度器激活的应用程序竞争处理器。内核处理器分配器只需要知道每个地址空间是否会使用更多的处理器或者是否有一些处理器出于空闲。(应用程序可以同时处于两种状态;例如,如果它请求了一个处理器,收到了它,但是还没有请求另外的处理器)。第3.2节描述的接口为使用调度器激活的地址空间提供此信息;内部内核数据结构为直接使用内核线程的地址空间提供了这信息。使用调度器激活分配给地址空间的处理器通过upcall传递给用户级线程调度器;使用内核线程分配给地址空间的处理器被交给原始的Topaz线程调度程序。因此,不需要对处理器进行静态分区。

4.2 线程调度策略

我们设计的一个重要方面是,内核不了解应用程序的并发模型或调度策略,也不了解用于在用户级管理并行性的数据结构。每个应用程序都可以根据需要完全自由地选择它们;它们可以根据应用程序的需要进行调整。FastThreads中的默认策略使用每一个处理器的就绪队列(这个队列会被每一个处理访问),并以LIFO的顺序来提高缓存性 ;如果处理器自己的就绪列表为空,则会扫描查找工作。这基本上是Multilisp使用的策略。
此外,我们的实现包括迟滞,以避免不必要的处理器重新分配;空闲处理器在通知内核它可以重新分配之前会进行短时间的自旋。

未完待续。。。

猜你喜欢

转载自blog.csdn.net/qq_31179577/article/details/80976036