避免在共享内存多处理器的用户级线程调度器中阻塞系统调用

第一章:简介

多线程是实现并行编程的几种途径之一。对于应用程序的线程支持可以由内核线程提供,也可以由在用户空间中操作的线程库提供。内核线程严重依赖内核资源,因此不适合细粒度的并行应用程序。用户线程比内核线程效率更高,因为它们不依赖内核资源进行调度、通信和同步。但是,由于用户线程不被操作系统内核识别为独立的执行线程,因此用户线程缺乏内核支持。

1.1 概述

在本文中,我们将结合两种技术,它们一起来使得并行应用程序可以充分利用底层硬件,同时减少开销。第一个技术涉及使用用户级多线程范例来运行应用程序。这使得并行程序的规范和设计更加自然,同时也保持了效率。第二个技术则涉及修改内核以提供对用户级的支持。当没有提供内核支持时,执行阻塞系统调用的用户级多线程的应用程序尤其脆弱。

1.1.1 阻塞系统调用

当线程在I/O操作中或通过其他阻塞系统调用被阻塞时,底层内核线程也会阻塞。因此,应用程序中的所有其他用户线程都不能使用内核线程来使用系统的处理器。这是内核中用户线程不透明的直接结果。经常执行阻塞系统调用的应用程序(如web服务器)不能通过用户级线程有效地实现。需要一些机制来避免系统调用阻塞掉应用程序的内核线程。

1.1.2 内核支持–调度激活

获取内核支持是一种解决方案,这用于即使其他的用户线程由于等待内核服务而处于阻塞状态时候,也可以允许其他用户线程被执行。Anderson的调度激活[1]通过创建一个新的内核线程并在用户线程阻塞时通知用户级别来提供这种支持。通过这种方式,可以运行其他用户线程,而不是让应用程序阻塞在内核。

1.2 目的和目标

在本文中,我们的目标是通过使用调度器激活提供的内核支持,使得将用户线程用于需要多个I/O操作和其他阻塞系统调用的实际应用程序变得可能。而这是通过修改两个现有的线程调度器实现的,一个用于单处理器,一个用于SMPs,以便通过调度器激活来获得内核支持。使用了两个不同的内核补丁。第一个与单处理器调度器集成,第二个可以与单处理器以及SMP调度器集成。还提供了一个实现HTTP 1.0协议并可以分发静态web页面的web服务器,以测试和比较使用激活的原始线程调度器与使用众所周知的Apache web服务器的线程调度器的性能。

1.3 本文的结构

第2章介绍了用户级线程,讨论了它们为什么会给操作系统提供最大性能带来问题。在第三章中,对这个问题的一些解决方案进行了评估。然后,我们将选择一个解决方案,并使用它实现两个调度程序,一个用于单处理器体系结构(第4章),另一个用于SMP体系结构(第5章)。最后,第6章讨论了web服务器和用于测试调度器的基准web客户机的设计和实现。还将对这个web服务器和Apache进行性能比较。
所有的调度器的实现都是在一个带有Intel Xeon (TM)四核处理器和256Mb内存的机器上完成的。所使用的操作系统是Linux 2.2.12和Linux 2.2.17,它们都被修补以提供调度器激活支持。

2. 背景

2.1 General interests

2.1.1 并发的历史

在早期版本的UNIX,支持并发编程是很麻烦的。80年代pc上的情况更糟,这个平台的标准操作系统DOS不支持任何并发。这使得并行程序的设计和执行变得困难。
解决方案是在一个新的结构中,这些程序可以被定义为一系列独立的顺序执行线程,它们在多个离散点进行通信和协作。在许多并行编程方法中,多线程是实现并发的工具。线程实质上是对程序中控制流的封装。

2.1.2 单处理器和多处理器系统

应用程序中每个线程的结构独立性,使得他很confirmationAgreementconfirmationAgreement自然地适合于使用多个处理元素来执行程序的想法。在单处理器上,线程编程只有两个优点。第一个是更自然的应用程序规范和设计。第二是通过时间分片等技术实现明显的并发性。但是,通过在多处理器(尤其是对称多处理器)上使用线程,我们可以在不同的cpu上对同一应用程序中的不同线程实现真正的并行。这就意味着我们可以在N个处理器的机器上拥有快达N倍的速度。

2.2 经典线程实现

2.2.1 从进程到内核线程

线程作为连续的控制流的概念至少可以追溯到1965年,Berkeley分时系统是第一个例子。当时它们不被称为线程,而是由Dijkstra[11]定义为进程。进程通过共享变量、信号量和类似方法进行交互。20世纪70年代初,UNIX操作系统诞生了。UNIX的进程的概念变成了一个连续的控制线程加上一个虚拟地址空间。因此,UNIX意义上的进程是相当重量级的机制。由于它们不能共享内存(每个进程都有自己的地址空间),所以它们通过管道、信号等进行交互。共享内存(也是一种相当笨重的机制)是在很久以后添加的。

