一些关于操作系统简单的介绍

目录

机制:有限的直接执行

问题1:受限操作。

问题2:进程间的切换

总结

调度:介绍

工作负载的假设

完全操作调度规程

调度度量 :

先进先出(FIFO)

最短作业优先(SJF)

最快完成时间优先(STCF)

一个新的指标:响应时间

轮询

结合I/0

没有更多的假设

总结

调度: 多级反馈队列

MLFQ:基本规则

尝试#1:如何更改优先级

目前的MLFQ有问题

尝试#2:优先级提升

尝试3:更好的计算

优化MLFQ和其他问题

MLFQ:总结

调度:比例共享

基本概念:彩票代表您的份额

彩票机制:

stride调度

Linux完全公平调度程序(CFS)

基本操作

加权

使用“红-黑”树

处理I/O和睡眠过程

CFS其他

总结

多处理器调度

抽象:地址空间

早期的系统

多进程设计和时间共享

地址空间

目标

机制:地址转换

假设

一个例子

动态(基于硬件)重定位

硬件支持:总结

操作系统问题

分段

关于栈

支持共享

细粒度分段和粗粒度分段

OS支持

总结

空闲空间管理

假设

底层机制

跟踪分配区域的大小

嵌入一个空闲列表

基本策略

总结

分页

一个简单的例子和概述

多级页表


机制:有限的直接执行

问题1:受限操作。

直接执行具有速度快的明显优势;程序在硬件CPU上本机运行,因此执行速度与预期一样快。但是在CPU上运行会带来一个问题:如果进程希望执行某种受限的操作,比如向磁盘发出I/O请求,或者访问更多的系统资源,比如CPU或内存,该怎么办?

关键是:如何执行受限的操作

进程必须能够执行I/O和其他一些受限制的操作,但不能让进程完全控制系统。
操作系统和硬件如何协同工作才能做到这一点?

一种方法就是让任何进程在I/O和其他相关操作方面做它想做的任何事情。然而,这样做将会阻止许多类型的系统的构建。例如,如果我们希望在授予对文件的访问权之前构建一个检查权限的文件系统,我们不能简单地让任何用户进程向磁盘发出I/Os;如果我们这样做了,进程可以简单地读写整个磁盘,因此所有的保护都将丢失。

因此,我们采用的方法是引入一种新的处理器模式,称为用户模式;在用户模式下运行的代码会受到限制。例如,在用户模式下运行时,进程无法发出I / O请求;这样做会导致处理器引发异常;操作系统可能会终止这个过程

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

然而,我们仍然面临一个挑战:当用户进程希望执行某种特权操作(如从磁盘读取)时,它应该做什么?为了实现这一点,几乎所有现代硬件都为用户程序提供了执行系统调用的能力。系统调用是在Atlas [K+61,L78]等古老机器上的先驱,它允许内核谨慎地向用户程序公开某些关键功能,比如访问文件系统、创建和销毁进程、与其他进程通信以及分配更多的内存。

要执行系统调用,程序必须执行一个特殊的陷阱指令。该指令同时跳转到内核中,并将权限级别提升到内核模式;在内核中之后,系统现在可以执行所需的任何特权操作(如果允许的话),从而完成调用过程所需的工作。完成后,操作系统调用一个特殊的从陷阱返回指令,正如您所期望的那样,该指令返回到调用的用户程序中,同时将权限级别降低到用户模式

硬件在执行陷阱时需要小心一点,因为它必须确保保存足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回。例如,在x86上,处理器将把程序计数器、标志和其他一些寄存器放入每个进程的内核堆栈中;返回陷阱会将这些值从堆栈中取出并重新执行usermode程序其他硬件系统使用不同的约定,但基本概念是相似的

这个讨论遗漏了一个重要的细节:陷阱如何知道在操作系统中运行哪些代码?显然,调用过程不能指定要跳转到的地址(就像进行过程调用时那样);这样做将允许程序跳转到内核的任何地方,这显然是一个非常糟糕的想法。因此,内核必须小心地控制在陷阱上执行的代码。

内核通过在引导时设置一个陷阱表来实现这一点。当机器启动时,它以特权(内核)模式启动,因此可以根据需要自由配置机器硬件。因此,操作系统所做的第一件事就是告诉硬件在某些异常事件发生时运行什么代码。例如,当硬盘中断发生时,当键盘中断发生时,或者当程序进行系统调用时,应该运行哪些代码.操作系统通知硬件这些陷阱处理器的位置,通常带有某种特殊的内部结构。

为了指定确切的系统调用,通常为每个系统调用分配一个系统调用号。因此,用户代码负责将所需的系统调用号放在寄存器中或堆栈上的指定位置;操作系统在处理陷阱处理程序内部的系统调用时,检查这个数字,确保它是有效的,如果是,则执行相应的代码。这种间接性是一种保护形式;用户代码不能指定要跳转到的确切地址,而是必须通过数字请求特定的服务。

最后一点:能够执行指令来告诉硬件trap表的位置是一个非常强大的功能。因此,正如您可能已经猜到的,它也是一个特权操作。如果您试图在用户模式下执行此指令,硬件将不允许您这样做.

我们假设每个进程都有一个注册的内核堆栈(包括通用寄存器和程序计数器)在转换到内核和退出内核时,(由硬件)保存和恢复。

有限直接执行协议有两个阶段。
在第一个阶段(在引导时),内核初始化trap表,以及
CPU会记住它的位置供后续使用。内核通过特权指令(所有特权指令都以粗体突出显示)来实现这一点

在第二个阶段(运行进程时),内核会设置一些东西
(例如,在进程列表上分配一个节点,分配内存),在从陷阱返回指令开始进程的执行之前;这会将CPU切换到用户模式并开始运行进程。
 

问题2:进程间的切换

直接执行的下一个问题是实现进程间的切换。进程间的切换应该很简单,对吧?
操作系统应该决定停止一个进程并启动另一个进程。

一个定时器装置可以被编程成每隔几毫秒就引发一次中断;当引发中断时,当前正在运行的进程就会停止,操作系统中会运行一个预先配置好的中断处理程序。
此时,操作系统已经重新控制了CPU,因此可以做它想做的事情:停止当前进程,并启动另一个进程。

正如我们之前讨论的系统调用一样,当计时器中断发生时,操作系统必须通知硬件运行哪个代码;因此,在引导时,操作系统就会这样做。其次,在引导序列中,操作系统必须启动计时器,这当然是一个特权操作。一旦计时器开始,操作系统就会感到安全,控制最终会返回给它,因此操作系统可以自由地运行用户程序。计时器也可以关闭(也是一种特权操作),我们将在稍后更详细地了解并发性时讨论这一点。

请注意,当中断发生时,硬件有一定的责任,特别是要保存中断发生时正在运行的程序的状态,以便后续的return-from - trap指令能够正确地恢复运行的程序。这组操作与显式系统调用陷阱进入内核期间硬件的行为非常相似,因此可以保存各种寄存器(例如,内核堆栈),并通过从陷阱返回指令轻松恢复。

保存和恢复上下文


现在操作系统重新获得了控制权,无论是通过系统调用进行合作,还是通过计时器中断进行更有力的合作,都必须做出决定:是继续运行当前正在运行的进程,还是切换到另一个进程。这个决策是由操作系统的一部分称为调度器做出的;我们将在接下来的几章中详细讨论调度策略

如果决定切换,操作系统就会执行一段低级代码,我们称之为上下文切换。上下文切换在概念上很简单:操作系统所要做的就是为当前正在执行的进程(例如,在其内核堆栈中)保存一些寄存器值,并为即将执行的进程(从其内核堆栈中)恢复一些寄存器值。通过这样做,操作系统确保当从陷阱返回指令最终执行时,系统将恢复另一个进程的执行,而不是返回到正在运行的进程。

为了保存当前正在运行的进程的上下文,操作系统将执行一些低级汇编代码来保存通用寄存器。然后恢复所述寄存器,PC,并切换到即将执行的进程的内核堆栈。通过交换堆栈,内核在一个进程(被中断的进程)的上下文中输入对切换代码的调用,并在另一个进程(即将被执行的进程)的上下文中返回。当操作系统最终执行一个从陷阱返回的指令时,即将被执行的进程就变成了当前正在运行的进程。这样上下文切换就完成了。

总结

我们已经描述了一些实现CPU虚拟化的关键低级机制,这是一组我们统称为有限直接执行的技术。基本思想很简单:只运行您想在CPU上运行的程序,但是首先要确保设置硬件,以限制在没有OS帮助的情况下进程能做什么.

CPU应该至少支持两种执行模式:受限用户模式和特权(非受限)内核模式.

典型的用户应用程序在用户模式下运行,使用系统调用进入内核请求操作系统服务。

trap指令小心地保存了寄存器状态,将硬件状态更改为内核模式,并跳转到OS中预先指定的目的地:trap表

当操作系统完成对系统调用的服务时,它通过另一条特殊的从陷阱返回的指令返回到用户程序,降低特权级,并在进入操作系统的陷阱之后返回控制。

陷阱表必须由操作系统在引导时设置,并确保它们不能被用户程序轻易修改。所有这些都是有限的直接执行协议的一部分,该协议有效地运行程序,但不会失去操作系统的控制。

一旦程序运行,操作系统必须使用硬件机制来确保用户程序不会永远运行,即计时器中断。这种方法是非合作的CPU调度方法。

有时,在计时器中断或系统调用期间,操作系统可能希望从运行当前进程切换到另一个进程,这是一种低级技术,称为上下文切换


调度:介绍

到目前为止,运行进程的低级机制(例如,上下文切换)应该是清晰的;如果没有,回到前面,再读一遍关于这些东西是如何工作的描述。然而,我们还需要了解OS调度器使用的高级策略。我们现在将这样做,展示一系列的调度策略(有时称为规程),这些策略是多年来各种聪明和勤奋的人开发出来的。

工作负载的假设

在讨论可能的策略之前,让我们首先对系统中运行的进程(有时统称为工作负载)进行一些简化假设。确定工作负载是构建策略的一个关键部分,您对工作负载了解得越多,您的策略就能得到更好的调整。

我们在这里所做的工作量假设大多是不现实的,但这是可以接受的(就目前而言),因为我们将在进行时放松它们,并最终开发出我们所称的……(戏剧性的停顿)…

完全操作调度规程

我们将对系统中运行的流程(有时称为作业)做出以下假设:

1. 每个作业运行的时间相同。
2. 所有的工作都同时到来。
3.一旦开始,每一项工作都要完成。
4. 所示。所有作业只使用CPU(即,则不执行I/O)
5. 每个作业的运行时是已知的

我们说这些假设很多都是不现实的,但是正如奥威尔的动物农场里的一些动物比其他动物更平等一样,在这一章里的一些假设比其他的更不现实。特别是,每个作业的运行时是已知的,这可能会让您感到困扰:这将使调度器无所不知,尽管这很好(可能),但不太可能发生.

调度度量 :

除了做出工作负载假设之外,我们还需要另外一件事情来让我们能够比较不同的调度策略:调度度量。
度量只是我们用来度量的东西,在调度中有许多不同的度量是有意义的.

然而,现在,让我们简化一下,只需要一个简单的指标:周转时间。作业的周转时间定义为作业完成的时间减去作业到达系统的时间。

因为我们假设现在所有的工作都是同时到来的
Tarrival = 0,因此Tturnaround = Tcompletion。当我们放松上述假设时,这个事实将会改变。

您应该注意到,周转时间是性能指标,这将是本章的重点。另一个利益衡量标准是公平,例如,由Jain公平指数来衡量。性能和公平性在调度上经常不一致;例如,调度器可以优化性能,但代价是阻止一些作业运行,从而降低公平性。

先进先出(FIFO)

我们能实现的最基本的算法被称为先进先出(FIFO)或先到先得(FCFS).。FIFO有一些积极的特性:它很简单,因此很容易实现。根据我们的假设,它工作得很好

让我们一起做一个简单的例子。假设系统中有三个作业,A、B和C,它们几乎同时到达(Tarrival = 0),FIFO必须先放进一下作业,假设它们几乎同时到达,A在B之前到达了一根头发,而c之前到达了一根头发。这些工作的平均周转时间是多少?你可以看到A在10点结束,B在20点结束,C在30点结束。
因此,这三个工作的平均周转时间就是(10+20+30) / 3 = 20.计算周转时间就这么简单。

现在让我们放松一个假设。特别地,让我们放松假设1,因此不再假设每个作业运行的时间相同。先进先出现在的表现如何?为了使FIFO的性能变差,您可以构造什么样的工作负载?

假设您现在已经知道了这一点,但是为了以防万一,让我们做一个例子来展示不同长度的工作如何导致调度机制FIFO的问题。特别地,我们再假设三个作业(A, B,和C),但是这次A跑100秒,B和C跑10秒。

在B或C有机会跑之前,作业A先跑了整整100秒。因此,系统的平均周转时间很高:痛苦的110秒(100+110+120)/ 2 = 110.

这个问题通常被称为护航效应,即资源的一些相对较短的潜在资源消费者在重量级资源消费者后面排队。这种安排场景可能会让你想起杂货店里的一行人,你看到你前面的人拿着三辆装满食物的手推车和一本支票簿。

那么我们该怎么办呢?我们如何才能开发出更好的算法来处理我们的新作业在现实中,当作业运行不同的时间?
首先想一下;然后继续读下去。

最短作业优先(SJF)

一个非常简单的方法解决了这个问题;事实上,这是从运筹学中偷来的一个想法,并应用于计算机系统中的作业安排。这种新的调度规则称为“最短作业优先”(SJF),这个名称应该很容易记住,因为它完全描述了策略:首先运行最短作业,然后运行下一个最短作业,依此类推。

简单地在A之前运行B和C, SJF将平均周转率从110秒降低到50秒((10+20+120)/3 = 50),超过两倍的改进。

事实上,假设所有的任务同时到达,我们可以证明SJF确实是一个最优的调度算法。然而,你是在系统课上,而不是理论或运筹学;不需要去证明它。

