zoukankan      html  css  js  c++  java
  • 操作系统导论-虚拟化-雷姆兹·H.阿帕希杜塞尔、安德莉亚·C.阿帕希杜塞尔

     

    学生:尊敬的教授,什么是虚拟化?

    教授:想象我们有一个桃子。

    学生:桃子?(不可思议)

    教授:是的,一个桃子,我们称之为物理(physical)桃子。但有很多想吃这个桃子的人,我们希望向每个想吃的人提供一个属于他的桃子,这样才能皆大欢喜。我们把给每个人的桃子称为虚拟(virtual)桃子。我们通过某种方式,从这个物理桃子创造出许多虚拟桃子。重要的是,在这种假象中,每个人看起来都有一个物理桃子,但实际上不是。

    学生:所以每个人都不知道他在和别人一起分享一个桃子吗?

    教授:是的。

    学生:但不管怎么样,实际情况就是只有一个桃子啊。

    教授:是的,所以呢?

    学生:所以,如果我和别人分享同一个桃子,我一定会发现这个问题。

    教授:是的!你说得没错。但吃的人多才有这样的问题。多数时间他们都在打盹或者做其他事情,所以,你可以在他们打盹的时候把他手中的桃子拿过来分给其他人,这样我们就创造了有许多虚拟桃子的假象,每人一个桃子!

    学生:这听起来就像糟糕的竞选口号。教授,您是在跟我讲计算机知识吗?

    教授:年轻人,看来需要给你一个更具体的例子。以最基本的计算机资源CPU为例,假设一个计算机只有一个CPU(尽管现代计算机一般拥有2个、4个或者更多CPU),虚拟化要做的就是将这个CPU虚拟成多个虚拟CPU并分给每一个进程使用,因此,每个应用都以为自己在独占CPU,但实际上只有一个CPU。这样操作系统就创造了美丽的假象——它虚拟化了CPU。

    学生:听起来好神奇,能再多讲一些吗?它是如何工作的?

    教授:问得很及时,听上去你已经做好开始学习的准备了。

    学生:是的,不过我还真有点担心您又要讲桃子的事情了。

    教授:不用担心,毕竟我也不喜欢吃桃子。那我们开始学习吧……

    进程

     进程的非正式定义非常简单:进程就是运行中的程序。程序本身是没有生命周期的,它只是存在磁盘上面的一些指令(也可能是一些静态数据)。是操作系统让这些字节运行起来,让程序发挥作用。也就是说,进程就是操作系统为正在运行的程序提供的抽象。

    事实表明,人们常常希望同时运行多个程序。比如:在使用计算机或者笔记本的时候,我们会同时运行浏览器、邮件、游戏、音乐播放器,等等。实际上,一个正常的系统可能会有上百个进程同时在运行。如果能实现这样的系统,人们就不需要考虑这个时候哪一个CPU是可用的。那么,如何提供有许多CPU的假象?

    操作系统通过虚拟化(virtualizing)CPU来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟CPU的假象。这就是时分共享(time sharing)CPU技术,允许用户如愿运行多个并发进程。

    时分共享(time sharing)是操作系统共享资源所使用的最基本的技术之一。通过允许资源由一个实体使用一小段时间,然后由另一个实体使用一小段时间,如此下去,所谓的资源(例如,CPU或网络链接)可以被许多人共享。时分共享的自然对应技术是空分共享,资源在空间上被划分给希望使用它的人。例如,磁盘空间自然是一个空分共享资源,因为一旦将块分配给文件,在用户删除文件之前,不可能将它分配给其他文件。

    在这些机制之上,操作系统中有一些智能以策略(policy)的形式存在。策略是在操作系统内做出某种决定的算法。

    进程API

    所有现代操作系统都以某种形式提供这些API.

    ·创建(create):操作系统必须包含一些创建新进程的方法。在shell中键入命令或双击应用程序图标时,会调用操作系统来创建新进程,运行指定的程序。

    UNIX系统采用了一种非常有趣的创建新进程的方式,即通过一对系统调用:fork()和exec()。进程还可以通过第三个系统调用wait(),来等待其创建的子进程执行完成。

    操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。程序最初以某种可执行格式驻留在磁盘上(disk,或者在某些现代系统中,在基于闪存的SSD上)。因此,将程序和静态数据加载到内存中的过程,需要操作系统从磁盘读取这些字节,并将它们放在内存中的某处

     将代码和静态数据加载到内存后,操作系统在运行此进程之前还需要执行其他一些操作。必须为程序的运行时栈(run-time stack或stack)分配一些内存。

    操作系统也可能为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据。操作系统还将执行一些其他初始化任务,特别是与输入/输出(I/O)相关的任务。

    通过将代码和静态数据加载到内存中,通过创建和初始化栈以及执行与I/O设置相关的其他工作,OS现在(终于)为程序执行搭好了舞台。然后它有最后一项任务:启动程序,在入口处运行,即main()。通过跳转到main()例程(第5章讨论的专门机制),OS将CPU的控制权转移到新创建的进程中,从而程序开始执行

     

    系统调用fork()用于创建新进程。创建的进程几乎与调用进程完全一样,对操作系统来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。新创建的进程称为子进程(child),原来的进程称为父进程(parent)。子进程不会从main()函数开始执行(因此hello world信息只输出了一次),而是直接从fork()系统调用返回,就好像是它自己调用了fork()。

    子进程并不是完全拷贝了父进程。具体来说,虽然它拥有自己的地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从fork()返回的值是不同的。父进程获得的返回值是新创建子进程的PID,而子进程获得的返回值是0。这个差别非常重要,因为这样就很容易编写代码处理两种不同的情况(像上面那样)。
    你可能还会注意到,它的输出不是确定的(deterministic)。子进程被创建后,我们就需要关心系统中的两个活动进程了:子进程和父进程。假设我们在单个CPU的系统上运行(简单起见),那么子进程或父进程在此时都有可能运行。在上面的例子中,父进程先运行并输出信息。在其他情况下,子进程可能先运行。CPU调度程序(scheduler)决定了某个时刻哪个进程被执行。

    exec()会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化。然后操作系统就执行该程序,将参数通过argv传递给该进程。因此,它并没有创建新进程,而是直接将当前运行的程序(以前的p3)替换为不同的运行程序(wc)。子进程执行exec()之后,几乎就像p3.c从未运行过一样。对exec()的成功调用永远不会返回。

    ·销毁(destroy):由于存在创建进程的接口,因此系统还提供了一个强制销毁进程的接口。当然,很多进程会在运行完成后自行退出。但是,如果它们不退出,用户可能希望终止它们,因此停止失控进程的接口非常有用。

    ·等待(wait):有时等待进程停止运行是有用的,因此经常提供某种等待接口。·其他控制(miscellaneous control):除了杀死或等待进程外,有时还可能有其他控制。例如,大多数操作系统提供某种方法来暂停进程(停止运行一段时间),然后恢复(继续运行)。

    有时候父进程需要等待子进程执行完毕。这项任务由wait()系统调用(或者更完整的兄弟接口waitpid())。

     父进程调用wait(),延迟自己的执行,直到子进程执行完毕。当子进程结束时,wait()才返回父进程。通过这段代码,现在我们知道子进程总是先输出结果。为什么知道?好吧,它可能只是碰巧先运行,像以前一样,因此先于父进程输出结果。但是,如果父进程碰巧先运行,它会马上调用wait()。该系统调用会在子进程运行结束后才返回。因此,即使父进程先运行,它也会礼貌地等待子进程运行完毕,然后wait()返回,接着父进程才输出自己的信息。

    状态(statu):通常也有一些接口可以获得有关进程的状态信息,例如运行了多长时间,或者处于什么状态。

    在早期的计算机系统[DV66,V+65]中,出现了一个进程可能处于这些状态之一的概念。简而言之,进程可以处于以下3种状态之一。

    ·运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。

    ·就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。

    ·阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。一个常见的例子是,当进程向磁盘发起I/O请求时,它会被阻塞,因此其他进程可以使用处理器。

     大多数情况下,shell可以在文件系统中找到这个可执行程序,调用fork()创建新进程,并调用exec()的某个变体来执行这个可执行程序,调用wait()等待该命令完成。子进程执行结束后,shell从wait()返回并再次输出一个提示符,等待用户输入下一条命令。

    总结:

    1、当调用fork()时,一个与父进程一模一样的子进程被创建,两个进程都从fork()系统调用返回,父进程的返回值是子进程的PID,而子进程的返回值是0。
    2、当父进程调用wait()时,父进程在子进程运行完后才运行。
    3、当子进程调用exec()时,子进程可以执行与父进程不同的程序。

    受限直接执行

    为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化。

    操作系统必须以高性能的方式虚拟化CPU,同时保持对系统的控制。为此,需要硬件和操作系统支持。操作系统通常会明智地利用硬件支持,以便高效地实现其工作。

    为了使程序尽可能快地运行,操作系统开发人员想出了一种技术——我们称之为受限的直接执行(limited direct execution)。这个概念的“直接执行”部分很简单:只需直接在CPU上运行程序即可。因此,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的),跳转到那里,并开始运行用户的代码。但是,这种方法在我们的虚拟化CPU时产生了一些问题。第一个问题:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希望它做的事,同时仍然高效地运行它?第二个问题:当我们运行一个进程时,操作系统如何让它停下来并切换到另一个进程,从而实现虚拟化CPU所需的时分共享?如果对运行程序没有限制,操作系统将无法控制任何事情,因此会成为“仅仅是一个库”——对于有抱负的操作系统而言,这真是非常令人悲伤的事!

    提示:采用受保护的控制权转移
    硬件通过提供不同的执行模式来协助操作系统。在用户模式(user mode)下,应用程序不能完全访问硬件资源。在内核模式(kernel mode)下,操作系统可以访问机器的全部资源。还提供了陷入(trap)内核和从陷阱返回(return-from-trap)到用户模式程序的特别说明,以及一些指令,让操作系统告诉硬件陷阱表(trap table)在内存中的位置。
    对于I/O和其在用户模式下运行的代码会受到限制。例如,在用户模式下运行时,进程不能发出I/O请求。这样做会导致处理器引发异常,操作系统可能会终止进程。

    与用户模式不同的内核模式(kernel mode),操作系统(或内核)就以这种模式运行。在此模式下,运行的代码可以做它喜欢的事,包括特权操作,如发出I/O请求和执行所有类型的受限指令。

    我们仍然面临着一个挑战——如果用户希望执行某种特权操作(如从磁盘读取),应该怎么做?为了实现这一点,几乎所有的现代硬件都提供了用户程序执行系统调用的能力。要执行系统调用,程序必须执行特殊的陷阱(trap)指令。该指令同时跳入内核并将特权级别提升到内核模式。一旦进入内核,系统就可以执行任何需要的特权操作(如果允许),从而为调用进程执行所需的工作。完成后,操作系统调用一个特殊的从陷阱返回(return-from-trap)指令,如你期望的那样,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。

    为什么系统调用看起来像过程调用?原因很简单:它是一个过程调用,但隐藏在过程调用内部的是著名的陷阱指令。更具体地说,当你调用open()(举个例子)时,你正在执行对C库的过程调用。其中,无论是对于open()还是提供的其他系统调用,库都使用与内核一致的调用约定来将参数放在众所周知的位置(例如,在栈中或特定的寄存器中),将系统调用号也放入一个众所周知的位置(同样,放在栈或寄存器中),然后执行上述的陷阱指令。库中陷阱之后的代码准备好返回值,并将控制权返回给发出系统调用的程序。因此,C库中进行系统调用的部分是用汇编手工编码的,因为它们需要仔细遵循约定,以便正确处理参数和返回值,以及执行硬件特定的陷阱指令。

    陷阱如何知道在OS内运行哪些代码?内核通过在启动时设置陷阱表(trap table)来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。能够执行指令来告诉硬件陷阱表的位置是一个非常强大的功能。因此,你可能已经猜到,这也是一项特权(privileged)操作。如果你试图在用户模式下执行这个指令,硬件不会允许,你可能会猜到会发生什么(提示:再见,违规程序)。

    我们假设每个进程都有一个内核栈,在进入内核和离开内核时,寄存器(包括通用寄存器和程序计数器)分别被保存和恢复。

     受限直接运行协议

    LDE协议(受限直接运行协议)有两个阶段。第一个阶段(在系统引导时),内核初始化陷阱表,并且CPU记住它的位置以供随后使用。内核通过特权指令来执行此操作(所有特权指令均以粗体突出显示)。第二个阶段(运行进程时),在使用从陷阱返回指令开始执行进程之前,内核设置了一些内容(例如,在进程列表中分配一个节点,分配内存)。这会将CPU切换到用户模式并开始运行该进程。当进程希望发出系统调用时,它会重新陷入操作系统,然后再次通过从陷阱返回,将控制权还给进程。该进程然后完成它的工作,并从main()返回。这通常会返回到一些存根代码,它将正确退出该程序(例如,通过调用exit()系统调用,这将陷入OS中)。此时,OS清理干净,任务完成了。

    操作系统如何重新获得CPU的控制权(regain control),以便它可以在进程之间切换?

    协作(cooperative)方式。在这种风格下,操作系统相信系统的进程会合理运行。运行时间过长的进程被假定会定期放弃CPU,以便操作系统可以决定运行其他任务。大多数进程通过进行系统调用,将CPU的控制权转移给操作系统,例如打开文件并随后读取文件,或者向另一台机器发送消息或创建新进程。像这样的系统通常包括一个显式的yield系统调用,它什么都不干,只是将控制权交给操作系统,以便系统可以运行其他如果应用程序执行了某些非法操作,也会将控制转移给操作系统。例如,如果应用程序以0为除数,或者尝试访问应该无法访问的内存,就会陷入(trap)操作系统。操作系统将再次控制CPU(并可能终止违规进程)。因此,在协作调度系统中,OS通过等待系统调用,或某种非法操作发生,从而重新获得CPU的控制权。

    事实证明,没有硬件的额外帮助,如果进程拒绝进行系统调用(也不出错),从而将控制权交还给操作系统,那么操作系统无法做任何事情。事实上,在协作方式中,当进程陷入无限循环时,唯一的办法就是使用古老的解决方案来解决计算机系统中的所有问题——重新启动计算机。因为 OS 只是个运行在 CPU 上的软件,跟能够运行在没有 OS 的 裸机上的普通软件是一样的,所以在争抢 CPU 资源上,其本身并没有特别卓越的能力。这个时候,就需要上游的场外裁判进场了,也就是硬件。

    如何在没有协作的情况下获得控制权?

    时钟中断(timer interrupt)。时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序(interrupt handler)会运行。此时,操作系统重新获得CPU的控制权,因此可以做它想做的事:停止当前进程,并启动另一个进程。即使进程以非协作的方式运行,添加时钟中断(timer interrupt)也让操作系统能够在CPU上重新运行。因此,该硬件功能对于帮助操作系统维持机器的控制权至关重要。

    首先,正如我们之前讨论过的系统调用一样,操作系统必须通知硬件哪些代码在发生时钟中断时运行。因此,在启动时,操作系统就是这样做的。其次,在启动过程中,操作系统也必须启动时钟,这当然是一项特权操作。一旦时钟开始运行,操作系统就感到安全了,因为控制权最终会归还给它,因此操作系统可以自由运行用户程序。

    硬件在发生中断时有一定的责任,尤其是在中断发生时,要为正在运行的程序保存足够的状态,以便随后从陷阱返回指令能够正确恢复正在运行的程序。这一组操作与硬件在显式系统调用陷入内核时的行为非常相似,其中各种寄存器因此被保存(进入内核栈),因此从陷阱返回指令可以容易地恢复。

    既然操作系统已经重新获得了控制权,无论是通过系统调用协作,还是通过时钟中断更强制执行,都必须决定:是继续运行当前正在运行的进程,还是切换到另一个进程。这个决定是由调度程序(scheduler)做出的,它是操作系统的一部分。

    如果决定进行切换,OS就会执行一些底层代码,即所谓的上下文切换(context switch)。上下文切换在概念上很简单:操作系统要做的就是为当前正在执行的进程保存一些寄存器的值(例如,到它的内核栈),并为即将执行的进程恢复一些寄存器的值(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回指令时,不是返回到之前运行的进程,而是继续执行另一个进程。
    为了保存当前正在运行的进程的上下文,操作系统会执行一些底层汇编代码,来保存通用寄存器、程序计数器,以及当前正在运行的进程的内核栈指针,然后恢复寄存器、程序计数器,并切换内核栈,供即将运行的进程使用。通过切换栈,内核在进入切换代码调用时,是一个进程(被中断的进程)的上下文,在返回时,是另一进程(即将执行的进程)的上下文。当操作系统最终执行从陷阱返回指令时,即将执行的进程变成了当前运行的进程。至此上下文切换完成。

    进程调度

    操作系统调度程序采用的上层策略(policy)。探讨可能的策略范围之前,我们先做一些简化假设。这些假设与系统中运行的进程有关,有时候统称为工作负载(workload)。确定工作负载是构建调度策略的关键部分。除了做出工作负载假设之外,还需要一个东西能让我们比较不同的调度策略:调度指标。
    现在,我们只考虑一个指标:周转时间(turnaround time)。任务的周转时间定义为任务完成时间减去任务到达系统的时间。即:T周转时间= T完成时间−T到达时间
    周转时间是一个性能(performance)指标,另一个有趣的指标是公平(fairness)。性能和公平在调度系统中往往是矛盾的。例如,调度程序可以优化性能,但代价是以阻止一些任务运行,这就降低了公平。
    先进先出(First In First Out或FIFO)调度,有时候也称为先到先服务(First Come First Served或FCFS)。但FIFO并没有想象的那么好,我们假设3个任务(A、B和C),但这次A运行100s,而B和C运行10s。A先运行100s,B或C才有机会运行。因此,系统的平均周转时间是比较高的:令人不快的110s((100 + 110 + 120)/ 3 = 110)。这个问题通常被称为护航效应(convoy effect),一些耗时较少的潜在资源消费者被排在重量级的资源消费者之后,这会导致后面的任务要等待很长时间。
    SJF原则:被称为最短任务优先(Shortest Job First,SJF),该名称应该很容易记住,因为它完全描述了这个策略:先运行最短的任务,然后是次短的任务,如此下去。
    考虑平均周转时间的情况下,SJF调度策略更好。仅通过在A之前运行B和C,SJF将平均周转时间从110s降低到50s((10 + 20 + 120)/3 = 50)。
    事实上,考虑到所有工作同时到达的假设,我们可以证明SJF确实是一个最优调度算法。

    抢占式调度程序

    几乎所有现代化的调度程序都是抢占式的(preemptive),非常愿意停止一个进程以运行另一个进程。
    现在假设任务可以随时到达,而不是同时到达。现在,假设A任务在t = 0时到达,且需要运行100s。而B任务和C任务在t = 10到达,且各需要运行10s。用纯SJF,即使B和C在A之后不久到达,它们仍然被迫等到A完成,从而遭遇同样的护航问题。这3项工作的平均周转时间为103.33s,即(100+(110−10)+(120−10))/3。SJF是一种非抢占式(non-preemptive)调度程序,因此存在上述问题。
    幸运的是,有一个调度程序完全就是这样做的:向SJF添加抢占,称为最短完成时间优先(Shortest Time-to-Completion First,STCF)或抢占式最短作业优先(Preemptive Shortest Job First ,PSJF)调度程序。每当新工作进入系统时,它就会确定剩余工作和新工作中,谁的剩余时间最少,然后调度该工作。因此,在我们的例子中,STCF将抢占A并运行B和C以完成。只有在它们完成后,才能调度A的剩余时间。

    新度量指标:响应时间

    如果我们知道任务长度,而且任务只使用CPU,而我们唯一的衡量是周转时间,STCF将是一个很好的策略。然而,引入分时系统改变了这一切。现在,用户将会坐在终端前面,同时也要求系统的交互性好。因此,一个新的度量标准诞生了:响应时间(response time)。
    响应时间定义为从任务到达系统到首次运行的时间。即:T响应时间= T首次运行−T到达时间
    例如,如果我们有上面的调度(A在时间0到达,B和C在时间10达到),每个作业的响应时间如下:作业A为0,B为0,C为10(平均:3.33)。
    你可能会想,STCF和相关方法在响应时间上并不是很好。例如,如果3个工作同时到达,第三个工作必须等待前两个工作全部运行后才能运行。这种方法虽然有很好的周转时间,但对于响应时间和交互性是相当糟糕的。假设你在终端前输入,不得不等待10s才能看到系统的回应,只是因为其他一些工作已经在你之前被调度,你肯定不太开心。

    如何构建对响应时间敏感的调度程序?

    为了解决这个问题,我们将介绍一种新的调度算法,通常被称为轮转(Round-Robin,RR)调度。基本思想很简单:RR在一个时间片(time slice,有时称为调度量子,scheduling quantum)内运行一个工作,然后切换到运行队列中的下一个任务,而不是运行一个任务直到结束。它反复执行,直到所有任务完成。因此,RR有时被称为时间切片(time-slicing)。请注意,时间片长度必须是时钟中断周期的倍数。因此,如果时钟中断是每10ms中断一次,则时间片可以是10ms、20ms或10ms的任何其他倍数。
    我们来看一个例子。假设3个任务A、B和C在系统中同时到达,并且它们都希望运行5s。SJF调度程序必须运行完当前任务才可运行下一个任务。相比之下,1s时间片的RR可以快速地循环工作,RR的平均响应时间是:(0 + 1 + 2)/3 = 1; SJF 算法平均响应时间是:(0 + 5 + 10)/ 3 = 5。
    时间片长度对于RR是至关重要的。越短,RR在响应时间上表现越好。然而,时间片太短是有问题的:突然上下文切换的成本将影响整体性能。因此,系统设计者需要权衡时间片的长度,使其足够长,以便摊销(amortize)上下文切换成本,而又不会使系统不及时响应。
    提示:摊销可以减少成本
    当系统某些操作有固定成本时,通常会使用摊销技术(amortization)。通过减少成本的频度(即执行较少次的操作),系统的总成本就会降低。例如,如果时间片设置为10ms,并且上下文切换时间为1ms,那么浪费大约10%的时间用于上下文切换。如果要摊销这个成本,可以把时间片增加到100ms。在这种情况下,不到1%的时间用于上下文切换,因此时间片带来的成本就被摊销了。
    请注意,上下文切换的成本不仅仅来自保存和恢复少量寄存器的操作系统操作。程序运行时,它们在CPU高速缓存、TLB、分支预测器和其他片上硬件中建立了大量的状态。切换到另一个工作会导致此状态被刷新,且与当前运行的作业相关的新状态被引入,这可能导致显著的性能成本。
    如果响应时间是我们的唯一指标,那么带有合理时间片的RR,就会是非常好的调度程序。但是我们老朋友的周转时间呢?再来看看我们的例子。A、B和C,每个运行时间为5s,同时到达,RR是具有(长)1s时间片的调度程序。那么,A在13完成,B在14,C在15,平均14。相当可怕!
    请注意,上下文切换的成本不仅仅来自保存和恢复少量寄存器的操作系统操作。程序运行时,它们在CPU高速缓存、TLB、分支预测器和其他片上硬件中建立了大量的状态。切换到另一个工作会导致此状态被刷新,且与当前运行的作业相关的新状态被引入,这可能导致显著的性能成本。
    如果响应时间是我们的唯一指标,那么带有合理时间片的RR,就会是非常好的调度程序。但是我们老朋友的周转时间呢?再来看看我们的例子。A、B和C,每个运行时间为5s,同时到达,RR是具有(长)1s时间片的调度程序。从图可以看出,A在13完成,B在14,C在15,平均14。相当可怕!
    这并不奇怪,如果周转时间是我们的指标,那么RR确实是最糟糕的策略之一。直观地说,这应该是有意义的:RR所做的正是延伸每个工作,只运行每个工作一小段时间,就转向下一个工作。因为周转时间只关心作业何时完成,RR几乎是最差的,在很多情况下甚至比简单的FIFO更差。
    我们开发了两种调度程序。第一种类型(SJF、STCF)优化周转时间,但对响应时间不利。第二种类型(RR)优化响应时间,但对周转时间不利。
    提示:重叠可以提高利用率
    如有可能,重叠(overlap)操作可以最大限度地提高系统的利用率。重叠在许多不同的领域很有用,包括执行磁盘I/O或将消息发送到远程机器时。在任何一种情况下,开始操作然后切换到其他工作都是一个好主意,这也提高了系统的整体利用率和效率。
     
    总结:我们介绍了调度的两类方法。第一类是运行最短的工作,从而优化周转时间。第二类是交替运行所有工作,从而优化响应时间。但很难做到“鱼与熊掌兼得”,这是系统中常见的、固有的折中。

    调度:多级反馈队列

    多级反馈队列(Multi-level Feedback Queue,MLFQ),用于兼容时分共享系统(CTSS),该调度程序经过多年的一系列优化,出现在许多现代操作系统中。
    多级反馈队列是用历史经验预测未来的一个典型的例子,操作系统中有很多地方采用了这种技术(同样存在于计算机科学领域的很多其他地方,比如硬件的分支预测及缓存算法)。

    MLFQ:基本规则

    MLFQ中有许多独立的队列(queue),每个队列有不同的优先级(priority level)。任何时刻,一个工作只能存在于一个队列中。MLFQ总是优先执行较高优先级的工作(即在较高级队列中的工作)。
    当然,每个队列中可能会有多个工作,因此具有同样的优先级。在这种情况下,我们就对这些工作采用轮转调度。
    因此,MLFQ调度策略的关键在于如何设置优先级。MLFQ没有为每个工作指定不变的优先情绪而已,而是根据观察到的行为调整它的优先级。例如,如果一个工作不断放弃CPU去等待键盘输入,这是交互型进程的可能行为,MLFQ因此会让它保持高优先级。相反,如果一个工作长时间地占用CPU,MLFQ会降低其优先级。通过这种方式,MLFQ在进程运行过程中学习其行为,从而利用工作的历史来预测它未来的行为。
    规则1:如果A的优先级 > B的优先级,运行A(不运行B)。
    ·规则2:如果A的优先级 = B的优先级,轮转运行A和B。
    ·规则3:工作进入系统时,放在最高优先级(最上层队列)。
    X·规则4a:工作用完整个时间片后,降低其优先级(移入下一个队列)。
    X·规则4b:如果工作在其时间片以内主动释放CPU,则优先级不变
    ·规则4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级(移入低一级队列)。
    ·规则5:经过一段时间S,就将系统中所有工作重新加入最高优先级队列。
    这个算法的一个主要目标:如果不知道工作是短工作还是长工作,那么就在开始的时候假设其是短工作,并赋予最高优先级。如果确实是短工作,则很快会执行完毕,否则将被慢慢移入低优先级队列,而这时该工作也被认为是长工作了。通过这种方式,MLFQ近似于SJF。
     

    MLFQ的一些问题:

    首先,会有饥饿(starvation)问题。如果系统有“太多”交互型工作,就会不断占用CPU,导致长工作永远无法得到CPU(它们饿死了)。即使在这种情况下,我们希望这些长工作也能有所进展。
    其次,聪明的用户会重写程序,愚弄调度程序(game the scheduler)。愚弄调度程序指的是用一些卑鄙的手段欺骗调度程序,让它给你远超公平的资源。上述算法对如下的攻击束手无策:进程在时间片用完之前,调用一个I/O操作(比如访问一个无关的文件),从而主动释放CPU。如此便可以保持在高优先级,占用更多的CPU时间。做得好时(比如,每运行99%的时间片时间就主动放弃一次CPU),工作可以几乎独占CPU。
    最后,一个程序可能在不同时间表现不同。一个计算密集的进程可能在某段时间表现为一个交互型的进程。用我们目前的方法,它不会享受系统中其他交互型工作的待遇。
    解决方案:经过一段时间S,就将系统中所有工作重新加入最高优先级队列。
    该方案解决了两个问题。首先,进程不会饿死——在最高优先级队列中,它会以轮转的方式,与其他高优先级工作分享CPU,从而最终获得执行。其次,如果一个CPU密集型工作变成了交互型,当它优先级提升时,调度程序会正确对待它。
    当然,添加时间段S导致了明显的问题:S的值应该如何设置?如果S设置得太高,长工作会饥饿;如果设置得太低,交互型工作又得不到合适的CPU时间比例。尽可能避免使用S是个好主意。然而,这通常很难。当然,我们也可以让系统自己去学习一个很优化的值,但这同样也不容易。因此,通常我们会有一个写满各种参数值默认值的配置文件,使得系统管理员可以方便地进行修改调整。然而,大多数使用者并不会去修改这些默认值,这时就寄希望于默认值合适了。
     
    还有一个问题要解决:如何阻止调度程序被愚弄?可以看出,这里的元凶是规则4a和4b,导致工作在时间片以内释放CPU,就保留它的优先级。那么应该怎么做?
    解决方案,是为MLFQ的每层队列提供更完善的CPU计时方式(accounting)。调度程序应该记录一个进程在某一层中消耗的总时间,而不是在调度时重新计时。只要进程用完了自己的配额,就将它降到低一优先级的队列中去。不论它是一次用完的,还是拆成很多次用完。因此,我们重写规则4a和4b。
    规则4:一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次CPU),就降低其优先级(移入低一级队列)。
     
     

     

  • 相关阅读:
    SCI写作经典替换词,瞬间高大上!(转)
    最佳化常用测试函数 Optimization Test functions
    算法复杂度速查表
    VS 代码行统计
    CPLEX IDE 菜单栏语言设置( 中文 英文 韩文 等多国语言 设置)
    如何从PDF文件中提取矢量图
    Matlab无法打开M文件的错误( Undefined function or method 'uiopen' for input arguments of type 'char)
    visual studio 资源视图 空白 解决方案
    MFC DialogBar 按钮灰色不响应
    嗨翻C语言笔记(二)
  • 原文地址:https://www.cnblogs.com/soaring-sun/p/15412208.html
Copyright © 2011-2022 走看看