进程被设计用于单处理器环境中的多道程序设计,使它们适合粗粒度的并行性,但不适用于一般的并行编程。这导致了内核线程的引入,而内核线程只不过是共享单个UNIX进程的地址空间的旧式进程。它们也被称为“轻量级进程”,与“重量级”UNIX进程形成对比。这种区别可以追溯到70年代末和80年代初。轻量级进程(LWPs)通常被称为内核线程,因为它是由内核负责所有的创建、销毁、同步和调度活动。内核线程的优点是,由于内核可以感知到这些线程,所以可以将所有对进程有效的调度策略也应用到LWPs,包括真正的分时处理(时间切片)。

2.2.2 从内核线程到用户线程

内核线程的主要缺点是它们仅限于由系统内核所提供的功能。例如,应用程序开发人员受到内核线程库提供的调度结构的约束。有些应用程序需要线程有一些非标准属性,而这些属性无法由内核提供给线程。与进程相比,内核线程虽然是轻量级的,但仍然严重依赖于内核资源。

用户线程为内核线程提供了一个替代方案,它是在内核线程之上进行操作。在使用用户级线程的应用程序中,应用程序的线程由应用程序本身管理。这样,就可以根据应用程序的需要来选择功能和调度策略。在执行诸如上下文切换之类的操作时,这些用户线程要比内核线程高效得多,因为操作用户线程不需要内核干预。内核线程的性能虽然比传统的进程的性能要快一个数量级,但通常比用户级线程的最佳用例性能差一个数量级。

2.2.3 用户线程的问题–糟糕的系统系统

由于在现有操作系统中缺乏对用户线程的内核支持,通常很难在用户线程与系统服务集成上达到内核线程所能实现的等级。反之,内核也无法感知到在内核线程线程之上创建了多少个用户线程。这种情况导致了内核线程的两个导致困难的相关特性,即:
1. 内核线程是在无视用户级线程状态情况下被调度的(因为内核感知不到在内核线程之上的用户线程的状态)。
2. 内核线程在不通知用户级的情况下阻塞和恢复。
当调度具有不同优先级的用户线程时,第一个问题很明显。内核无法区分这些优先级级别,因此,为了执行具有较低优先级的用户线程,可能会抢占具有高优先级的用户线程。最坏的情况是内核抢占内核线程时,而在该内核线程之上所运行的用户线程正持有锁。然后,它可能会给另一个内核线程提供处理器时间,而该内核线程上运行的用户线程在自旋,并等待释放锁。

第二个问题是我们所重点关注的,如何在用户级线程库中处理内核线程的阻塞和恢复。第3章描述的许多解决方案确实对上面描述的调度和抢占问题产生了影响(在某些情况下提供了解决方案)。在这种情况下,我们将简单地讨论它们。但是,我们将主要关注如何处理用于运行用户级线程的内核线程的阻塞和解除阻塞。

2.3 阻塞系统调用

当应用程序需要来自内核的服务时,它通过系统调用来实现这一点。系统调用提供介于用户级应用程序和内核之间的接口。当应用程序需要底层系统硬件(如网络接口卡或I/O设备)的服务时,通常使用系统调用。操作系统根据系统调用的定义服务请求并返回结果。

然而,内核有时不可能立即为请求提供服务,因为底层硬件还没有准备好来提供所需的服务。例如,如果从输入设备(如磁盘)进行read()系统调用,而该设备可能仍然正在忙于从介质读取数据。在这种情况下,内核将内核线程从运行队列中删除,并为其他线程提供服务,从而将其设置为休眠。只有当请求最终得到满足时,线程才会被解除阻塞并重新放置到内核运行队列中。
在任何使用内核线程等于处理器数量的系统的应用程序中(就像我们将要使用的用户级线程库一样),可能出现在正在运行的内核线程的数量小于可用的处理器数量的情况。这种资源的不充分利用意味着很可能有用户线程正在等待被运行,而处理器却在等待线程解除阻塞而处于空闲状态。这是由于内核无法识别用户线程以及操作系统内核和用户级调度器之间缺乏通信而引起的。
在这一章中,我们描述了线程是如何在并行应用程序中为并发性提供工具的。我们介绍了两种传统的线程——内核线程和用户线程。我们讨论了它们的优缺点以及它们之间的通信如何能够提高性能,特别是在使用阻塞系统调用的应用程序中。在第三章中,我们将描述一些解决这个问题的方法。我们将特别关注每个解决方案在等待被阻塞的线程被解除阻塞时,是如何使用CPU资源的。

3 Survey

本章介绍的解决方案需要增加编程的复杂性或工作量,无论是在操作系统级、用户线程库级还是应用程序级。此外,在决定在用户级调度器中实现哪种技术之前,需要考虑每个解决方案的优缺点。

3.1 两级复合库解决方案

两级库指的是一个是可以从内核线程和用户线程获得并行性的库。所有用户线程都需要在内核线程上运行,但是在两个级别的库中,内核线程的数量要大于处理器的数量。图3.1显示了此模型。用户线程在内核线程之间进行多路复用,而内核线程又在cpu之间进行多路复用。因此,内核线程成为“虚拟处理器”,在用户级线程调度器中,它是物理处理器。