在这里,我们可以用一个例子来说明这个问题。这一次,假设A到达t = 0时需要运行100秒,而B和C到达t = 10时需要运行10秒。用纯SJF,虽然B和C在A之后不久就到了,但是他们仍然被迫等待A完成,所以这三个作业的平均周转时间是103.33秒( 100+(110−10)+(120−10) /3 = 103.33 ).

最快完成时间优先(STCF)

为了解决这个问题,我们需要放松假设3(工作必须运行到完成),所以让我们这样做。我们还需要调度程序内部的一些机制。正如您可能已经猜到的,考虑到我们之前关于计时器中断和上下文切换的讨论,调度器当然可以在B和C到达时做其他事情:它可以抢占作业A并决定运行另一个作业,可能会继续A。根据我们的定义,SJF是非抢占式调度器,因此存在上述问题。

幸运的是,有一个调度器可以做到这一点:向SJF添加抢占,称为最短完成时间优先(STCF)或抢占最短作业优先(PSJF)调度程序。任作业进入系统后,STCF调度器确定剩下的作业(包括新作业)中剩下的时间最少的,并调度该作业。因此,在我们的示例中,STCF将抢占A并运行B和C来完成; 只有当它们完成后,A的剩余时间才会被安排。

结果是大大提高了平均周转时间:50秒
((120−0)+(20−10)+(30−10)/3 = 50)。和之前一样,根据我们的新假设,
STCF是可证明的最优;假设如果所有作业同时到达,SJF是最优的,那么您应该能够看到STCF最优性背后的直觉.

一个新的指标:响应时间

因此,如果我们知道作业长度,并且作业只使用CPU,而我们唯一的度量标准是周转时间,那么STCF将是一个很好的策略。事实上,对于许多早期的批处理计算系统来说,这些类型的调度算法是有意义的。然而,时间共享机器的引入改变了这一切。现在,用户将坐在终端前,并要求系统提供交互性能。因此,一个新的度量标准诞生了:响应时间。

我们将响应时间定义为从作业到达系统到第一次被调度的时间。

例如,如果我们有上面的时间表(A在时间0到达,B和C在时间10到达),每个作业的响应时间如下:A作业0,B作业0,C作业10(平均3.33)。

正如您可能认为的那样,STCF和相关的规程对于响应时间不是特别好。例如,如果同时有三个作业到达,那么第三个作业必须等待前两个作业完整运行,然后才调度一次。虽然这种方法对于周转时间很好,但是对于响应时间和交互性来说却很糟糕

轮询

为了解决这个问题,我们将引入一种新的调度算法,通常称为循环调度(RR) [K64]。基本思想很简单:RR在一个时间片(有时称为调度量)上运行作业,然后切换到运行队列中的下一个作业。它重复这样做,直到工作完成。因此,RR有时被称为时间切片。注意,时间片的长度必须是时间中断周期的倍数;因此,如果计时器每10毫秒中断一次,时间片可能是10、20毫秒。

为了更详细地理解RR,让我们看一个例子。假设系统中同时出现三个作业A、B和C,并且每个作业都希望运行5秒钟。SJF调度器在运行另一个作业之前运行每个作业。相比之下,时间片为1秒的RR会快速循环通过作业
RR的平均响应时间为:(0+1+2)/3 = 1;对于SJF,平均响应时间为:(0+5+10)/3 = 5。

如您所见,时间片的长度对于RR非常关键。它越短,RR在响应时间度量下的性能越好。
然而,使时间片太短是有问题的:频繁地上下文切换的成本将主导整体性能。因此,对系统设计人员来说,决定时间片的长度是一种权衡。

请注意,上下文切换的成本不仅仅来自于操作系统保存和恢复一些寄存器的操作。当程序运行时,它们在CPU缓存、TLBs、分支预测器和其他芯片硬件中建立了大量的状态。切换到另一个作业会导致刷新这个状态,并引入与当前正在运行的作业相关的新状态,这可能会导致显著的性能成本。

如果响应时间是我们唯一的度量标准,那么具有合理时间片的RR是一个优秀的调度器。但是我们的老朋友周转时间呢?让我们再看一下上面的例子。A、B和C,每一个运行时间为5秒,同时到达,RR是一个(长)1秒时间片的调度程序。但是,我们可以看到,完成时间A在13,B在14,C在15,平均14。这很糟糕。

因此,如果以周转时间作为衡量标准,那么RR确实是最糟糕的政策之一就不足为奇了。直觉上,这应该是有道理的:。RR所做的就是尽可能地延长每一项工作的时间,在移动到下一项工作之前只运行一小段时间。因为周转时间只关心工作完成的时间,所以RR在很多情况下甚至比简单的FIFO更糟糕。

更一般地说,任何公平的政策(如RR),即在很小的时间范围内将CPU平均分配给活动进程,在诸如周转时间等指标上的表现会很差。事实上,这是一种固有的权衡:如果你愿意不公平,你可以运行更短的工作来完成,但以牺牲响应时间为代价;如果你看重公平性,响应时间就会降低,但代价是周转时间。

我们开发了两种调度器。第一种类型(SJF, STCF)优化了周转时间,但不利于响应时间。第二种类型
(RR)优化响应时间,但不利于周转。我们仍然有两个需要放松的假设:假设4(作业不做I/O)和假设5(每个作业的运行时已知)。
接下来让我们来解决这些假设。

结合I/0

首先,我们将放松假设4——程序会执行I / O。

当作业发起I/O请求时,调度器显然要做出决定,因为当前正在运行的作业在I/O期间不会使用CPU;它被阻塞,等待I/O完成。如果将I/O发送到硬盘驱动器,进程可能会阻塞几毫秒甚至更长时间,这取决于驱动器的当前I/O负载。因此,调度程序可能会在那个时候在CPU上调度另一个作业。

调度程序还必须在I/O完成时做出决定。
发生这种情况时,会引发一个中断,操作系统会运行并将发出I/O的进程从阻塞状态移回就绪状态。当然,它甚至可以在那个时候决定运行这项工作。操作系统应该如何处理每一项工作?

为了更好地理解这个问题,让我们假设我们有两个作业,A和B,每个都需要50毫秒的CPU时间。然而,有一个明显的区别:A运行10毫秒,然后发出I/O请求(假设这里的I/Os每个需要10毫秒),而B只使用CPU 50毫秒不执行i/o操作。

假设我们正在尝试构建一个STCF调度器。这样的调度器应该如何解释这样一个事实:a被分解为5个10-ms的子作业,而B仅仅是一个50-ms的CPU需求?显然,只运行一个作业,然后再运行另一个作业,而不考虑I/O没有任何意义。

一种常见的方法是将A的每个10-ms子工作视为独立的工作。因此,在本例中a在左边,它开始运行。然后提交一个新的子作业a,它抢占B,运行10毫秒。这样做允许重叠,一个进程使用CPU,而等待另一个进程的I/O完成;因此,系统得到更好的利用。

没有更多的假设

有了一种基本的I/O方法,我们就得出了最后的假设:调度程序知道每个作业的长度。正如我们之前所说,这可能是我们能做出的最糟糕的假设。事实上,操作系统(就像我们关心的那些),操作系统通常对每个工作的长度知之甚少。因此,在没有这种先验知识的情况下,我们如何构建一种行为类似SJF/STCF的方法呢?此外,我们如何将看到的一些思想与RR调度器结合起来,从而使响应时间也相当好?

总结


我们介绍了调度的基本思想,并开发了两类方法。第一个运行剩下的最短作业,从而优化周转时间;第二个任务在所有作业之间交替进行,从而优化响应时间。两者需要权衡。我们还看到了如何将I/O结合,但仍未能解决操作系统根本无法预见未来的问题。很快,我们将看到如何通过构建一个多级反馈队列调度器来克服这个问题


调度: 多级反馈队列

在这一节,我们将了解最著名的调度方法之一的问题,即多级反馈队列(MLFQ)。多级反馈队列(MLFQ)调度器最早由Corbato等人于1962年在一个被称为兼容分时系统(CTSS)的系统中描述,这项工作以及后来在Multics方面的工作,使ACM将Corbato的最高荣誉——图灵奖授予了它。调度器随后经过多年的改进,最终实现了你将在一些现代系统中遇到的实现。

MLFQ试图解决的基本问题有两方面。首先,它希望优化周转时间,正如我们在上一篇文章中看到的,这是通过运行更短的作业来完成的;不幸的是,操作系统通常不知道一个任务将运行多长时间,这正是SJF(或STCF)等算法所需要的知识。其次,MLFQ希望使系统对交互式用户具有响应性。不幸的是,像轮询这样的算法减少了响应时间,但是对于周转时间来说却很糟糕。因此,我们的问题是:考虑到我们通常对一个过程一无所知,我们如何构建一个调度程序来实现这些目标呢?当系统运行时,调度器如何学习它正在运行的作业的特征,从而做出更好的调度决策

MLFQ:基本规则

为了构建这样一个调度器,在本章中,我们将描述多级反馈队列背后的基本算法;虽然许多实现的MLFQs的细节各不相同,但大多数方法是相似的。

在我们的处理中,MLFQ有许多不同的队列,每个队列都分配了不同的优先级。在任何给定时间,准备运行的作业都在一个队列上。MLFQ使用优先级来决定哪个作业应该在给定的时间运行: 一个具有更高优先级的作业(即,选择运行较高队列上的作业。

当然,在给定的队列中可能有多个作业,因此具有相同的优先级。在本例中,我们将在这些作业中使用循环调度。
因此,我们得出了MLFQ的前两个基本规则:
•规则1:如果优先级(A) >优先级(B),则A运行(B不运行)。
•规则2:如果优先级(A) =优先级(B),则A和B在RR中运行。

因此,MLFQ调度的关键在于调度程序如何设置优先级。MLFQ不是给每个作业一个固定的优先级,而是根据所观察到的行为改变作业的优先级。例如,如果一个作业在等待键盘输入时反复放弃CPU,MLFQ将保持它的优先级,因为这是一个交互过程可能表现的方式。相反,如果作业密集地使用CPU很长时间,MLFQ将降低其优先级。通过这种方式,MLFQ将尝试在进程运行时了解它们,从而使用作业的历史来预测其未来行为。

如果我们提出一个队列在给定时刻可能是什么样子的图片,我们可能会看到如下内容,在图中,两个作业(A和B)处于最高优先级,而作业C在中间,作业D在最低优先级。根据我们目前对MLFQ工作方式的了解,调度器将在A和B之间交替使用时间片,因为它们是系统中最优先的作业;可怜的C和D甚至永远都跑不了,真是令人愤慨。

当然,仅仅显示一些队列的静态快照并不能真正让您了解MLFQ是如何工作的。我们需要的是理解优先级随时间的变化。

尝试#1:如何更改优先级

现在,我们必须决定MLFQ在一个作业的生命周期内将如何更改作业的优先级级别(它所在的队列)。要做到这一点,我们必须记住我们的工作负载:短时间运行的交互式作业(可能经常放弃CPU)和一些长时间运行的“CPU绑定”作业(需要大量CPU时间,但响应时间不重要)的组合。这是我们第一次尝试优先级调整算法.

•规则3:当作业进入系统时,它处于最高优先级(最高队列)。
•规则4a:如果一个任务在运行时占用了整个时间片,那么它的优先级就会降低(例如:,它向下移动一个队列)
•规则4b:如果一个任务在时间片结束之前放弃了CPU,那么它将保持相同的优先级。

理解该算法的主要目标之一:因为它不知道某个作业是短期作业还是长期作业,所以它首先假设它可能是短期作业,从而给予该作业较高的优先级。如果它实际上是一个短的作业,它将运行得很快完成;如果它不是一个短期任务,它将缓慢地沿着队列移动,因此很快就会证明自己是一个长时间运行的更类似批处理的过程。


目前的MLFQ有问题


因此我们有一个基本的MLFQ。它似乎做得相当好,共享CPU在长时间运行的作业和让短时间或I/ o密集型交互作业快速运行之间切换。遗憾的是,我们迄今所开发的方法存在严重缺陷。你能想出一个吗?

首先是饥饿问题:如果系统中有“太多”的交互式作业,它们将联合起来消耗所有的CPU时间,因此长时间运行的作业将永远不会获得任何CPU时间(它们会饿死)。

第二,一个聪明的用户可以重写他们的程序来玩弄调度程序。玩弄调度程序通常指的是做一些鬼鬼祟祟的事情,诱使调度程序给你更多的资源份额。我们描述的算法容易受到以下攻击:在时间片结束之前,发出I/O操作(一些你不关心的文件),从而放弃CPU;这样做允许您保持在相同的队列中,从而获得更高的CPU时间百分比。

最后,程序可能会随着时间的推移而改变其行为;被称为CPU bound的作业可能会转型为一个交互作业。在我们目前的方法中,这样的工作是不幸的,不会像系统中的其他交互作业那样被对待。

尝试#2:优先级提升

让我们试着改变规则,看看是否能避免饥饿的问题。我们能做些什么来保证被cpu束缚的工作能够取得一些进展(即使进展不大?)
这里的简单想法是定期提高系统中所有作业的优先级。有很多方法可以实现这一点,但我们只需要做一些简单的事情:把它们都放到最上面的队列中;因此,有一个新规则:
•规则5:经过一段时间后,将系统中的所有作业移动到最上面的队列。、

我们的新规定同时解决了两个问题。首先,确保进程不会饿死:通过放置在最上面的队列中,作业将以循环方式与其他高优先级作业共享CPU,从而最终获得服务。其次,如果一个cpu绑定的作业变成了交互式的,调度程序在它获得优先级提升后就会正确地处理它。

当然,时间周期S的增加引出了一个显而易见的问题:S应该被设置为什么?John Ousterhout是一位颇受好评的系统研究员,他曾经在系统中使用voo-doo常量来调用这些值,因为它们似乎需要某种形式的黑魔法来正确地设置它们。不幸的是,S如果设定的过高,长期作业岗位可能会挨饿; 太低,交互作业可能得不到适当的CPU份额。

尝试3:更好的计算

我们现在还有一个问题要解决:如何防止调度程序的戏弄?正如你可能已经猜到的,真正的罪魁祸首是

规则4a和4b,通过放弃时间片到期之前的CPU。那么我们该怎么办呢?

这里的解决方案是在MLFQ的每个级别上更好地计算CPU时间。调度程序应该跟踪进程在给定级别上使用了多少时间片,而不是忘记它;一旦进程使用了它的分配,它就被降级到下一个优先队列。它是在一个长脉冲中使用时间片,还是在许多小脉冲中使用时间片,都无关紧要。因此,我们将规则4a和4b重写为以下单一规则:

•规则4:一旦一个任务在给定的级别上耗尽了它的时间分配(不管它放弃了多少次CPU),它的优先级就会降低(例如:,它向下移动一个队列)

优化MLFQ和其他问题

MLFQ调度还出现了一些其他问题。一个大问题是如何参数化这样的调度程序。例如,应该有多少个队列?每个队列的时间片应该有多大?为了避免饥饿和解释行为的变化,应该多久提高优先级?这些问题没有简单的答案,只有对工作负载和后续调度器调优的一些经验才能得到满意的平衡。

例如,大多数MLFQ变体允许跨不同队列改变时间片长度。高优先级队列通常是短时间片;毕竟,它们是由交互式作业组成的,因此在它们之间快速切换是有意义的。

Solaris MLFQ实现——分时调度类,或TS——特别容易配置;它提供了一组表,精确地确定进程的优先级在整个生命周期中是如何改变的,每个时间片有多长,以及提高作业优先级的频率有多高;管理员可以处理这个表,以便让调度器以不同的方式工作。表的默认值是60个队列,时间片长度从20毫秒(最高优先级)到几百毫秒(最低),优先级提升大约每1秒进行一次。

其他MLFQ调度器不使用本章描述的表或精确规则;相反,他们使用数学公式来调整优先级。
例如,FreeBSD调度器(版本4.3)使用一个公式来计算作业的当前优先级,它基于进程使用了多少CPU ;此外,随着时间的推移,使用量会逐渐减少,以不同于本文描述的方式提供所需的优先级提升。请参阅Epema的论文,以获得关于这种衰变使用算法及其特性的优秀概述。

最后,许多调度器还有一些您可能遇到的其他特性。例如,有些调度器为操作系统工作保留最高优先级;因此,典型的用户作业永远无法获得系统中最高级别的优先级。一些系统还允许一些用户建议来帮助设置优先级;例如,通过使用命令行实用程序nice,您可以增加或减少作业的优先级(稍微),从而增加或减少它在任何给定时间运行的机会。
 

MLFQ:总结

我们描述了一种称为多级调度的方法,反馈队列(MLFQ)。希望您现在可以看到为什么要这样做:它有多个队列,并使用反馈来确定给定作业的优先级。历史是它的指南:关注工作在一段时间内的表现,并相应地对待它们。

详细的MLFQ规则,分散在各处,复制在这里

•规则1:如果优先级(A) >优先级(B),则A运行(B不运行)。
•规则2:如果优先级(A) =优先级(B), A和B使用给定队列的时间片(量子长度)以循环方式运行。
•规则3:当作业进入系统时,它处于最高优先级(最高队列)。
•规则4:一旦一个任务在给定的级别上耗尽了它的时间分配(不管它放弃了多少次CPU),它的优先级就会降低(例如:,它向下移动一个队列)。
•规则5:经过一段时间后,将系统中的所有作业移动到最上面的队列。

MLFQ之所以有趣,是因为以下原因:它不是要求对作业的有先验知识,而是观察作业的执行情况,并相应地对其进行优先排序。通过这种方式,它成功地实现了两方面的优点:对于短时间运行的交互式作业,它可以提供出色的整体性能(类似于SJF/STCF),对于长时间运行的cpu密集型工作负载,它是相对公平的。因此,许多系统,包括BSD UNIX衍生物, Solaris,以及windowsnt和随后的Windows操作系统使用MLFQ的形式作为基本调度。



调度:比例共享

在本章中,我们将研究一种不同类型的调度器,称为比例共享调度器,有时也称为公平共享调度器。比例共享基于一个简单的概念:调度程序可能会尝试确保每个作业获得一定百分比的CPU时间,而不是优化周转或响应时间。

Waldspurger和Weihl 的研究中发现了比例共享调度的一个很好的早期例子,称为彩票调度; 当然,这个想法肯定是比较老的。基本的想法很简单:每隔一段时间,通过抽签来决定下一个进程应该运行哪个进程;应该更经常运行的流程应该有更多的机会赢得彩票。

基本概念:彩票代表您的份额

基础彩票调度有一个非常基本的概念:彩票,用于表示进程(或用户或其他)应该接收的资源的份额。进程拥有的票据的百分比表示它在系统资源中所占的份额

让我们看一个例子。假设有两个过程,A和B, A有75张票,而B只有25张。因此,我们想要的是A接收75%的CPU, B接收剩下的25%

彩票调度通过每隔一段时间(比如,每次抽一次)持有彩票来实现这种概率性(但不是决定性的)。持有彩票很简单:调度程序必须知道总共有多少张彩票(在我们的示例中,有100张)。调度程序随机选择中奖彩票,号码从0到99。假设A持有0到74和b75到99的彩票,中奖的彩票简单地决定了A还是B运行。然后调度程序加载获胜进程的状态并运行它。

在彩票调度中使用随机性会尽量满足所需比例的概率正确性,但不能保证。在上面的示例中,B只能运行20个时间片中的4个(20%),而不是所需的25%分配。然而,这两种工作竞争的时间越长,就越有可能达到预期的比例。

彩票机制:

彩票调度还提供了一些机制,以不同的,有时是有用的方式操纵彩票。一种方法是票券货币的概念。允许拥有一组彩票的用户在自己的作业中以任何货币分配彩票;然后系统自动将该货币转换为正确的全球价值。例如,假设用户A和B每人获得了100张彩票。用户A运行两个作业A1和A2,并给他们每人500张彩票(总共1000个)。用户B只运行一个作业,并给它10张彩票(总共10张)。系统会转换A1和A2的分配从A货币的500个到全球货币的50个;同样,B1的10张票将被转换为100张票。然后,彩票将在全球彩票货币(总计200张)之上进行,以确定运行哪一种工作。

User A -> 500 (A’s currency) to A1 -> 50 (global currency) -> 500 (A’s currency) to A2 -> 50 (global currency)

User B -> 10 (B’s currency) to B1 -> 100 (global currency)

另一个有用的机制是票据转让。通过传输,进程可以临时将其票据传递给另一个进程。这种能力在客户机/服务器设置中特别有用,在这种设置中,客户机进程向服务器发送一条消息,要求它代表客户机做一些工作。为了加快工作速度,客户机可以将票据传递给服务器,从而在服务器处理客户机请求时尽量提高服务器的性能。完成后,服务器将票据传输回客户端,一切如常。

最后,票价膨胀有时是一个有用的技巧。随着通货膨胀,一个过程可以暂时增加或减少它所拥有的彩票数量。当然,在一个相互不信任的竞争场景中,这没有什么意义;一个贪婪的进程可以给自己大量的票,并接管机器。相反,膨胀可以应用在一组进程相互信任的环境中;在这种情况下,如果任何一个进程知道它需要更多的CPU时间,它可以提高它的票证值,作为一种向系统反映这种需求的方式,不需要和任何其他进程通信。

实现

抽签调度最令人惊奇的地方可能是它实现的简单性。您所需要的只是一个选择中奖彩票的良好随机数生成器、一个跟踪系统进程(例如,一个列表)的数据结构和彩票总数。

假设我们将流程保存在一个列表中。下面是一个由三个进程组成的示例,A、B和C,每个进程都有一些票据。

代码遍历进程列表,将每个票据值添加到计数器中,直到值超过获胜者。一旦出现这种情况,当前列表元素就是赢家。在我们的中奖彩票为300的例子中,发生了以下事情。首先,计数器被增加到100来表示A的票;因为100小于300,所以循环继续。
然后计数器会更新到150张(B的票),仍然小于300张,因此我们继续。最后,counter被更新为400(显然大于300),这样我们就跳出了循环.

为了使这个过程更高效,通常最好是按照从最多的彩票到最低将作业排列。排序不影响算法的正确性;然而,它确实确保了列表迭代的最少次数,特别是当有少数几个进程拥有大部分的票据时。

 

stride调度

您可能还想知道:为什么要使用随机性呢?正如我们在上面看到的,虽然随机性让我们得到了一个简单的(并且近似正确的)调度程序,但它偶尔也不会提供完全正确的比例,特别是在短时间尺度上。因此,Waldspurger发明了stride调度,一种确定性公平共享调度。

步幅调度也很简单。系统中的每一项工作都有一个大步,这与它拥有的彩票数量成反比。在上面的示例中,使用job A、B和C,分别使用100、50和250张彩票,我们可以通过将一些大数字除以分配给每个进程的彩票数量来计算每个进程的跨步。例如,如果我们将10,000除以这些票券值,我们将得到以下A、B和C的stride值:100、200和40。我们称这个值为每个过程的步长;每次进程运行时,我们都会通过它的stride为它增加一个计数器(称为它的pass值),以跟踪它的全局进度。

然后,调度器使用stride和pass来确定接下来应该运行哪个进程。基本思想很简单:在任何给定的时间,选择运行到目前为止具有最低pass值的进程;当您运行一个进程时,通过它的stride来增加它的pass计数器。Waldspurger提供了一个伪代码实现.

current = remove_min(queue); // pick client with minimum pass

schedule(current); // use resource for quantum

current->pass += current->stride; // compute next pass using stride

insert(queue, current); // put back into the queue

在我们的示例中,我们从三个进程(A、B和C)开始,stride值为100、200和40,所有进程的初始值都为0。因此,首先,任何进程都可能运行,因为它们的pass值都一样低。假设我们选择A(任意;可以选择任何具有相同低pass的进程)。一个运行;完成时间片后,我们将其pass值更新为100。然后我们运行B,它的pass值设置为200。最后,我们运行C,它的pass值增加到40。此时,算法将选择最低的pass值,即C,再次运行(仍然是最低的pass值),将其pass提升到120。A现在会运行,更新它的pass到200(现在等于B)。然后C将再运行两次,将其pass更新为160,而不是200。在这一点上,所有的pass值都是相等的,这个过程将不断重复,直到无限。

从图中我们可以看到,C运行了5次,A运行了两次,B只运行了一次,刚好与它们的票面价值250,100和50成比例你,跨步调度在每个调度周期的末尾使它们完全正确。

因此,您可能想知道:考虑到跨步调度的精确性,为什么要使用彩票调度呢?抽签调度有一个很好的特性是跨步调度所没有的:没有全局状态。假设在上面的stride调度示例中有一个新任务进入;它的pass值应该是多少?应该设置为0吗?如果是这样,它将独占CPU。对于彩票调度,每个进程没有全局状态;我们只需要添加一个新进程,使用它所拥有的所有票据,更新单个全局变量来跟踪总共有多少张票据,然后从那里开始。通过这种方式,彩票调度可以更容易地以一种合理的方式整合新流程。

Linux完全公平调度程序(CFS)

尽管在公平共享调度中有这些早期工作,但是当前的Linux方法以另一种方式实现了类似的目标。被称为完全公平调度程序(或CFS) 的调度器以一种高效和可扩展的方式实现实现了公平共享调度。

为了实现其效率目标,CFS的目标是通过其固有的设计和对非常适合任务的数据结构的巧妙使用,花很少的时间来制定调度决策。最近的研究表明,调度器效率非常重要; 具体来说,在谷歌数据中心的研究中,Kanev等人表明,即使经过积极的优化,调度也会占用数据中心总用CPU时间的5%。因此,尽可能减少这种开销是现代调度器体系结构中的一个关键目标。

基本操作

虽然大多数调度器都是基于固定时间片的概念,但是CFS的操作有点不同。它的目标很简单:在所有竞争进程中平均分配CPU。它是通过一种简单的基于计数的技术,即虚拟运行时(vruntime)来实现的

当每个进程运行时,它会累积vruntime值。在最基本的情况下,每个进程的vruntime以相同的速度增长,与物理(实际)时间成比例。当发生调度决策时,CFS将选择运行vruntime值最低的进程。

这提出了一个问题:调度程序如何知道何时停止当前正在运行的进程,并运行下一个进程?这里的矛盾很明显:如果CFS频繁切换,公平性就会增加,因为CFS将确保每个进程在极短的时间内都能接收到CPU的份额,但代价是性能(太多的上下文切换);如果CFS更少地切换,性能就会提高(减少上下文切换),但这是以牺牲近期的公平性为代价的。

CFS通过各种控制参数来控制这种张力。首先是sched latency.。CFS使用这个值来确定一个进程在考虑切换(以动态方式有效地确定其时间片)之前应该运行多长时间。典型的sched latency是48(毫秒);CFS将这个值除以CPU上运行的进程的数量(n),以确定进程的时间片,从而确保在这段时间内CFS是完全公平的。

例如,如果有n = 4个进程在运行,那么CFS将sched latency延迟值除以n,得到每个进程的时间片为12毫秒。然后,CFS调度第一个作业并运行它,直到它使用了12ms(虚拟)运行时,然后检查是否有一个vruntime值较低的作业需要运行。在这种情况下,CFS会切换到其他三个工作中的一个,以此类推。下图显示了一个示例,其中四个作业(A、B、C、D)以这种方式分别运行两个时间片;其中两个(C, D)完成,只剩下两个,然后每个循环运行24毫秒。

但是,如果有“太多”的进程在运行呢?这会不会导致时间片太小,从而导致太多的上下文切换?
好问题!答案是肯定的。

为了解决这个问题,CFS增加了另一个参数,最小粒度,通常设置为6毫秒。CFS永远不会将进程的时间片设置为小于此值,从而确保不会在调度开销上花费太多时间。

例如,如果有10个进程在运行,我们的原始计算将sched延迟除以10来确定时间片(结果:4.8 ms)。但是,由于最小粒度,CFS将每个进程的时间片设置为6ms。尽管CFS不会(相当)完全公平地超过48毫秒的目标调度延迟(sched latency),它将接近,同时仍然达到高CPU效率。

注意,CFS使用周期性的计时器中断,这意味着它只能在固定的时间间隔做出决定。这个中断频繁地进行(例如,每1毫秒一次),让CFS有机会清醒过来,判断当前的工作是否已经结束。如果作业的时间片不是计时器中断间隔的完美倍数,那也可以, 从长远来看,它最终将近似理想的CPU共享。

加权

CFS还支持对进程优先级的控制,允许用户或管理员为某些进程提供更高的CPU份额。它不是通过票据实现的,而是通过一种经典的UNIX机制来实现的,这种机制被称为进程的nice级别。nice参数可以从设置为-20到+19,默认值为0。奇怪的是,正的值表示优先级较低,负的值表示优先级较高。

CFS将每个进程的良好值映射到一个权重,如下所示:

static const int prio_to_weight[40] = {

/* -20 */ 88761, 71755, 56483, 46273, 36291,

/* -15 */ 29154, 23254, 18705, 14949, 11916,

/* -10 */ 9548, 7620, 6100, 4904, 3906,

/* -5 */ 3121, 2501, 1991, 1586, 1277,

/* 0 */ 1024, 820, 655, 526, 423,

/* 5 */ 335, 272, 215, 172, 137,

/* 10 */ 110, 87, 70, 56, 45,

/* 15 */ 36, 29, 23, 18, 15, };

这些权值允许我们计算每个过程的有效时间片(正如我们之前所做的),但现在考虑到他们的优先级差异。
这样做的公式如下:

让我们做一个例子来看看它是如何工作的。假设有两份工作,A和B.  A因为是我们最宝贵的工作,被给予了更高的优先级。B,因为我们讨厌它,所以将它赋值为-5;这意味着weightA(从表中)为3121,而weightB为1024。如果你计算每个作业的时间片,你会发现A的时间片大约是3/4的sched延迟(因此,36ms), B大约是1/4(12 ms)。

除了时间片计算之外,CFS计算vruntime的方法也必须进行调整。这是一个新的公式,它使用进程积累的实际运行时间(runtimei),并以进程的权重为反比进行缩放。在我们的运行示例中,A的vruntime值的累计是B的三分之一。

构建上面的权重表的一个聪明的方面是,当nicw值的差异是常量时,表保持了CPU的比例比。例如,如果进程A有一个nice值5(不是-5),而进程B有一个nice值10(不是0),那么CFS将以完全相同的方式安排它们。你自己把数学算一遍,看看为什么.

使用“红-黑”树

如上所述,CFS的一个主要焦点是效率。对于调度器,有许多效率的方面,但其中一个方面非常简单:当调度器必须找到下一个要运行的作业时,它应该尽可能快地完成。像列表这样的简单数据结构无法扩展:现代系统有时由1000多个进程组成,因此每隔这么多毫秒就搜索一个长列表是浪费的。

CFS通过在红黑树中保存进程来解决这个问题。红黑树是多种平衡树之一;与简单的二叉树(在最坏情况下插入模式下会退化为列表式的性能)相比,平衡树要做一些额外的工作来保持较低的深度,从而确保操作是对数的(而且不是线性的)时间。

CFS并不是将所有过程都保留在这个结构中;相反,只运行(或可运行的)进程保存在其中。如果进程进入休眠状态(比如等待I/O完成,或者等待网络数据包到达),它将从树中删除,并跟踪其他位置。

让我们看一个例子,让它更清楚。假设有10个作业,它们有以下vruntime值:1、5、9、10、14,18, 17 ,21 ,22 ,24。如果我们将这些作业保持在有序列表中,那么查找下一个要运行的作业将很简单:只删除第一个元素。然而,当将该工作重新放入列表(按顺序)时,我们必须进行扫描寻找合适的插入点,一个O(n)操作。任何搜索都是非常低效的,平均花费线性时间。

在红黑树中保存值可以提高大多数操作的效率,如图所示。vruntime在树中对进程进行排序,大多数操作(如插入和删除)都是时间对数的,即当n是千位时,对数的效率明显高于线性。

处理I/O和睡眠过程

选择最低的vruntime以供下一个运行,其中一个问题出现在已经休眠了很长一段时间的作业中。想象两个过程, A和B,其中一个(A)连续运行,另一个(B)睡眠时间较长(比如10秒)。当B会醒来,它的vruntime会比A晚10秒,因此(如果我们不小心的话),B会在接下来的10秒内独占CPU,实际上会让A挨饿。

CFS通过在作业苏醒时更改其vruntime来处理这种情况。具体来说,CFS将该作业的vruntime设置为树中找到的最小值(记住,树只包含正在运行的作业),通过这种方式,可以避免挨饿,但也不是没有代价:睡眠时间短而且频繁的的作业往往得不到应有的CPU份额。

CFS其他

CFS还有许多其他特性,在本书的这一点上讨论的特性太多了。它包括许多提高缓存性能的启发式方法、有效地处理多个cpu的策略(如本书后面讨论的)、可以跨过组进程(而不是将每个进程视为独立的实体)以及许多其他有趣的特性。
阅读最近的研究,了解更多。

总结

我们介绍了比例共享调度的概念,并简要讨论了三种方法:彩票调度、跨步调度和Linux的完全公平调度(CFS)。彩票以一种巧妙的方式利用随机性来实现比例份额;而stride则是决定性的。CFS是本章中讨论的惟一的“真正的”调度程序,它有点像带有动态时间片的加权循环,但是它的构建是为了在负载下进行扩展和执行;据我们所知,它是目前使用最广泛的公平共享调度器。

没有调度程序是万能的,公平共享的调度程序也有自己的问题。一个问题是,这种方法与I/O 不太匹配;如上所述,偶尔执行I/O的作业可能无法获得它们应有的CPU份额。另一个问题是,他们留下了彩票或优先分配的难题,即,你如何知道你的浏览器应该分配多少张票,或者设置文本编辑器的值是多少?其他通用的调度器(例如我们前面讨论过的MLFQ,以及其他类似的Linux调度器)自动处理这些问题,因此更容易部署。

好消息是,在许多领域中,这些问题并不是主要关注的问题,而且比例共享调度器的使用效果非常好。例如,在虚拟数据中心(或云)中,您可能希望将四分之一的CPU周期分配给Windows VM,其余的分配给基本的Linux安装,比例共享可以简单有效。这个想法也可以扩展到其他资源;有关如何在VMWare的ESX服务器中按比例共享内存的详细信息,请参阅Waldspurger 。



多处理器调度



抽象:地址空间

在早期,建立计算机系统很容易。为什么?因为用户并没有期望太多。正是这些该死的用户对“易用性”、“高性能”、“可靠性”等的期望导致了这些令人头痛的问题。

早期的系统

从内存的角度来看,早期的机器并没有为用户提供太多的抽象。基本上,机器的物理内存类似于下图所示。

操作系统是一组在内存的程序(在这个例子中从物理地址0开始), 有一个运行的程序(进程),目前在物理内存中(物理地址从开始64 k在本例中)。

多进程设计和时间共享

过了一段时间,因为机器很贵,人们开始更有效地共享机器。这样,多进程设计的时代就诞生了。多个进程准备在给定时间运行,操作系统将在它们之间切换,例如, 当一个进程决定执行I/O操作时。这样做提高了CPU的有效利用率。在那些每台电脑都要花费数十万甚至数百万美元的年代,这样的效率提升尤其重要(你认为你的Mac电脑很贵?)

然而,很快,人们开始对机器有更多的要求,时间共享的时代诞生了。特别地,许多人意识到批处理计算的局限性,特别是程序员,他们厌倦了冗长程序调试周期。交互性的概念变得很重要,因为许多用户可能同时在使用一台机器,每个用户都在等待(或希望)当前正在执行的任务的及时响应。

一个方法来实现时间共享是运行一个进程是很短的一段时间,  使它完全访问所有内存,然后停止, 保存所有的状态到磁盘(所有物理内存),加载其他进程的状态,运行一段时间,从而实现某种粗糙的共享机制。

不幸的是,这种方法有一个大问题:它太慢了,尤其是随着内存的增长。虽然保存和恢复寄存器级状态(PC机、通用寄存器等)比较快,但是将内存的全部内容保存到磁盘上是非常低性能的。因此,我们宁愿将进程留在内存中,同时在它们之间切换,从而允许操作系统高效地实现时间共享。

在图中,有三个进程(A、B和C),每个进程都有为它们划分的512KB物理内存的一小部分。假设只有一个CPU,操作系统选择运行其中一个进程(例如,A),而其他(B和C)则在等待运行的就绪队列中。

随着时间共享变得越来越流行,您可能会猜测操作系统将面临新的需求。特别地,允许多个程序同时驻留在内存中使保护成为一个重要问题;   您不希望进程能够读取,甚至写入其他进程的内存。

地址空间

这样做需要操作系统创建一个易于使用的物理内存抽象。我们将这个抽象称为地址空间,它是系统中运行程序的内存视图。理解这个基本的操作系统对内存的抽象是理解内存是如何虚拟化的关键。

进程的地址空间包含运行程序的所有内存状态。例如,程序的代码(指令)必须驻留在内存中的某个地方,因此它们位于地址空间中。程序在运行时,使用栈跟踪它在函数调用链中的位置,分配局部变量,向进程传递参数和返回值。最后,堆用于动态分配的、用户管理的内存,例如您可能从C语言中的malloc()调用接收到的内存。现在让我们假设这三个组件:代码、栈和堆。

在图的示例中,我们有一个很小的地址空间(只有16 kb)。程序代码位于地址空间的顶部(在本例中从0开始,并被挤满地址空间的第1K位中)。代码是静态的(因此很容易放在内存中),因此我们可以将它放在地址空间的顶部,并且知道在程序运行时它不需要更多的空间。

接下来,我们关注程序运行时可能增长(和收缩)的两个地址空间区域,这些是堆(在顶部)和栈(在底部)。我们这样放置它们是因为希望它们能够增长,通过将它们放在地址空间的两端,我们可以允许这样的增长:它们只需要向相反的方向增长。因此,堆在代码之后开始(1KB),然后向下增长(例如,当用户通过malloc()请求更多内存时);堆栈从16KB开始向上增长(例如当用户进行过程调用时)。然而,这种堆栈和堆的位置只是一种约定;如果您愿意,您可以用另一种方式来安排地址空间(稍后我们将看到,当多个线程共存于一个地址空间时,这样划分地址空间的方法不再适用了)。

当然,当我们描述地址空间时,我们描述的是操作系统提供给运行中的程序的抽象。在物理地址0到16KB之间,程序确实不在内存中;而是在任意物理地址加载。

当操作系统这样做时,我们说操作系统正在虚拟化内存,因为运行中的程序认为它被加载到特定地址的内存中(比如0)并且有一个可能非常大的地址空间(比如32位或64位); 但现实是完全不同的。

例如,当进程A试图执行一个负载在地址0(我们将称之为虚拟地址),操作系统,和一些硬件支持,必须确保负载实际上并不去物理地址0而是去物理地址320KB(其中A被加载到内存中)。这是内存虚拟化的关键,它是世界上所有现代计算机系统的基础。

目标

操作系统虚拟内存的一个目标是应该以运行程序看不到的方式实现虚拟内存。因此,程序不应该意识到内存是虚拟化的事实;相反,程序的行为就好像它有自己的私有物理内存一样。在幕后,操作系统(和硬件)完成了在许多不同的任务之间复用内存的所有工作,并因此实现了这种错觉。

VM的另一个目标是效率。操作系统应该努力使虚拟化尽可能地高效,无论是在时间上(即在时间上不让程序运行变得慢得多)和空间上(例如,不需要为支持虚拟化所需的结构使用太多内存)。在实现时间效率高的虚拟化时,操作系统将不得不依赖于硬件支持,包括硬件特性,比如TLBs(我们将在后面学到)。

最后,第三个VM目标是保护。操作系统应该确保保护各个进程,以及操作系统本身不受其他进程的影响。当一个进程执行加载、存储或指令取回时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即,地址空间之外的任何内容)。因此,保护使我们能够有进程间隔离的属性;每个进程都应该在自己单独的茧中运行,以避免受到其他错误甚至恶意进程的破坏。