3.1.1 更多的内核线程–简单的多对多模型

使用两级复合库本身就是避免阻塞式系统调用阻塞整个应用程序的解决方案。当用户线程阻塞时,作为其虚拟处理器的内核线程也会阻塞。当发生这种情况时,操作系统只需切换到运行在另一个内核线程上的另一个用户线程,从而保持对处理器的充分利用。因此,由于内核线程比处理器多,所以当内核线程阻塞时,它们会被内核线程的剩余部分所取代。
该系统的主要优点是实现起来非常简单。用户线程库只创建一些额外的内核线程,并将用户线程分派到这些内核线程。在操作系统或应用程序中都不需要更改。使用这样的系统也会自动使线程库适合于SMP机器。UNIX平台上的Solaris[23]和pm2[13]中的MARCEL[12]是这类库的两个示例。

两级库系统的主要问题是决定有多少内核线程。内核线程池的大小对多对多模型的性能有至关重要的影响。如果池中内核线程的数量几乎等于用户线程的数量,那么实现将非常类似于一对一模型。因此,一些阻塞系统调用可以阻塞所有内核线程,从而阻塞整个应用程序。另一方面,如果创建的内核线程太多,则在内核执行内核线程之间的上下文切换时会浪费处理器时间。

3.1.2 Solaris中的SIGWAITING

通过使用SIGWAITING信号,在Solaris操作系统中实现了一种避免使用两级复合系统阻塞所有内核线程,从而阻塞整个应用程序的解决方案。当内核意识到所有应用程序的内核线程都被阻塞时,它会在进程中删除一个SIGWAITING信号。在收到信号后,用户级调度器根据可运行用户线程的数量决定是否创建一个新的内核线程。SIGWAITING机制并不保证在多处理器上最优地使用内核线程。具体地说,应用程序可能具有等待处理器时间的可运行的用户级线程,但与运行它们的处理器相比,未阻塞的内核线程要少一些。在这种情况下,应用程序在所有内核线程被阻塞之前都不会收到SIGWAITING。因此,即使有可用的处理器和要做的作业,SIGWAITING机制也不能保证有足够的内核线程在可用的处理器上运行用户线程。

两级线程库提供了一个简单的解决方案,以避免阻塞的系统调用阻塞整个应用程序。但是,由于用户和内核级别之间缺乏合作和信息共享,它们在性能方面存在缺陷。它们提供的解决方案对于经常进行阻塞式系统调用(如web服务器)的应用程序是不具有可伸缩性的。

3.2 非协作系统

有可能存在这样的系统:内核线程的数量与处理器的数量保持一致,在调度决策上没有内核的额外支持,并且仍然避免阻塞式系统调用阻塞应用程序。我们现在提出两种这样的解决办法。

3.2.1 Wrappers

Vella[7]提出的解决方案是用代码“包装”每个阻塞系统调用。这段代码将在阻塞前生成一个新的内核线程,并在取消阻塞后销毁或保存它以供重用。因此,
虽然调用这其中一个系统调用通常会阻塞进程,但包装例程确保只有调用线程被阻塞,从而使进程可以执行其他线程。此类系统的的一个示例是DCE threads库[24],它是POSIX 1003.4a标准的实现。这样的库通常有一些系统调用被包装了,而另一些系统调用则没有。例如,在DCE中,read()、write()、open()、socket()、send()和recv()被包装,而wait()、sigpause()、msgsnd()、msgrcv()和semop()没有被包装。

包装器的另一个有趣的例子是由Barnes[18]为一个occam编译器KRoC[10]实现的。该系统只在单处理器机器上运行。但是,它展示了包装器在确保应用程序不会因为阻塞式系统调用而阻塞方面的有效性。作为演示,Barnes还构建了一个web服务器occserv,并将其与Apache web服务器进行比较。occserv使用Occam的细粒度线程调度器,Apache则使用系统进程实现并发。结果显示了服务时间、每秒请求数、并发级别和吞吐量方面的改进。与Apache的不稳定行为相比,occserv的行为也更为平滑。

这种解决方案的明显缺点是需要封装每个阻塞的系统调用。在一些操作系统中,这一数字高达数百。如果在以后的操作系统版本中引入了更多的阻塞系统调用,则需要相应地升级线程包。另一个缺点是使用阻塞系统调用总会伴随着创建新的内核线程。但是这可能是不必要的,因为该系统调用可能被内核立即服务,因此不需要阻塞。一个示例可能是从快速缓存中服务的read()调用。这中系统的一个优点是内核中不需要做任何更改,使库的分发更容易。

3.2.2 使用回调机制

另一个解决方案是永远不要有在内核中阻塞的系统调用。当用户进行阻塞系统调用时,他应该立即恢复控制。当系统调用完成时,会发出“回调”通知应用程序。该系统在Microsoft Windows 95中实现,并作为异步过程调用(或APCs)[28]来实现。Windows实际上遵循事件驱动的范例。有有趣的是,这是每个内核中实际发生的情况。如果内核线程实际上要阻塞,处理器也将阻塞,其他可运行的内核线程将不会执行。而实际上,内核会从运行队列中将该线程移除出去,给用户一种线程已经被阻塞的印象。