在下一节中,我们将重点探讨虚拟化内存所需的基本机制,包括硬件和操作系统支持。我们还将研究在操作系统中遇到的一些更相关的策略,包括如何管理空闲空间,以及在空间不足时应该删除哪些页面。在此过程中,我们将帮助您理解现代虚拟内存系统是如何工作的。我们已经看到了一个主要操作系统子系统的引入:虚拟内存。VM系统负责向程序提供一个大的、稀疏的、私有的地址空间的假象,程序在这个空间中保存所有的指令和数据。在一些硬件的帮助下,操作系统将获取这些虚拟内存引用,并将它们转换为物理地址,以获取所需的信息。操作系统将同时对多个进程执行此操作,确保保护程序,以及保护操作系统。整个方法需要大量的机制(大量的底层机制)以及一些关键的策略才能起作用。


机制:地址转换

在开发CPU的虚拟化时,我们关注于一种通用机制,即有限直接执行(LDE)。LDE背后的想法很简单:在大多数情况下,让程序直接在硬件上运行;然而,在某些关键时刻(例如当进程发出系统调用或计时器中断发生时),安排操作系统参与进来并确保“正确”的事情发生。因此,有着硬件支持的操作系统,会尽力避开正在运行的程序,提供高效的虚拟化; 但是,通过及时插入这些临界点,操作系统确保了对硬件的控制。效率和控制是现代操作系统的两个主要目标。

虚拟化内存中,我们将采用类似的策略,在提供所需的虚拟化的同时实现效率和控制。效率要求我们使用硬件支持,硬件支持一开始是很基本的(例如,只有几个寄存器),但是随后会变得相当复杂(例如,TLBs、页表支持等等,您将看到)。控制意味着操作系统确保应用程序不允许访问任意内存;因此,为了保护应用程序不受其他应用程序和操作系统不受应用程序的影响,我们还需要硬件的帮助。最后,我们需要从VM系统中获得更多的灵活性; 具体来说,我们希望程序能够以任何方式使用它们的地址空间,从而使系统更易于编程。

我们将使用的通用技术(您可以将其看作是对有限直接执行的一般方法的补充),被称为基于硬件的地址转换,或者简称为地址转换。通过地址转换,硬件将转换每个内存访问(例如,指令获取、加载或存储),将指令提供的虚拟地址更改为所需信息所在实际物理地址。因此,在每个内存引用上,地址转换由硬件执行,将应用程序内存引用重定向到它们在内存中的实际位置。、

当然,硬件本身不能虚拟化内存,因为它只是提供了一种低级机制来有效地实现这一点。操作系统必须在关键时刻介入,以设置硬件,以便进行正确的转换;  因此,它必须管理内存,跟踪哪些位置是空闲的,哪些位置正在使用,并明智地介入以保持对内存使用方式的控制。

假设

我们对虚拟化内存的第一次尝试将非常简单。具体来说,我们现在假设用户的地址空间必须连续地放在物理内存中。为了简单起见,我们还将假设地址空间的大小不太大;具体来说,它小于物理内存的大小。最后,我们还假设每个地址空间的大小完全相同。不要担心这些假设是否不切实际;   我们将逐渐放宽它们,从而实现内存的实际虚拟化。

一个例子

为了更好地理解实现地址转换需要做什么,以及为什么需要这样的机制,让我们看一个简单的例子。
假设有一个进程,其地址空间如图所示,我们在这里要研究的是一个简短的代码序列,它从内存中加载一个值,将其加3,然后将值存储回内存中。您可以想象这个代码的c语言表示可能是这样的:

void func() {

        int x = 3000; // thanks, Perry.

        x = x + 3; // this is the line of code we are interested in

        ...

编译器将这一行代码转换为汇编,它看起来可能像这样(在x86程序集中)。

movl 0x0(%ebx), %eax ;load 0+ebx into eax 

addl $0x03, %eax ;add 3 to eax register 

movl %eax, 0x0(%ebx) ;store eax back to mem

这个代码片段相对简单;它假定x的地址已经放在寄存器ebx中,然后使用movl指令将该地址处的值加载到通用寄存器eax中
(“longword”移动)。下一条指令向eax添加3,最后一条指令将eax中的值存储回位于同一位置的内存中。

观察在进程的地址空间中,代码和数据是如何排列的;三条指令代码序列位于地址128(在靠近顶部的代码部分),变量x位于地址15kb(在靠近底部的堆栈中)。在图中,x的初始值是3000,如其在堆栈上的位置所示。

当这些指令运行时,从进程的角度来看,会发生以下内存访问:

•在地址128处获取指令
           •执行此指令(从地址15kb加载)
           •在地址132获取指令
           •执行此指令(无内存引用)
           •在地址135处取指令
           •执行此指令(存储地址为15kb)

从程序的角度来看,它的地址空间从地址0开始,最大增长到16kb;它生成的所有内存引用都应该在这些范围内。然而,为了虚拟化内存,操作系统希望将进程放在物理内存中的其他位置,而不是地址0。因此,我们有一个问题:如何以对进程透明的方式在内存中重新定位这个进程?当实际地址空间位于其他物理地址时,我们如何提供虚拟地址空间从0开始的假象?

动态(基于硬件)重定位

为了对基于硬件的地址转换有所了解,我们将首先讨论它的第一个版本。在20世纪50年代末的第一批分时机器中引入了一个简单的概念,称为基数和界限;这种技术也被称为动态重定位;我们将交替使用这两个术语。

具体来说,我们在每个CPU中需要两个硬件寄存器:一个称为基寄存器,另一个称为界限寄存器(有时称为限制寄存器)。这个基础和边界对将允许我们将地址空间放在物理内存中任何我们想要的地方,并确保进程只能访问自己的地址空间。

在这个设置中,每个程序都被编写和编译,就像在地址0处加载一样。然而,当程序开始运行时,操作系统决定在物理内存中加载它的位置,并将基寄存器设置为该值。在上面的示例中,操作系统决定在物理地址32 KB处加载进程,从而将基本寄存器设置为这个值。当进程运行时,有趣的事情开始发生。现在,当进程生成任何内存引用时,处理器会以以下方式对其进行翻译:

physical address = virtual address + base

进程生成的每个内存引用都是一个虚拟地址;硬件依次将基寄存器的内容添加到这个地址,结果是一个可以发送到内存系统的物理地址。

为了更好地理解这一点,让我们跟踪执行单个指令时会发生什么。具体地说,让我们看看前面序列中的一条指令.

128: movl 0x0(%ebx), %eax

程序计数器(PC)设置为128;当硬件需要获取此指令时,它首先将该值添加到基寄存器值32 KB(32768),以获得物理地址32896;然后硬件从物理地址获取指令。接下来,处理器开始执行指令。在某个时候,进程会从虚拟地址15 KB发出负载,处理器再一次将其添加到基本寄存器(32 KB),从而得到最终的物理地址47 KB,得到需要的内容。

将虚拟地址转换为物理地址正是我们所说的地址转换技术;也就是说,硬件接受进程认为它正在引用的虚拟地址,并将其转换为物理地址,而物理地址是数据实际驻留的地方。由于地址的重新定位发生在运行时,而且我们甚至可以在进程开始运行之后移动地址空间,因此这种技术通常被称为动态重新定位。

现在你可能会问:那个界限(限制)寄存器发生了什么?毕竟,这不是基址和边界方法吗?事实上,它是。正如您可能已经猜到的,边界寄存器是用来帮助保护的。具体来说,处理器将首先检查内存引用是否在范围内,以确保它是合法的;在上面的简单示例中,边界寄存器总是设置为16kb。如果一个进程生成一个大于边界的虚拟地址,或者一个为负的地址,那么CPU将引发异常,进程可能会终止。

我们应该注意到,基寄存器和边界寄存器是保存在芯片上的硬件结构(每个CPU一对)。有时人们把帮助地址转换的处理器部分称为内存管理单元(MMU);随着我们开发更复杂的内存管理技术,我们将向MMU添加更多的电路。。

硬件支持:总结

现在让我们总结一下我们需要从硬件中得到的支持。操作系统以特权模式(或内核模式)运行,它可以访问整个计算机;应用程序在用户模式下运行,在这种模式下,它们只能做有限的事情。单个位(可能存储在某种处理器状态字中)表示CPU当前运行的模式; 在某些特殊情况下(例如,系统调用或其他类型的异常或中断),CPU切换从用户模式切换到特权模式。

硬件本身还必须提供基寄存器和边界寄存器;因此,每个CPU都有一对额外的寄存器,它们是CPU的内存管理单元(MMU)的一部分。当用户程序运行时,硬件将通过向用户程序生成的虚拟地址添加基值来转换每个地址。硬件还必须能够检查地址是否有效,这是通过使用边界寄存器和CPU中的一些电路来完成的。

硬件应该提供修改基本寄存器和边界寄存器的特殊指令,允许操作系统在不同进程运行时更改它们。这些指示是有特权的;只有在内核(或特权)模式下才能修改寄存器。

最后,CPU必须能够在用户程序试图非法访问内存(地址是“越界的”)时生成异常;在这种情况下,CPU应该停止执行用户程序,并安排OS“越界”异常处理程序运行。然后操作系统处理程序可以找出如何响应,在这种情况下,可能会终止进程。类似地,如果用户程序试图更改(特权)基寄存器和界限寄存器的值,CPU应该引发异常并运行“在用户模式下试图执行特权操作”。CPU还必须提供一种方法来通知它这些处理器的位置;因此需要更多的特权指令。

操作系统问题

正如硬件提供了支持动态重定位的新特性一样,操作系统现在也有了它必须处理的新问题;硬件支持和操作系统管理的结合导致了简单虚拟内存的实现。具体来说,有几个关键的节点,操作系统必须参与其中,以实现我们的虚拟内存基于基址和边界的版本。

首先,操作系统必须在创建进程时采取行动,在内存中为其地址空间找到空间。幸运的是,假设每个地址空间小于物理内存的大小,以及每个地址空间同样大小,这对操作系统来说相当容易;它可以简单地将物理内存看作一个插槽数组,并跟踪每个插槽是空闲的还是正在使用的。在创建新进程时,操作系统必须搜索数据结构(通常称为空闲列表),以便为新地址空间找到空间,然后把它标记为已经使用。使用可变大小的地址空间,问题会变得更加复杂, 我们将把这个问题留给以后。

让我们看一个例子。在上图中,您可以看到操作系统使用了物理内存的第一个插槽,并且它已经将上面示例中的进程从物理内存地址32 KB开始重新定位到插槽中。另外两个插槽是空闲的(16kb - 32kb和48kb-64KB);因此,空闲列表应该包含这两个条目。

第二,当进程终止时,操作系统必须做一些工作(当它优雅地退出,或者因为行为不当而强制终止),回收它的所有内存,以便在其他进程或操作系统中使用。因此,在进程终止时,OS将其内存放回空闲列表,并根据需要清理任何关联的数据结构

第三,当发生上下文切换时,操作系统还必须执行一些额外的步骤。毕竟每个CPU上只有一个基寄存器和一个边界寄存器对,它们的值对于每个运行的程序是不同的,因为每个程序在内存中以不同的物理地址加载。因此,当操作系统切换为-时,必须保存和恢复基址与边界对。

第四,操作系统必须提供异常处理程序,或如上所述需要调用的函数;操作系统在引导时安装这些处理程序(通过特权指令)。例如,如果一个进程试图在它的边界之外进行访问,CPU就会抛出异常;当出现这样的异常时,操作系统必须准备好采取行动。操作系统可能会终止违规过程。



分段

到目前为止,我们已经将每个进程的全部地址空间放入内存中。使用基寄存器和边界寄存器,操作系统可以轻松地将进程重新分配到物理内存的不同部分。但是,您可能已经注意到这些地址空间的一些有趣之处:在堆栈和堆之间的中间有一大块“空闲”空间

正如您可以想象到的,尽管进程没有使用堆栈和堆之间的空间,但是当我们将整个地址空间重新定位到物理内存中的某个位置时,它仍然占用物理内存;因此,使用基寄存器和边界寄存器对来虚拟化内存的简单方法是浪费内存。当整个地址空间无法装入内存时,这也使得运行程序非常困难,不像我们想的那样灵活。因此:

关键是:如何支持大的地址空间。我们如何支持在堆栈和堆之间(可能)有大量空闲空间的大地址空间?注意,在我们的示例中,使用很小的(假装的)地址空间,浪费看起来并不太严重。然而,想象一下,32位地址空间(4 GB大小);一个典型的程序将只使用兆字节的内存,但仍然要求整个地址空间驻留在内存中。

为了解决这个问题,一个想法诞生了,叫做分段。这是一个相当古老的想法,至少可以追溯到很早以前。这个想法很简单:与其在MMU中只有一个基和界对,为什么不在每个地址空间的逻辑段中都有一个基和界对呢?段只是特定长度的地址空间的连续部分,在我们的规范中,我们有三个逻辑不同的部分:代码、堆栈和堆。分段允许操作系统做的是将这些段中的每个段放在物理内存的不同部分,从而避免用未使用的虚拟地址空间填充物理内存。

下面的图显示了示例的寄存器值;每个边界寄存器保存段的大小。

让我们做一个示例转换。假设对虚拟地址100(在代码段中)进行了引用。当引用发生时(例如在指令获取时),硬件将向这个段(在本例中为100)的偏移量添加基值,以达到所需的物理地址:100 + 32KB,或32868。然后它将检查地址是否在范围内(100小于2KB),找到它,并发出对物理内存地址32868的引用。

现在让我们看看堆中的一个地址,虚拟地址4200。如果我们将虚拟地址4200添加到堆的底部(34KB),就会得到一个物理地址39016,这不是正确的物理地址。我们首先需要做的是将偏移量提取到堆中,即,地址在此段中所引用的字节。因为堆从虚拟地址4KB(4096)开始,所以4200的偏移量实际上是4200减去4096,也就是104。然后,我们使用这个偏移量(104)并将其添加到基寄存器物理地址(34K)中,以得到所需的结果:34920。

如果我们试图引用一个非法地址,比如超过堆末尾的7KB,会怎么样?您可以想象会发生什么:硬件检测到地址是越界的,可能导致违规进程的终止。现在您知道了所有C程序员都害怕的一个著名术语的起源:segmentation fault。

我们指的是哪一个段?

硬件在转换过程中使用段寄存器。它如何知道偏移到一个段的偏移量,地址指向哪个段?一种常见的方法,有时被称为显式方法,是根据虚拟地址的最上面的几个位将地址空间分割;该技术在VAX/VMS系统中得到了应用。在上面的例子中,我们有三个部分;因此,我们需要两个比特位来完成我们的任务。如果我们使用14位虚拟地址的前两位来选择段,我们的虚拟地址看起来是这样的:

在我们的示例中,如果前两位是00,硬件知道虚拟地址在代码段中,因此使用代码基和边界对将地址重新定位到正确的物理位置。如果前两位是01,硬件知道地址在堆中,因此使用堆基和边界。让我们以上面的示例堆虚拟地址(4200)为例转换它,以确保这是清晰的。虚拟地址4200,以二进制形式,如下所示:

从图中可以看到,最上面的两位(01)告诉硬件我们指的是哪个部分。下面的12位是段的偏移量:0000 0110 1000,或者十六进制0x068,或者十进制的104。因此,硬件简单地使用前两个位来确定使用哪个段寄存器,然后使用后面的12位作为段的偏移量。通过向偏移量添加基寄存器,硬件到达最终物理地址。

您可能还注意到,当我们使用最上面的两个位时,我们只有三个段(代码、堆、栈),地址空间的一个段没有使用。因此,一些系统将代码放在与堆相同的段中,因此只使用一位来选择使用哪个段。

硬件还有其他方法来确定特定地址所在的段。在隐式方法中,硬件通过注意地址是如何形成的来确定段。例如,如果地址是由程序计数器生成,则地址在代码段内;  如果地址是基于栈指针,它必须在栈段;   任何其他地址都必须在堆中。

关于栈

到目前为止,我们遗漏了地址空间的一个重要组件:栈。在上面的图中,栈已经被重新定位到物理地址28KB,但是有一个关键的区别: 它向后增长。在物理内存中,它从28KB开始增长到26KB,对应于虚拟地址16KB到14KB;转化必须以不同的方式进行。

我们需要的第一件事是额外的硬件支持。硬件还需要知道段的增长方式,而不仅仅是基值和界限值(例如,当段向正方向增长时,它被设置为1,而为负时为0)。

随着硬件理解到段可以在负方向上增长,硬件现在必须以稍微不同的方式转换这些虚拟地址。让我们举一个栈虚拟地址的例子。

在本例中,假设我们希望访问虚拟地址15KB,它应该映射到物理地址27KB。我们的二进制虚拟地址是这样的:11 1100 0000 0000(十六进制0x3C00)。硬件使用最上面的两个位(11)来指定段,但是我们只剩下3KB的偏移量。为了得到正确的负偏移量,我们必须从3KB中减去最大的段大小:在这个例子中,一个段可以是4KB,因此正确的负偏移量是3KB - 4KB,等于-1KB。我们只需将负偏移量(-1KB)添加到基上(28KB)得到正确的物理地址:27KB。边界检查可以通过确保负偏移的绝对值小于段的大小来计算。

支持共享

随着对分段的支持的增加,系统设计人员很快意识到他们可以通过更多的硬件支持来实现新的效率类型。具体来说,为了节省内存,有时在地址空间之间共享某些内存段是很有用的。特别是,代码共享是很常见的,现在仍然在系统中使用

为了支持共享,我们需要一些来自硬件的额外支持,以保护位的形式。基本支持为每个段添加一些位,指示程序是否可以读写段,或者执行段内的代码。通过将代码段设置为只读,可以在多个进程之间共享相同的代码,而不必担心会损害隔离性;当每个进程仍然认为它正在访问自己的私有内存时,操作系统正在秘密地共享进程无法修改的内存,从而保持了这种错觉。

硬件跟踪的附加信息示例,如您所见,代码段被设置为读取和执行,因此内存中的相同物理段可以映射到多个虚拟地址空间。

对于保护位,前面描述的硬件算法也必须改变。除了检查虚拟地址是否在范围内之外,硬件还必须检查是否允许特定的访问。如果用户进程试图写入只读段,或从非可执行段执行,硬件应该引发异常,从而让操作系统处理出错的进程。

细粒度分段和粗粒度分段

到目前为止,我们的大多数示例都集中在只有几个部分的系统(即,代码,堆栈,堆);我们可以将此分割视为粗粒度的,因为它将地址空间分割成相对较大的粗块。然而,一些早期系统更加灵活,允许地址空间由大量更小的段组成,称为细粒度分段

支持许多段甚至需要进一步的硬件支持,在内存中存储某种类型的段表。这种段表通常支持创建大量段,从而使系统能够以比我们目前讨论的更灵活的方式使用段。例如,像Burroughs B5000这样的早期机器支持数千个段,并且期望编译器将代码和数据分割成单独的段,然后操作系统和硬件将支持这些段。当时的想法是,通过拥有细粒度的片段,操作系统可以更好地了解哪些片段在使用,哪些没有使用,从而更有效地利用主内存。

OS支持

你现在应该对分段是如何工作有一个基本的认识。当系统运行时,地址空间的片段被重新分配到物理内存中,因此相对于我们简单的方法,只需对整个地址空间使用一个基/界对,就可以节省大量的物理内存。具体来说,栈和堆之间所有未使用的空间都不需要在物理内存中分配,这样我们就可以在物理内存中容纳更多的地址空间。

然而,分段带来了许多问题。第一个:操作系统在上下文切换时应该做什么?您现在应该有一个很好的猜测:段寄存器必须被保存和恢复。显然,每个进程都有自己的虚拟地址空间,操作系统必须确保在再次运行进程之前正确设置这些寄存器。

第二个,也是更重要的问题是管理物理内存中的空闲空间。创建新地址空间时,操作系统必须能够在物理内存中为其段找到空间。在此之前,我们假设每个地址空间大小相同,因此物理内存可以被看作是一堆插槽,进程可以放在这些插槽中。现在,每个进程有很多段,每个段的大小可能不同。

出现的一般问题是物理内存很快就充满了自由空间的小洞,使得分配新内存段或扩展现有内存段变得困难。我们称这个问题为外部碎片化

在这个示例中,出现了一个进程,希望分配一个20KB的段。在这个示例中,有24KB的空闲内存,但不是在一个连续的段中(而是在三个非连续的块中)。因此,OS不能满足20KB的请求。

这个问题的一个解决方案是通过重新排列现有的段来压缩物理内存。例如,操作系统可以停止任何正在运行的进程,将它们的数据复制到一个连续的内存区域,更改它们的段寄存器值以指向新的物理位置,从而有一个大的可用内存范围。通过这样做,操作系统可以使新的分配请求成功。然而,压缩是昂贵的,因为复制段是内存密集型的,并且通常使用相当多的处理器时间。

一种更简单的方法是使用一种自由列表管理算法,它试图保持大范围的内存可用来分配。实际上,人们已经使用了数百种方法,包括经典算法best-fit(它保留一个空闲空间列表,并返回一个大小最接近的、满足请求者所需分配的空间)、最差拟合、优先拟合,以及更复杂的方案,如buddy算法。有这么多不同的算法试图最小化外部碎片,这一事实表明了一个更强大的潜在真理:没有一种“最佳”的方法来解决问题。因此,我们满足于合理的东西,并希望它足够好。唯一真正的解决方案(正如我们将在即将到来的章节中看到的那样)是完全避免这个问题,永远不要在可变大小的块中分配内存。

总结

分段解决了许多问题,并帮助我们构建更有效的内存虚拟化。除了动态重新定位之外,通过避免地址空间的逻辑段之间潜在的内存浪费,分段可以更好地支持稀疏地址空间。附带的好处是:代码共享。如果代码放在一个单独的段中,这样的段可能会在多个运行的程序之间共享。

然而,正如我们所了解的,在内存中分配可变大小的段会导致一些我们想要克服的问题。如上所述,第一个是外部碎片化。因为段是可变的,所以空闲内存被分割成大小不同的块,因此要满足内存分配请求是很困难的。人们可以尝试使用智能算法]或定期压缩内存,但这个问题是很难避免。