这个系统是高效的,但是要求应用程序开发人员负有很大的责任。它要求开发人员在线程的概念之上,还需要考虑到并行性的另一层。在同一个线程中,他必须考虑内核除了可以执行一个代码序列之外,同时可以等待一个阻塞请求完成。当这个阻塞请求完成时,他必须决定应用程序如何在APC中处理它,最后返回执行前面的代码。Windows是完全事件驱动的,它很自然地适合于这样的系统。另一方面,Linux确实提供了系统调用的非阻塞版本,但即使不被驱动,在使用它们时,开发也会变得复杂得多。

上面描述的两个系统将维护与有处理器一样多的内核线程。这样,当有一个要运行的线程时,任何处理器都不会被闲置,并且不会浪费时间在内核的上下文切换上。

3.3 协作系统

在协作系统中,内核可以通过异步或同步接口,将信息传递给用户级。用户级应用程序可以使用这些信息来优化其操作。

3.3.1 统计活跃的内核线程

如果系统中的线程库要在该系统上的内核线程数和处理器数之间保持一个最佳关系,那么它必须有关于当前活跃的内核线程总数的信息。这种方法是由Inohara和Masuda[14]提出的。他们认为,复合库都有下面这三个特点,从而导致在多道程序设计的系统上出现的的不必要的垂直和水平方向上的上下文切换:

  1. 用户级程序(或线程库)决定使用多少内核线程。
  2. 内核不告诉用户级哪个或有多少内核线程被分配给处理器。
  3. 内核和用户级调度器之间的交互是同步的。

他们提出了一种机制,旨在最小化线程切换开销,无论是水平切换还是垂直切换。水平上下文切换是同一级别的线程之间的上下文切换,即用户空间中的用户线程或者内核空间中的内核线程之间的切换。垂直上下文切换是从用户级切换到内核级(系统调用),反之亦然(upcall稍后讲解3.4.1)。

  1. 首先,内核调度程序决定在每个地址空间中使用多少内核线程。这将最小化内核调度器中水平切换的数量。
  2. 其次,内核调度器允许用户级调度器知道哪些内核线程实际上正在被分配到处理器。
  3. 第三,也是最重要的一点是,内核调度器和用户级调度器之间的所有交互都是异步完成的。内核线程的调度状态信息通过异步接口,使用一个在用户空间和内核之间共享内存区域来传递。由于这种交互是异步的,而且内核和用户级调度器之间的同步必然涉及水平和垂直的切换,因此自然就不需要进行不必要的切换。在第3.4.3节中,我们将比较该方法和调度激活方法,该方法它使用的是在用户和内核级别之间的同步协作系统。
    具有特别意义的是这个系统如何控制系统中被阻塞的线程的数量。内核向共享内存区域写入应用程序的活跃的内核线程数。通过这种方式,线程库能够根据需要创建和销毁内核线程。线程包程序员和应用程序开发人员的职责是确保在适当的时间轮询这个共享内存区域中的信息,这将用于根据用户线程和处理器的数量优化运行中的内核线程的数量。

Inohara产生的结果是对任何系统的改进,包括由安德森[1]最初提出的调度激活。这是因为上面描述的第三个特性仍然适用于调度激活。在引入调度激活后,我们将讨论这个问题。

3.3.2 Process control

Tucker[4,5]提出了另一种也需要在内核和用户层之间进行信息交换的方法。他的系统需要来自应用程序的process control,以及来自操作系统的处理器分区[processor partitioning]和接口的共同支持。process control技术基于这样的原则:为了提高性能,一个并行应用程序必须可以动态地控制其可运行的进程(或内核线程)的数量,以与对其可用的处理器数量像匹配(也可以说相等)。Tucker争辩说,在多道程序环境中,进程的调整必须是动态的,因为其他应用程序不断地进入和离开系统,从而需要不断地改变应用程序所拥有的的可用处理器数量。

他的系统使用异步接口,类似于Inohara所使用的接口。然而,传递给用户级的信息取决于应用程序所处的系统范围环境,而不仅仅是应用程序本身的状态。因此,如果多道程序设计的环境中的所有应用程序都使用process control机制,那么它是最好的方案。
例如,假设有有一个10个处理器的机器,同时运行两个应用程序。内核将在应用程序之间划分(分区)处理器,例如给第一个应用程序提供3个处理器,然后给第二个应用程序分配7个处理器。然后,应用程序中的process control机制负责创建多个进程或内核线程,这些进程或内核线程的数量与该应用被分配的处理器数量相等。当这个数字发生变化时,每个应用程序的共享内存区域都会被更新,应用程序相应地做出反应,根据需要创建或销毁进程或内核线程。因此,process control模型引入了“空间分区”,而不是Inohara和Anderson的模型中使用的“时间分区”。