第二个更重要的问题是,分段仍然不够灵活,不足以支持我们完全通用的稀疏地址空间。例如,如果我们在一个逻辑段中有一个大型但很少使用的堆,那么整个堆必须仍然驻留在内存中才能被访问。换句话说,如果我们关于地址空间的使用方式的模型与底层分割的设计不完全匹配,那么分割就不能很好地工作。因此,我们需要找到一些新的解决方案。准备好了吗?





空闲空间管理

在本节中,我们将从我们对虚拟化内存的讨论中稍微绕过一下,来讨论任何内存管理系统的一个基本方面,无论是malloc库(管理进程堆的页)还是操作系统本身(管理进程地址空间的一部分)。具体来说,我们将讨论关于空闲空间管理

让我们把问题说得更具体些。管理空闲空间当然很容易,我们将在讨论分页的概念时看到这一点。当你管理的空间被划分为固定大小的单元时,这是很容易的;在这种情况下,你只需要将这些固定大小的单位组织成列表;当请求其中一个条目时,返回第一个条目即可。

自由空间管理变得更困难(也更有趣)的地方是,您正在管理的自由空间由可变大小的单元组成;这出现在用户级内存分配库(如malloc()和free())中,以及在使用分段实现虚拟内存时管理物理内存的操作系统中。在任何一种情况下,存在的问题都被称为外部碎片:自由空间被切成大小不同的小块,因此被碎片化;后续请求可能会失败,因为没有单个连续空间可以满足请求。

图中显示了这个问题的一个例子。在本例中,可用的空闲空间总数为20字节;不幸的是,它被分割成两个大小为10的块。因此,即使有20个字节空闲,请求15个字节也会失败。因此,我们得出了本节所讨论的问题。

假设

本文的大部分讨论将集中于在用户级内存分配库中的分配器的伟大历史。我们借鉴了Wilson的优秀研究。

我们假设有一个基本的接口,比如malloc()和free()提供的接口。具体来说,void *malloc(size t size)接受一个参数size,即应用程序请求的字节数;它会返回一个指针(没有特定类型的指针)到那个大小(或更大)的区域。补充例程void free(void *ptr)接受一个指针并释放相应的块。注意暗示的含义:用户在释放空间时,不会告知块的大小。

这个库管理的空间在历史上被称为堆,用于管理堆中的空闲空间的通用数据结构是某种自由列表。这个结构包含对内存管理区域中所有空闲空间块的引用。当然,这个数据结构本身不需要是一个列表,而仅仅是某种跟踪空闲空间的数据结构。

我们进一步假设我们主要关注外部碎片,如上所述。分配器当然也有内部碎片的问题;如果分配程序分配的内存块大于所请求的内存块,那么这种块中任何未经请求(因而未使用)的空间都被认为是内部碎片(因为浪费发生在分配的单元中),并且是空间浪费的另一个例子。但是,为了简单起见,并且因为这是两种类型的碎片中比较有趣的一种,所以我们将主要关注外部碎片。

我们还假设,一旦内存被分发给客户端,它就不能被重新分配到内存中的其他位置。例如,如果一个程序调用malloc()并给它一个指向堆中某个空间的指针,那么这个内存区域实际上是由程序“拥有”的(并且不能被库移动),直到程序通过相应的调用返回到free()为止。

最后,我们假设分配器管理一个的连续区域。在某些情况下,分配器可以要求该区域增长; 例如,当堆耗尽空间时,用户级内存分配库可能会调用内核来增加堆(通过sbrk等系统调用)。但是,为了简单起见,我们假设该区域在其生命周期中是一个固定的大小。

底层机制

在深入研究一些策略细节之前,我们将首先介绍大多数分配器中使用的一些通用机制。首先,我们将讨论分裂和合并的基础知识,这是大多数分配器中的通用技术。其次,我们将展示如何快速和相对轻松地跟踪分配区域的大小。最后,我们将讨论如何在空闲空间中构建一个简单的列表,以跟踪哪些是空闲的,哪些不是。

一个空闲列表包含一组元素,这些元素描述了堆中剩余的空闲空间。因此,假设以下30字节堆:

这个堆的空闲列表上有两个元素。一个条目描述第一个10字节的自由段(字节0-9),一个条目描述另一个自由段(字节20-29):

如上所述,任何大于10字节的请求都会失败(返回NULL);没有一个连续的内存块有这么大。对于这个大小(10字节)的请求可以通过任意一个空闲块轻松满足。但是,如果请求小于10字节,会发生什么情况呢?

假设我们只请求一个字节的内存。在本例中,分配器将执行一个名为split的操作:它将查找一个空闲的内存块,它可以满足请求并将其分成两部分。它将返回给调用者的第一个块;第二部分将保留在列表中。因此,在上面的例子中,如果请求1字节,和分配器决定使用第二个元素来满足请求,调用malloc()将返回20(字节的地址分配的区域)和列表会看起来像这样:

在图中,你可以看到列表基本保持不变;唯一的变化是空闲区域从21开始,而不是20,空闲区域的长度现在是9。因此,当请求小于任何特定空闲块的大小时,分裂通常在分配器中使用。

在许多分配器中实验的一个机制称为空闲空间的合并。再以上面的示例为例(空闲10字节,使用10字节,另外空闲10字节)。注意这个问题:虽然整个堆现在是空闲的,但它似乎被分成三个块,每个块10个字节。因此,如果用户请求20字节,简单的列表遍历将不会找到这样的空闲块,并返回失败。

分配器为了避免这个问题,在释放内存块时合并空闲空间。这个想法很简单:当在内存中返回一个空闲块时,仔细查看正在返回的块的地址以及附近的空闲空间块;如果新释放的空间恰好位于一个(或两个,如本例中所示)现有的空闲块旁边,则将它们合并为一个更大的空闲块。因此,通过合并,我们的最终列表应该是这样的:

实际上,在进行任何分配之前,这就是堆列表最初的样子。通过合并,分配器可以更好地确保应用程序可以使用较大的空闲区。

跟踪分配区域的大小

您可能已经注意到,到free(void *ptr)的接口不带大小参数;因此,假设给定一个指针,malloc库可以快速确定被释放的内存区域的大小,从而将空间合并回空闲列表中。

为了完成这项任务,大多数分配器会在头块中存储一些额外的信息,这些头块通常保存在内存中,通常是在分配的内存块之前。让我们再看一个例子。在本例中,我们将检查由ptr指向的大小为20字节的分配块;假设用户调用malloc()并将结果存储在ptr中,例如ptr = malloc(20)。

报头最少包含分配区域的大小(在本例中为20);它还可能包含额外的指针,以加快回收,一个魔术数字提供额外的完整性检查,和其他信息。让我们假设一个包含区域大小和一个魔术数字的简单标头,如下所示:

当用户调用free(ptr),然后库使用简单的指针算法来确定头从哪里开始

在获得这样一个指向标头的指针之后,库可以轻松地确定魔术数字是否与预期值匹配(assert(hptr->magic == 1234567)),并通过简单的数学计算(即,将标头的大小添加到区域的大小)。注意最后一句话中的小而关键的细节:请求空闲区域的大小是头的大小加上分配给用户的空间的大小。因此,当用户请求N字节的内存时,库不会搜索大小为N的空闲块;相反,它搜索一个大小为N加上标头的大小的空闲块。

嵌入一个空闲列表

到目前为止,我们已经把我们简单的空闲列表当作一个概念实体;它只是一个描述堆中空闲内存块的列表。但我们如何在空闲空间内构建这样一个列表呢?

在更典型的列表中,在分配新节点时,当需要节点空间时,只需调用malloc()。不幸的是,在内存分配库中,您不能这样做!相反,您需要在空闲空间中构建列表。

假设我们有一个4096字节的内存块要管理(即堆为4KB)。要将此管理为一个空闲列表,首先需要初始化上述列表; 最初,列表应该有一个条目,大小为4096(减去标头大小)。下面是列表节点的描述:

typedef struct __node_t {

       int size;

       struct __node_t *next;

} node_t;