Tucker还将他的系统与Anderson的调度激活[1]系统进行了比较,碰巧的是,这个系统是同时开发的。我们将再次推迟这两个系统的比较,直到我们详细地介绍了调度激活的操作机制。

3.4 调度激活

调度激活最初是由华盛顿大学的Anderson等人提出的。作者在Topaz系统上的FastThreads库上实现了这种机制。不幸的是,该系统不再运行,源文件也没有被发布。在本节中,我们将详细描述Anderson的调度激活的操作,特别是当应用程序进行阻塞的系统调用时,系统的行为。我们将用一个图形示例来演示该系统在实践中的工作方式。然后,我们将讨论这个系统如何与Inohara使用异步共享内存区域的实现和Tucker的process control机制进行比较。最后,我们将介绍由Danjean[2]实现的调度激活的新模型,而这个实现已经作为Linux操作系统内核的补丁。

3.4.1 调度激活背后的思想

调度器激活使内核能够在执行影响其线程之一的调度决策时,通知应用程序。Anderson创造了“调度激活”这个术语,因为内核中的每个事件都会导致用户级线程系统重新考虑它的调度决策,即哪个线程在哪个处理器上运行。这种机制通过引入一种称为upcall的系统调用来实现。虽然应用程序对应用程序的传统系统调用可以称为downcall,但是upcall则是由内核对应用程序的调用。为了进行这些向上调用,内核使用了调度激活。调度器激活是执行上下文,其方式与普通内核线程完全相同。实际上,激活的实现使用的是操作系统的本机内核线程,并简单地添加upcall的功能。当应用程序使用传统的内核线程时,它会创建这个线程本身并为操作系统指定一个要执行的函数。而调度器激活则相反。操作系统决定何时需要激活。然后创建它并开始执行一个特定的用户函数。

3.4.2 激活和阻塞系统调用

我们的主要兴趣在于调度激活如何处理阻塞系统调用。下面是一个简单的激活实现示例,这只是一个精简版,并具有处理阻塞系统调用的功能。我们在线程包中使用的实现也是基于相同的思想,但使用的是不同的一组upcalls和downcalls。
首先,我们将定义三个必需的upcall:
1.upcall new:这个upcall用于通知应用程序已经创建了一个新的激活。然后应用程序可以使用这个激活来运行它需要的代码。当这个upcall被产生时候,我我们将要执行的函数命名为new()。

2.upcall block:当一个激活阻塞时,内核使用另一个激活来进行执行upcall,以便通知应用程序它的一个激活已经被阻塞。当这个upcall被产生时候,我们将要执行的函数命名为block()。

3.upcall unblock:这个upcall用于向应用程序报告它的一个激活已被解除阻塞。然后,应用程序可以恢复在该激活上运行的用户线程。当这个upcall被产生时候,我们将要执行的函数命名为unblock()。

图3.2说明了在双核处理器机器上,使用调度激活的应用程序发出阻塞I/O请求时会发生什么。
时间T1:内核将两个处理器分配给应用程序。通过new upcall作用到new(),启动两个新激活。用户级线程调度程序从pool中选择两个线程并开始运行它们。

时间T2:激活(A)进行阻塞系统调用(如I/O请求)。使用new upcall创建一个新的激活。同时一个block upcall也被用于block(),以通知应用程序它的一个激活已被阻塞。这允许它采取适当的操作,例如从运行队列中删除线程。然后,用户级线程调度器从线程池中选择另一个线程,并使用新创建的激活(C)来运行它。

时间T3:激活(A)最终解除阻塞(例如在完成I/O请求时),应用程序通过作用到unblock()的unblocked uplcall,从内核接收通知。这个upcall可以通过激活(B)或(C)来执行。此时,执行upcall的激活可以选择继续其线程,或者立即恢复被阻塞的线程。在任何情况下,额外的激活都会被丢弃。这样,活跃的激活的数量总是等于可用处理器的数量。这将移除内核中任何不必要的水平上下文切换。

关于这个系统需要注意的最重要的一点是,应用程序的活跃的激活数总是等于物理处理器的数量。不管实际上有多少个处理器可用,这都是正确的。因此,在N处理器机器上,应用程序可以有任意数量的非活跃的阻塞线程,但活跃的线程的数量总是N。实际给定处理时间的活动线程的数量并不总是N,而是由内核调度程序决定的。在多道程序设计的级别较高的环境中,这个值可能远小于N。而在专用环境中,这个值则总是N。

虽然可以使用调度激活来基于内核调度器做出用户级的调度决策,但是我们应该只关注使用激活来避免阻塞系统调用以降低应用程序的性能。其他upcall(即upcall preempt和upcall restart)将被要求通知用户级调度程序内核线程的抢占和恢复。在高度多道程序设计的的环境中,这些upcall将产生大量开销,因为操作系统将不断抢占内核线程来执行其他应用程序。另一方面,在低多道程序设计的环境中,特别是在使用没有优先级的粗粒度线程的应用程序中,内核很少会在临界区(如持有自旋锁的线程)抢占线程。