现在,让我们看看初始化堆并将空闲列表的第一个元素放入该空间中的一些代码。我们假设堆是在一个通过调用系统调用mmap()获得的空闲空间内构建的;这不是构建这样一个堆的唯一方法,但在本例中很好地为我们服务。代码如下:

运行这段代码之后,列表的状态是它只有一个条目,大小为4088。是的,这是一个很小的堆,但它为我们提供了一个很好的例子。

头指针包含此范围的起始地址;让我们假设它是16KB(尽管任何虚拟地址都可以)。因此,从视觉上看,堆看起来就像您在图17.3中看到的那样。

现在,让我们假设请求了一块内存,比如说大小100字节。为了服务这个请求,库首先会找到一个足够大的块来容纳这个请求;因为只有一个空闲块(大小:4088),所以将选择这个块。然后,块将被分割为两个:一个足够大的块(和头,如上所述)来服务请求,以及剩余的空闲块。假设有一个8字节的头(一个整数大小和一个整数魔法数字),堆中的空间现在看起来就像图17.4所示。

因此,在请求100字节时,库从现有的一个空闲块中分配了108字节,返回一个指针(在上图中标记为ptr)给它,并将头信息存储在分配空间供以后在free()上使用,并将列表中的一个空闲节点压缩为3980字节(4088 - 108)。

现在让我们看看有三个分配的区域时的堆,每个区域都有100个字节(或者包括头信息在内的108个字节)。此堆的可视化图如图17.5所示。正如您在其中看到的,堆的前324字节现在已经被分配,因此我们可以看到在该空间中有三个头,以及调用程序使用的三个100字节区域。空闲列表仍然很无趣:只有一个节点(由head指向),但是在三次分割之后,现在只有3764字节。但是当调用程序通过free()返回一些内存时会发生什么呢?

在本例中,应用程序通过调用free(16500)返回分配的内存的中间块(16500的值是通过将内存区域16384的起始值添加到前一个块的108和该块的头的8个字节)。这个值由指针sptr显示在前面的图表中。

库立即计算出空闲区域的大小,然后将空闲块添加到空闲列表中。假设我们在空闲列表的开头插入,空间现在看起来是这样的(图17.6)。

现在我们有一个列表,它以一个小的空闲块(100字节,由列表的开头指向)和一个大的空闲块(3764字节)开始。

最后一个示例:现在假设最后两个使用中的块被释放。如果不合并,您可能会得到一个高度分散的自由列表(参见图17.7)

从图中可以看到,我们现在有一个大混乱!为什么?很简单,我们忘记合并列表了。虽然所有的内存都是空闲的,但它被分割成碎片,因此看起来就像碎片一样,尽管不是一个。解决方案很简单:遍历列表并合并相邻的块;完成后,堆将再次完整。

我们应该讨论在许多分配库中发现的最后一个机制。具体来说,如果堆耗尽空间,应该怎么办?最简单的方法就是返回失败。在某些情况下,这是唯一的选择,因此返回NULL是一种体面的方法。不要难过!你努力了,尽管你失败了,但你打了一场漂亮的仗。大多数传统的分配器从一个小型堆开始,然后当它们耗尽时从操作系统请求更多的内存。通常,这意味着它们进行某种系统调用(例如,大多数UNIX系统中的sbrk)来增加堆,然后从中分配新的块。为了服务sbrk请求,操作系统找到空闲的物理页面,将它们映射到请求进程的地址空间中,然后返回新堆末尾的值;此时,可以使用更大的堆,可以成功地为请求提供服务。

基本策略

让我们来回顾一下管理自由空间的一些基本策略。这些方法主要是基于你自己能想到的非常简单的策略;在阅读之前尝试一下,看看你是否想出了所有的选择。

理想的分配器是快速和最小化碎片。不幸的是,因为分配和自由请求的流可以是任意的(毕竟,它们是由程序员决定的),任何特定的策略在给定错误输入集的情况下都会做得相当糟糕。因此,我们将不描述“最佳”方法,而是讨论一些基础知识并讨论它们的优缺点。

best fit的策略非常简单:首先,在空闲列表中搜索,找到与请求大小相同或更大的空闲内存块。然后,返回那组候选人中最小的那个;这就是所谓的最佳拟合块(也可以称为最小拟合块)。一次遍历空闲列表就足以找到要返回的正确块。best fit背后的直觉很简单:通过返回一个接近用户要求的块,best fit试图减少浪费空间。然而,这是有代价的;这种简单的实现需要付出沉重的性能代价。

first fit方法只是找到第一个足够大的块,并将请求的数量返回给用户。与前面一样,剩余的空闲空间将保持为后续请求的空闲空间。First fit的优点是速度快——不需要对所有空闲空间进行彻底搜索——但有时会用小对象污染空闲列表的开头。因此,分配程序如何管理空闲列表的顺序就成了一个问题。一种方法是使用基于地址的排序;通过保持列表按照空闲空间的地址排序,合并变得更容易,有利于碎片化减少。

next fit算法保留了一个指向列表中最后一个查找位置的额外指针。其思想是将对空闲空间的搜索更均匀地分布在列表中,从而避免了列表头的分裂。这种方法的性能与first fit非常相似,因为再次避免了彻底搜索。

以下是上述策略的几个例子。设想一个有三个元素的空闲列表,大小分别为10、30和20(我们将忽略标题和其他细节,而仅仅关注策略是如何运作的):

假设分配请求的大小为15。best fit方法将搜索整个列表,发现20是最佳拟合,因为它是能够容纳请求的最小空闲空间。

一个有趣的方法已经出现了一段时间了,就是使用fsegregated lists。基本思想很简单:如果一个特定的应用程序有一个(或几个)受欢迎的大小的请求,保持一个单独的列表来管理这个大小的对象;所有其他请求都被转发到更通用的内存分配器。

这种方法的好处是显而易见的。通过为特定大小的请求分配一块内存,碎片就不那么重要了;此外,当分配和空闲请求大小合适时,可以非常快速地提供它们,因为不需要对列表进行复杂的搜索。

就像任何好的想法一样,这种方法也为系统引入了新的复杂性。例如,相对于一般的内存池,应该将多少内存分配给为特定大小的特定请求服务的内存池?一个特殊的分配器,由超级工程师Jeff Bonwick设计的平板分配器(它是为Solaris内核设计的),以一种相当不错的方式处理这个问题。

具体来说,当内核启动时,它会为可能经常被请求的内核对象分配一些对象缓存(例如锁、文件系统索引节点等);因此,对象缓存是每个给定大小的独立的空闲列表,并快速地为内存分配和空闲请求提供服务。当给定的缓存空闲空间不足时,它会从更一般的内存分配器(请求的总量是页面大小和相关对象的倍数)请求一些内存板。相反,当给定slab中对象的引用计数都为零时,通用分配器可以从专用分配器中回收它们,这通常是在VM系统需要更多内存时完成的。

通过将列表中的空闲对象保持在预先初始化的状态,slab分配器也超越了大多数分离的列表方法。Bonwick表明数据结构的初始化和破坏代价高昂;通过将释放的对象保持在特定的列表中初始化状态,slab分配器因此避免了每个对象频繁的初始化和销毁周期,从而显著降低了开销。

由于合并对于分配器来说是至关重要的,所以已经设计了一些方法使合并变得简单,binary buddy allocator是一个很好的例子。

在这样一个系统中,空闲内存首先被概念性地认为是一个大小为2^N的空间。当发出内存请求时,对空闲空间的搜索会递归地将空闲空间分成两部分,直到找到一个足够容纳请求的块(如果再进一步将其一分为二,则会导致空间太小)。此时,请求的块将返回给用户。这里有一个64KB的空闲空间在搜索7KB块时被分割的例子。

在本例中,最左边的8KB块被分配(用较暗的灰色表示)并返回给用户;请注意,这个方案可能会受到内部碎片的影响,因为您只能发出两的N次方大小的块。

伙伴分配的美妙之处在于释放该块时会发生什么。当将8KB块返回到空闲列表时,分配器检查“伙伴”8KB是否空闲;如果是这样,它将两个块合并成一个16KB的块。然后分配器检查16KB块的伙伴是否仍然空闲;如果是这样,它就会把这两个块合并起来。这个递归的合并过程在树中继续进行,或者恢复整个空闲空间,或者在发现伙伴正在使用时停止。

伙伴分配如此有效的原因是,很容易确定特定块的伙伴。

总结

在本节中,我们已经讨论了最基本的内存分配器形式。这样的分配器无处不在,链接到您编写的每个C程序中,也链接到底层操作系统中,这些操作系统为自己的数据结构管理内存。与许多系统一样,在构建这样的系统时需要进行许多权衡,并且您对分配程序所呈现的确切工作负载了解得越多,您就越有可能对其进行调优,以更好地应对该工作负载。开发一种快速、高效、可扩展的分配器,能够很好地处理广泛的工作负载。

 



分页

有时有人说,在解决大多数空间管理问题时,操作系统采用两种方法中的一种。第一种方法是把东西分割成大小不同的块,就像我们在虚拟内存中看到的分段一样。不幸的是,这个解决方案存在固有的困难。特别地,当将一个空间划分为不同大小的块时,空间本身会变得支离破碎,因此分配变得越来越具有挑战性。

因此,可能值得考虑第二种方法:将空间分割成固定大小的块。在虚拟内存中,我们称这种想法为分页,它可以追溯到一个早期的重要系统,Atlas。我们不是将进程的地址空间分割成若干可变大小的逻辑段(例如,代码、堆、堆栈),而是将其分割成大小固定的单元,每个单元都称为一个页面。相应地,我们将物理内存看作一组称为页帧的固定大小的槽;每个帧都可以包含一个虚拟内存页面。我们面临的挑战:   如何利用页面对内存进行虚拟化以避免分割问题?基本技术是什么?我们如何使这些技术在最少的空间和时间开销的情况下很好地工作?

一个简单的例子和概述

为了使这种方法更清楚,让我们用一个简单的例子来说明它。下图展示了一个很小的地址空间的例子,总共只有64字节,有4个16字节的页面(虚拟页面0、1、2和3); 当然,实际地址空间要大得多,通常是32位,因此是4GB的地址空间,甚至是64位;  在这里,我们经常使用小的例子使它们更容易理解。

如图所示,物理内存包含一些固定大小的插槽,在本例中是8个页帧(这使得一个128字节的物理内存也非常小)。如图所示,虚拟地址空间的页面被放置在整个物理内存中的不同位置;该图还显示了操作系统本身使用的一些物理内存。

我们将看到,与以前的方法相比,分页有很多优势。可能最重要的改进是灵活性:使用完全开发的分页方法,系统将能够有效地支持地址空间的抽象,而不管进程如何使用地址空间;例如,我们不会对堆和堆栈的增长方向以及如何使用它们做出假设。

另一个优点是分页提供的空闲空间管理的简单性。例如,当操作系统希望将64字节的地址空间放入8页的物理内存中时,它只会找到4个空闲页面;也许操作系统为这个保留了一个所有空闲页面的空闲列表,并从这个列表中抓取了前四个空闲页面。

为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常会保留一个每个进程的数据结构,称为页表。页表的主要作用是存储地址空间的每个虚拟页的地址转换,从而让我们知道每个页驻留在物理内存中的位置。我们的简单的例子(图18.2,页2),页表将因此有以下四项:(vp0→pf3),(vp1→pf7)、(vp2→pf5)和(vp3→pf2)。

重要的是要记住这个页表是每个进程的数据结构(我们讨论的大多数页表结构是每个进程的结构;我们将要提到的一个例外是反向页表)。如果要在上面的示例中运行另一个进程,操作系统必须为它管理一个不同的页表,因为它的虚拟页显然映射到不同的物理页。

现在,我们知道了足够的知识来执行一个地址转换示例。让我们想象一下,这个小地址空间(64字节)的进程正在执行内存访问:

具体来说,让我们关注地址<virtual address>到寄存器eax的数据的显式加载.

要转换该进程生成的虚拟地址,我们必须首先将其分解为两个组件:虚拟页码(VPN)和页面内的偏移量。对于本例,因为进程的虚拟地址空间是64字节,所以我们需要6位的空间来处理虚拟地址(2^6 = 64)。因此,我们的虚拟地址可以概念化如下:

在这个图中,Va5是虚拟地址的最高位。因为我们知道页面大小(16字节),所以我们可以进一步划分虚拟地址如下:

页面大小为64字节地址空间中的16字节;因此,我们需要能够选择4个页面,地址的前2位就是这个作用。因此,我们有一个2位虚拟页码(VPN)。剩下的位告诉我们对页面的哪个字节感兴趣,在本例中是4位;我们称之为偏移量。

当进程生成虚拟地址时,操作系统和硬件必须结合起来将其转换为有意义的物理地址。例如,让我们假设上面的负载是虚拟地址21: 

movl 21, %eax

将“21”转换为二进制形式,我们得到“010101”,因此我们可以检查这个虚拟地址,看看它如何分解成一个虚拟页码(VPN)和偏移

因此,虚拟地址“21”在虚拟页“01”(或1)的第5(“0101”)字节上。通过我们的虚拟页码,我们现在可以索引我们的页表,并找到哪个物理框架是虚拟页1驻留在其中。在上面的页表中,物理帧号(PFN)(有时也称为物理页码或PPN)是7(二进制111)。因此,我们可以通过将VPN替换为PFN来转换这个虚拟地址,然后将负载发送到物理内存。

注意偏移保持不变(即,因为偏移量只告诉我们想要的页面中的哪个字节。我们的最终物理地址是1110101(在decimal中是117),它正是我们希望从其中提取数据的地方。

有了这个基本的概览,我们现在可以询问(并且希望回答)一些关于分页的基本问题。例如,这些页表存储在哪里?页表的典型内容是什么?表有多大?分页是否构成系统慢?下面的文章至少部分回答了这些问题和其他一些有趣的问题。继续读下去!

页表可能变得非常大,比我们前面讨论过的小段表或基/界对大得多。例如,想象一个典型的32位地址空间,有4KB页面。这个虚拟地址分为20位VPN和12位偏移(回想一下,1KB的页面大小需要10位,只要再加2位就可以达到4KB)。

一个20位的VPN意味着操作系统必须为每个进程20^20翻译(大约100万);假设每个页表条目(PTE)需要4个字节来保存物理转换和其他有用的东西,那么每个页表都需要4MB的内存!这是相当大的。现在假设有100个进程在运行:这意味着操作系统需要400MB内存来完成所有的地址转换!即使在现代,机器有千兆内存的时代,仅仅为transla使用一大块内存似乎也有点疯狂。

由于页表太大,我们没有在MMU中保留任何特殊的片上硬件来存储当前正在运行的进程的页表。相反,我们将每个进程的页表存储在内存中的某处。现在让我们假设页表位于操作系统管理的物理内存中;稍后我们将看到,许多OS内存本身可以被虚拟化,因此页表可以存储在OS虚拟内存中(甚至可以交换到磁盘),但现在这太混乱了,所以我们将忽略它。

 

让我们讨论一下页面表的组织。页表只是一个数据结构,用于将虚拟地址(实际上是虚拟页码)映射到物理地址(物理帧号)。因此,任何数据结构都可以工作。最简单的形式称为线性页表,它只是一个数组。操作系统根据虚拟页码(VPN)对数组进行索引,并在该索引处查找页表条目(PTE),以便找到所需的物理帧号(PFN)。现在,我们假设这个简单的线性结构;在后面的章节中,我们将使用更高级的数据结构来帮助解决分页的一些问题。

对于每一个PTE的内容,我们有一些不同的比特在某种程度上值得理解。一个有效的位通常表示特定的转化是否有效;例如,当程序开始运行时,它将在地址空间的一端有代码和堆,另一端有栈。中间所有未使用的空间将被标记为无效,如果进程试图访问这些内存,它将生成一个陷阱到操作系统,可能会终止进程。因此,有效位对于支持稀疏地址空间至关重要;通过简单地将地址空间中所有未使用的页标记为无效的,我们消除了为这些页分配物理帧的需要,从而节省了大量内存。我们还可能有保护位,指示是否可以从页面读取、写入或从页面执行。同样,以这些位所不允许的方式访问页面将会给操作系统生成一个陷阱。

还有一些其他的方面很重要,但我们现在不会谈论太多。当前位指示该页是在物理内存中还是在磁盘上(即)。当我们研究如何将部分地址空间交换到磁盘以支持大于物理内存的地址空间时,我们将进一步了解这种机制;交换允许操作系统通过将很少使用的页面移动到磁盘来释放物理内存。一个脏位也很常见,它指示页面在进入内存后是否被修改过。引用位(也称为访问位)有时用于跟踪是否访问了一个页面,这些知识是非常重要的。

 

对于内存中的页表,我们已经知道它们可能太大了。事实证明,他们也能让事情慢下来。例如,以我们的简单指令为例。

movl 21, %eax

同样,让我们检查显式引用到地址21,而不必担心指令获取。在本例中,我们假设硬件为我们执行翻译。要获取所需的数据,系统必须首先将虚拟地址(21)转换为正确的物理地址
(117)。因此,在从地址117获取数据之前,系统必须首先从进程的页表中获取适当的页表条目,执行转换,然后从物理内存中加载数据。

同样,让我们检查显式引用到地址21,而不必担心指令获取。在本例中,我们假设硬件为我们执行翻译。要获取所需的数据,系统必须首先将虚拟地址(21)转换为正确的物理地址(117)。因此,在从地址117获取数据之前,系统必须首先从进程的页表中获取适当的页表条目,执行转换,然后从物理内存中加载数据。

为此,硬件必须知道当前正在运行的进程的页表在哪里。现在,让我们假设一个页表基寄存器包含页表起始位置的物理地址。为了找到所需PTE的位置,硬件将执行以下功能:

VPN = (VirtualAddress & VPN_MASK) >> SHIFT

PTEAddr = PageTableBaseRegister + (VPN * sizeof(PTE))

在我们的示例中,VPN掩码将设置为0x30(十六进制30,或二进制110000)从完整的虚拟地址中提取VPN比特;SHIFT设置为4(偏移量中的位数),这样我们就可以将VPN位向下移动,以形成正确的整数虚拟页码。例如,使用虚拟地址21(010101),并使用屏蔽将此值转换为010000;根据需要,将它转换为01即虚拟页面1。然后,我们使用这个值作为页面表基寄存器指向的PTEs数组的索引。

一旦知道了这个物理地址,硬件就可以从内存中获取PTE,提取PFN,并将其与虚拟地址的偏移量连接起来,以形成所需的物理地址。具体来说,您可以将PFN按移位向左移位,然后按位或按位偏移偏移形成最终地址如下:

offset = VirtualAddress & OFFSET_MASK

PhysAddr = (PFN << SHIFT) | offset

对于每个内存引用(无论是指令获取还是显式加载或存储),分页需要我们执行一个额外的内存引用,以便首先从页表中获取转换。要做的工作太多了!额外的内存引用是昂贵的,在这种情况下可能会使进程慢两倍或更多

现在你们可以看到有两个真正的问题我们必须解决。如果不仔细设计硬件和软件,页表将导致系统运行太慢,并占用太多内存。虽然这看起来是内存虚拟化需求的一个很好的解决方案,但这两个关键问题必须首先解决。

在结束之前,我们现在跟踪一个简单的内存访问示例,以演示使用分页时所产生的所有内存访问。我们感兴趣的代码片段(在C语言中,在名为array.c的文件中)如下所示

下面是生成的汇编代码:


 

要了解哪个内存访问这个指令序列(在虚拟和物理级别),我们必须假设在虚拟内存中找到代码片段和数组的位置,以及页表的内容和位置.

对于本例,我们假设虚拟地址空间大小为64KB(小得不现实)。我们还假设页面大小为1KB。

我们现在需要知道的只是页表的内容,以及它在物理内存中的位置。假设我们有一个线性(基于数组的)页表,它位于物理地址1KB(1024)处。

至于它的内容,我们只需要为这个示例映射几个虚拟页面。首先,存在代码赖以生存的虚拟页面。由于页面大小为1KB,虚拟地址1024驻留在虚拟地址空间的第二页(VPN=1, VPN=0是第一页)。让我们假设这个虚拟页面映射到物理框架4(VPN 1→PFN 4)

接下来是数组本身。它的大小是4000字节(1000个整数),我们假设它驻留在虚拟地址40000到44000之间(不包括最后一个字节)。这个十进制范围的虚拟页面是VPN = 39……VPN = 42。因此,我们需要这些页面的映射。让我们假设这些"虚拟到物理"映射的例子:(VPN 39→PFN 7),(VPN 40→PFN 8),(VPN 41→PFN 9),(VPN 42→PFN 10).

我们现在可以跟踪程序的内存引用了。当它运行时,每个指令获取将生成两个内存引用:一个到页表,以找到指令驻留在其中的物理框架.此外,还有一个以mov指令形式的显式内存引用;这将首先添加另一个页表访问(将数组虚拟地址转换为正确的物理地址),然后再添加数组访问本身。

前五个循环迭代的整个过程如图所示。最下面的图以黑色表示y轴上的指令内存引用(左边是虚拟地址,右边是实际的物理地址);中间的图显示了以深灰色显示的数组访问(同样,虚拟的在左边,物理的在右边);最后,最上面的图以浅灰色显示页表内存访问(只是物理的,因为本例中的页表驻留在物理内存中)。对于整个跟踪,x轴显示循环的前5次迭代的内存访问;每个循环有10个内存访问,其中包括4个指令获取、1个显式内存更新、5个页面表访问来转化这4个获取和1个显式更新。

这只是最简单的示例(只有几行C代码),但是您可能已经能够感觉到理解实际应用程序的实际内存行为的复杂性。别担心:情况肯定会变得更糟,因为我们将要引入的机制只会让这个已经很复杂的机制变得更加复杂

多级页表

现在我们来解决分页引入的第二个问题:页表太大,因此会消耗太多内存。一个简单的解决方法,多级页表。

多级页表背后的基本思想很简单。首先,把页表切成页大小的单位;然后,如果整个页表条目(PTEs)页面无效,则根本不分配该页表的该页。要跟踪页表的页面是否有效(如果有效,则在内存中),使用称为页目录的新结构。因此,页目录可以用来告诉您页表的一个页在哪里,或者页表的整个页是不是有效页。

图20.3显示了一个示例。图的左边是经典的线性页表;即使地址空间的大部分中间区域无效,我们仍然需要为这些区域分配页表空间(即,页表的中间两页)。右边是一个多级页表,页目录仅标记页表有效(第一页和最后一页);因此,只有页表的那两页驻留在内存中。因此,您可以看到一种可视化多级表的方法:它使线性页表的一部分消失,并跟踪页表的哪些页与页目录一起分配。

与我们目前看到的方法相比,多级页表有一些明显的优势。首先,也可能是最明显的,多级表仅根据所使用的地址空间的比例分配页表空间;因此,它通常是紧凑的,并支持稀疏地址空间。

其次,如果精心构建,页表的每个部分都可以整齐地放在一个页面中,从而更容易管理内存;当需要分配或增长一个页表时,操作系统可以简单地获取下一个空闲页。将其与简单的(非分页的)线性页面表2进行对比,即VPN索引的PTEs数组;有了这样的结构,整个线性页表必须连续地驻留在物理内存中。对于一个大的页表(比如4MB),找到这么大的未使用的连续的空闲物理内存可能是一个相当大的挑战。

应该指出的是,多级表是有成本的;在TLB失败时,需要从内存中加载两次才能从页表中获得正确的翻译信息(一个用于页目录,一个用于PTE本身),而不是只加载一次线性页表。因此,多级表是时空权衡的一个小例子。

另一个明显的负面因素是复杂性。无论是硬件还是操作系统处理页表查找(TLB丢失),这样做无疑比简单的线性页表查找更复杂。我们常常愿意增加复杂性,以提高性能或减少管理费用;在多级表的情况下,为了节省宝贵的内存,我们使页表查找更加复杂。

为了更好地理解多级页表背后的思想,让我们做一个示例。假设有一个16KB大小、64字节页面的小地址空间。因此,我们有一个14位的虚拟地址空间,其中8位用于VPN和6位表示偏移量。线性页表将有2^8个(256)条目。

在本例中,虚拟页0和1代表代码,虚拟页4和5为堆,254和255为堆栈;地址空间的其余部分未使用。

为了为这个地址空间构建一个两级页表,我们从完整的线性页表开始,并将其分解为页大小的单元。回想一下我们有256个条目;假设每个PTE的大小为4字节。因此,我们的页表1 KB大小(256×4字节)。假设我们有64字节的页面,那么1KB的页面表可以被分成16个64字节的页面;每页可容纳16PTE.

我们现在需要了解的是如何使用VPN,首先将其索引到页目录,然后再索引到页表的页面。记住每个都是一个条目数组;因此,我们需要弄清楚的是如何从VPN的各个片段构建每个索引。

让我们首先索引到页面目录。本例中的页表较小:256个条目,分布在16个页面中。页目录每一页需要一个条目;因此,它有16个条目。因此,我们需要4位VPN来索引到目录;我们使用VPN的前四位,如下所示:

一旦我们从VPN中提取了页目录索引(简称PDIndex),我们就可以使用它来查找页目录条目的地址(PDE),计算简单:PDEAddr = PageDirBase + (PDIndex)* sizeof(PDE))。这将导致我们的页面目录,我们现在将对该目录进行检查,以在转化中取得进一步的进展。

如果页面目录条目被标记为无效,我们知道访问无效,因此引发异常。然而,如果PDE有效,我们还有更多的工作要做。具体来说,我们现在必须从这个pagedirectory条目所指向的页表的页中获取pagetable条目(PTE)。要找到这个PTE,我们必须使用剩下的VPN位索引到页表的部分。

然后可以使用这个页表索引(简称PTIndex)对页表本身进行索引,从而给出PTE的地址:

为了看看这些是否都有意义,我们现在用一些实际值填充一个多级页面表,并转换一个虚拟地址。

在图中,您可以看到每个目录条目(PDE)描述了关于地址空间的页表的一个页面。在本例中,我们在地址空间中有两个有效的区域(在开始和结束处),中间有一些无效的映射。

在PFN 100该页表包含前16个映射vpn;在我们的示例中,VPNs 0和1是有效的(代码段),4和5(堆)也是有效的。因此,该表具有每个页面的映射信息。其余的条目被标记为无效

在PFN 101中可以找到该页表的另一个有效页。此页面包含地址空间的最后16个vpn的映射。

在示例中,VPNs 254和255(堆栈)有有效的映射。希望我们能从这个示例中看到,使用多级索引结构可以节省多少空间。在本例中,我们没有为线性页表分配完整的16个页,而是只分配了三个页:一个用于页目录,两个用于具有有效映射的页表块。对于大型(32位或64位)地址空间的节省显然要大得多.

最后,让我们使用这些信息来执行地址转化。这里是一个地址,引用VPN 254的第0字节:0x3F80,或者11 1111 10000000二进制。

回想一下,我们将使用VPN的前4位来索引到页目录。因此,1111将选择最后一个页目录的入口。这将指向位于PFN101的页表的有效页。然后我们使用的下一个4位VPN(1110)指数到页面的页表,找到所需的PTE。1110年是倒数第二(14)页面上的条目,并告诉我们,254页的虚拟地址空间映射到物理页55.   通过将PFN=55(或十六进制0x37)与offset=000000连接起来,我们就可以形成我们想要的物理地址,并向内存系统发出请求:PhysAddr = (PTE.PFN << SHIFT) + offset= 00 1101 1100 0000 = 0x0DC0.

现在您应该对如何构造一个两层的页表有了一些概念,使用一个指向页表的页目录。然而,不幸的是,我们的工作没有完成。正如我们现在将要讨论的,有时两个级别的页表是不够的!

在我们目前的示例中,我们假设多级页表只有两个级别:一个页目录,然后是页表的各个部分。在某些情况下,一个更深的树是可能的(而且确实需要)。这里就不讨论了。

猜你喜欢

转载自blog.csdn.net/songjiji2/article/details/83473441
今日推荐