3.4.3 Comparing cooperative techniques

现在我们将把调度激活机制与Inohara的(参见第3.3.1节)和Tucker的技术(参见第3.3.2节)进行比较。

如前所述,调度激活始终保持激活和处理器的数量一致,尽管其中一些激活在多道程序设计的环境中可能是不活跃的。使用Tucker机制,内核线程的数量取决于应用程序可用的处理器数量,当然应用新恒旭可用的处理器数量是由内核决定。对于多道程序设计的环境来说,这是一个更好的解决方案。

这些技术和调度激活之间的一个本质区别是调度激活使用同步方法来实现内核和用户层之间的信息交换。upcall是调度激活中使用的同步方法。Inohara和Tucker提出的方法则是由应用程序轮询内核和用户空间之间的共享内存区域,以检索内核信息。

Tucker认为,影响选择同步接口还是异步接口的两个因素是(1)响应时间和(2)通信开销。他认为,在响应时间的长短很关键的地方,基于应用程序的轮询没有竞争力,因此基于内核的信令将是最佳选择的机制。这样的应用程序将执行多个阻塞系统调用,从而使处理器在较长时间内处于空闲状态。他继续说,当可以容忍一些松弛时候,在用户内存空间中使用信息轮询从而减少开销(如Inohara的解决方案)的系统是最好的选择。

顺便提一下Tucker在他的论文中讨论的另一个很有趣的观点。关于是什么构成了在内核和用户层之间交换信息的“相关”事件,其实并不是很简单。无论使用异步机制还是同步机制,都是如此。例如,长生命周期的阻塞式系统调用需要内核通知应用程序,而短生命周期的阻塞式系统调用可能不需要。

Inohara认为,同步接口会导致垂直切换,而在某些情况下,这是多余的。实际上,在同步系统中,更重要的是确定与应用程序相关的信息。由于内核和用户级别之间的通信开销可能很高,因此需要将upcall的频率保持在最小值。此外,由于用户级调度器需要对内核调度器发送的更新消息立即做出反应,因此在用户级会增加水平切换频率。此外,使用调度激活时,内核决定何时创建和销毁内核线程,而不是应用程序。
我们现在给出一个示例,说明从调度激活技术和异步技术中所获得的不同行为。我们将使用Inohara的活跃线程统计,并展示系统在接收阻塞系统调用时的行为。为了简单起见,我们假设我们有两个处理器,且这两个处理器都完全用于我们的应用程序。

使用调度激活,内核首先为应用程序创建两个激活来运行其线程。对于异步系统,用户内存的一个区域被填充为“2”。应用程序读取这个值并创建两个内核线程来运行它的应用程序。然后将内存中的值设置为“0”。

考虑当应用程序的一个用户线程发出阻塞系统调用时,在两个系统中会发生什么。在调度激活系统中,将立即创建一个新的激活,并为其分配一个新的用户线程。对于Inohara的解决方案,共享内存中的count现在设置为“1”,表示有一个空闲的处理器。然后应用程序会在稍后的某个时间轮询取到这个值,并创建一个新的内核线程。性能最好的系统在很大程度上取决于系统调用完成和解除激活或内核线程阻塞所花费的时间。创建新的激活需要一定的开销,而这个开销所花的时间,很可能会比处理器一直处于空闲状态的时间还要长(言外之意是该阻塞调用其实只需要很短的时间即可完成)。另一方面,如果阻塞系统调用持续的时间很长,那么调度激活将得到最佳结果,因为应用程序的响应可以是即时的。如果频繁地进行轮询,则也可以实现同样的效果。这提供了一个几乎立即的响应时间。但是频繁的轮询会降低性能。

当内核线程解除阻塞时,也存在类似的论证。这次,使用调度激活的系统,我们可以保证激活的数量总是等于处理器的数量。如果应用程序不经常轮询,那么它可能会在一段时间内,活跃的内核线程比处理器多。这会增加内核中水平上下文切换的数量,从而降低性能。同样重要的是应用程序如何处理调度激活模型中的unblock upcall。因为这个upcall可以在任何时候发生,所以被抢占的激活必须停止运行它的用户线程来处理upcall。这可能会导致竞争风险,特别是当被抢占的用户线程正运行在临界区时。它还增加了水平切换开销,而这是可以通过轮询技术避免的。

这种情况将我们引到本章节的最后一部分。Danjean[2]开发了一个系统,既使用调度激活,也使用了轮询内核和用户空间之间的共享内存区域。他将其开发为Linux操作系统的补丁。通过合并这两个方法,可以确保分配给某个应用程序的处理器的数目,一直保持与激活的数目一致,同时不会出现不必要的应用程序中断(即所谓的临界区抢占问题)。

3.5 Scheduler activations with polled unblocking

这个版本的调度器激活是由V. Danjean在法国里昂的高等师范学院开发的。到目前为止,还没有基于这个最新激活补丁的出版物。到目前为止我们所引用的出版物,参阅根据Anderson关于原始模型描述激活补丁实现的论文。现在我们将演示Danjean模型的操作,然后讨论它与传统激活模型的比较。在这里,我们只是想让读者了解这个模型。我们不会描述任何实现问题,也不会使用补丁提供的API。当我们在第4章和第5章描述如何将调度激活集成到我们的线程库中时,将给出更多的细节。我们首先定义这个模型需要的两个upcall:

  1. upcall new :这个upcall用于通知应用程序已经创建了一个新的激活。该upcall这可能发生在:
    ①内核在程序开始时创建初始激活。
    ② 一个激活阻塞
    注意,通过使用一种技术来区分这两种情况,这样我们可以避免在激活阻塞时发出两个upcall (upcall new和upcall block)。然后应用程序使用这个激活来运行它所需要的代码。
  2. upcall unblock:当激活解除阻塞时,内核使用相同的激活来做出这个upcall,以便通知应用程序激活解除阻塞。这种优化提高了性能,因为这样的话,在其他处理器上运行的激活不必被抢占,以便通知用户级某个激活已被解除阻塞。在退出unblock()函数时,激活在它停止的地方继续执行(也就是在阻塞系统调用之后)。
    我们还需要另外两个重要的组成部分。第一个是内核和用户空间之间的共享内存区域,第二个是重新启动解除阻塞的激活的系统调用。Danjean使用一个整数值(nb_unblocked)来存储解除阻塞的激活的数量。应用程序或线程库轮询该值,在需要时,通过系统调用重新启动解除阻塞的激活。我们将调用这个系统调用act_restart()。
    我们最后需要指出的是一个进一步的优化项,它被用于处理SMP机器上的空闲激活。应用程序中运行的用户线程比处理器数量少的情况并不少见。由于调度激活总是会保证激活和处理器的数量的一致性,因此一些激活,也可以说是处理器,可能是处于空闲的状态。这就产生了轮询解阻塞的问题。使用同步激活模型情况下,将立即做出unblock upcall,并重新启动解除阻塞的激活。如果我们使用轮询机制,我们可能会在轮询完成之前的一段时间内都会得到未阻塞的激活和空闲处理器。Danjean解决了这个问题,它允许且只允许当一个处理器上的激活处于空闲状态时自动解除阻塞。因此,一个解除阻塞的激活可以通过两种方式重新启动:
    ①如果有空闲处理器,则自动执行。
    ②通过使用act restart()系统调用。如果没有其他激活是空闲的,这是重新启动解除阻塞激活的惟一方法。
    图3.3说明了在双处理器机器上,使用激活的应用程序发出阻塞I/O请求时会发生什么。我们展示了重新启动解除阻塞激活的两种可能情况下发生的情况。

时间T1:内核为应用程序分配两个处理器。通过将new upcall应用到new()函数,启动了两个新的激活。用户级线程调度程序从pool中选择两个线程并开始运行它们。

时间T2:激活(A)进行阻塞系统调用(如I/O请求)。使用new upcall创建一个新的激活。注意,这一次没有进行任何block upcall来通知用户级它的一个激活已经被阻塞。new upcall直接意味着激活已被阻塞。线程调度程序采取适当的操作,例如从运行队列中删除线程。然后,它从线程池中选择另一个线程,并使用新的激活(C)来运行它。

时间T3:激活(B)也进行阻塞系统调用。再次启动一个新的激活(D),用于运行线程池中的一个用户线程。

时间T4:激活(A)解除内核中的阻塞。 nb_unblocked的值增加为1。然而,这一次,两个激活(C)和(D)没有被中断,并继续在两个处理器上执行。

时间T5:在激活(C)上运行的线程完成。线程调度器轮询nb_unblocked的值。由于它是非零的,所以它进行了一个act_restart()系统调用,内核恢复在激活(A)上运行的线程(注意调度器的轮训可以在调度器内核中的任何时候进行轮询,而不必在用户线程终止时进行轮询)。

时间T6:激活(D)上运行的线程完成。用户级调度器轮询nb_unblocked的值。由于这一次为零,它试图从线程池中运行另一个用户线程。然而,没有更多的用户线程要运行,因此激活将进入休眠状态,处理器也将处于空闲状态。

时间T7:激活(A)解除内核中的阻塞。内核意识到有一个空闲处理器。因此,nb_unblocked的值没有改变,激活(B)被自动调度。

在这一章中,我们已经认识了一些解决方案,以避免阻塞系统调用阻塞整个应用程序。对于每一个解决方案,我们都描述了该解决方案的优缺点。我们看到,提供内核支持的系统最适合于那些不遵循事件驱动模型的操作系统,比如Linux。我们通过在内核和用户空间提供这种支持,区分了同步与异步接口,并得出结论复合模型是最好的,特别是在低多道程序设计的环境中。Danjean提出的调度激活就是这样一个模型。在接下来的两章中,我们将把调度激活集成到一个单处理器和一个SMP线程调度器中。最后,我们将设计并构建一个web服务器来测试调度程序,并将它们与一个进程驱动的web服务器和一个没有内核支持的用户级web服务器进行比较。

4.为单处理器集成调度激活与用户级线程调度器

在本章中,我们将描述与单核处理器用户级编程库集成的两种调度激活模型。这两个模型作为Linux操作系统的内核补丁,都是由里昂的Danjean开发的。我们将首先简要地描述所使用的线程调度器。对于这两个补丁,我们将描述补丁所提供的API,然后讨论如何将每个补丁与线程调度器集成。最后,我们对这两种模型进行了简要的比较。

4.1 Uniprocessor smash——一个用户级的单处理器调度器

我们所使用的单处理器调度器是smash。这个调度程序是由Debattista[6]在马耳他大学开发的。单处理器smash是基于MESH[9]调度器。smash是一种基于用户级线程的非抢占式调度器,它借鉴了MESH中用于提高性能和加速的许多思想。它剥离了外部通信接口。除了提供给线程管理的API之外,smash还提供用于CSP构造的API。虽然我们不会讨论CSP,但是这些构造在使用激活的情况下也可以正确的操作,就像它们使用接下来描述的底层内部调度器函数。

我们将为这个实现使用的版本是没有虚拟头的简单循环运行队列。这个配置如图4.1所示。有一个正在执行的当前线程,这个线程由变量sched.current指向。有关如何实现线程调度程序的详细描述,请参阅Debattista[6]。我们将只详细讨论与将调度激活与调度器集成起来的相关函数。总之,线程包使用以下内部函数并提供后续的API。

4.4.1 用于单处理器smash的内部调度函数

Thread Insertion

线程插入涉及将用户线程放置到运行队列中。由于原始调度器所操作的环境是单处理器的,没有自动抢占,因此在保护运行队列数据结构时不需要考虑并发问题。线程插入由scheduler_in()例程/函数中执行。

Thread Removal

当一个线程终止时,它将从运行队列中删除。如果运行队列中没有线程,则调度程序终止。线程删除是由sched_dequeue()例程执行的。

Thread Yield

线程让步包括保存当前线程的上下文,并切换到运行队列上的下一个线程的上下文。线程让步是由sched_yield()例程执行的。

读者需要知道的惟一变量是变量sched.current。这是指向运行队列上当前用户级线程的指针

4.1.2 单核处理器smash的API

为了完整性,以下函数提供了应用程序开发人员可以使用smash的API:

  1. cthread_init()——创建并初始化一个新的线程。
  2. cthread_run()——将一个线程放置在运行队列中执行(calls scheduler_in())
  3. cthread_yield() )——让步执行给另一个线程(calls sched_yield())。
  4. cthread_join()——一个同步函数,用于允许线程在继续之前等待另一个线程终止。
  5. cthread_stop()——终止一个线程(calls sched_dequeue())。

4.2 使用同步的解除阻塞的调度激活传递

本节中使用的调度器激活补丁是围绕Anderson提出的调度器激活的原始模型构建的,并在第3.4.1节中进行了描述。这个版本的调度器激活补丁不像第二个那样稳定,并且有很多Bug。然而有趣的是,它允许我们研究当使用这个模型时候产生的竞态条件的特点/本质。注意,对于单处理器,只有一个活跃的激活。所有其他激活要么被阻塞,要么被解除阻塞,等待重新启动。活跃的激活要么运行用户线程,在upcall中执行代码,要么处于空闲状态。
完整的API规范和补丁实现概述在[3]中给出。所需的系统调用和upcall如下:

4.2.1 The scheduler activations API for synchronous unblocking

System call(系统调用)
该补丁API提供了3个系统调用:

  1. act_init():如果应用程序要使用激活,则必须首先使用这个系统调用。它传递一个结构体作为参数,用于通知内核:
    •可以并行运行的激活数。这通常被设置为机器上的处理器数量,尽管它可以更少。
    •可以创建的最大激活数。
    •作出upcall的函数的地址。
    •用于存储upcall的激活上下文的两个区域。第一个区域是抢占激活的上下文。第二个是upcall提供信息的激活上下文。
  2. act_cntl():应用程序使用这个系统调用来请求关于激活的信息或修改内核变量(例如应用程序希望使用的处理器数量)。在此实现中,最重要的是调用act_cntl时,需要使用标志ACT_CTNL_WAIT_UPCALL。当使用此标志(flag)调用时,激活将在内核中阻塞,直到有一个upcall即将被生成为止。为了使正在运行的激活进入休眠状态,需要使用此功能。当运行队列中没有更多的线程等待执行,并且存在必须等待的阻塞激活时,就可以进行此操作。
  3. act_resume():当upcall生成时,它会获得一个锁,而这个锁需要通过调用这个系统调用来释放。因此,因此在处理upcall之后,必须调用act_resume()。act_resume()将激活上下文作为参数。如果该参数不是NULL,则激活将继续执行保存在该参数中的状态。如果它为NULL,那么在系统调用之后激活继续。

Upcalls
以下是内核为通知用户级关于任何激活的已创建、阻塞或解除阻塞的消息而生成的upcall:

act_restart:

猜你喜欢

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