内存:你跑慢点行不行?CPU:跑慢点你养我吗?内存:我无论!(内附超全思惟导图)

主存(RAM) 是一件很是重要的资源,必需要认真对待内存。虽然目前大多数内存的增加速度要比 IBM 7094 要快的多,可是,程序大小的增加要比内存的增加还快不少。无论存储器有多大,程序大小的增加速度比内存容量的增加速度要快的多。下面咱们就来探讨一下操做系统是如何建立内存并管理他们的。html

通过多年的研究发现,科学家提出了一种 分层存储器体系(memory hierarchy),下面是分层体系的分类java

位于顶层的存储器速度最快,可是相对容量最小,成本很是高。层级结构向下,其访问速度会变慢,可是容量会变大,相对造价也就越便宜。(因此我的感受相对存储容量来讲,访问速度是更重要的)程序员

操做系统中管理内存层次结构的部分称为内存管理器(memory manager),它的主要工做是有效的管理内存,记录哪些内存是正在使用的,在进程须要时分配内存以及在进程完成时回收内存。全部现代操做系统都提供内存管理。算法

下面咱们会对不一样的内存管理模型进行探讨,从简单到复杂,因为最低级别的缓存是由硬件进行管理的,因此咱们主要探讨主存模型和如何对主存进行管理。编程

无存储器抽象

最简单的存储器抽象是无存储器。早期大型计算机(20 世纪 60 年代以前),小型计算机(20 世纪 70 年代以前)和我的计算机(20 世纪 80 年代以前)都没有存储器抽象。每个程序都直接访问物理内存。当一个程序执行以下命令:数组

MOV REGISTER1, 1000

计算机会把位置为 1000 的物理内存中的内容移到 REGISTER1 中。所以呈现给程序员的内存模型就是物理内存,内存地址从 0 开始到内存地址的最大值中,每一个地址中都会包含一个 8 位位数的内存单元。浏览器

因此这种状况下的计算机不可能会有两个应用程序同时在内存中。若是第一个程序向内存地址 2000 的这个位置写入了一个值,那么此值将会替换第二个程序 2000 位置上的值,因此,同时运行两个应用程序是行不通的,两个程序会马上崩溃。缓存

不过即便存储器模型就是物理内存,仍是存在一些可变体的。下面展现了三种变体服务器

在上图 a 中,操做系统位于 RAM(Random Access Memory) 的底部,或像是图 b 同样位于 ROM(Read-Only Memory) 顶部;而在图 c 中,设备驱动程序位于顶端的 ROM 中,而操做系统位于底部的 RAM 中。图 a 的模型之前用在大型机和小型机上,但如今已经不多使用了;图 b 中的模型通常用于掌上电脑或者是嵌入式系统中。第三种模型就应用在早期我的计算机中了。ROM 系统中的一部分红为 BIOS (Basic Input Output System)。模型 a 和 c 的缺点是用户程序中的错误可能会破坏操做系统,可能会致使灾难性的后果。网络

按照这种方式组织系统时,一般同一个时刻只能有一个进程正在运行。一旦用户键入了一个命令,操做系统就把须要的程序从磁盘复制到内存中并执行;当进程运行结束后,操做系统在用户终端显示提示符并等待新的命令。收到新的命令后,它把新的程序装入内存,覆盖前一个程序。

在没有存储器抽象的系统中实现并行性一种方式是使用多线程来编程。因为同一进程中的多线程内部共享同一内存映像,那么实现并行也就不是问题了。可是这种方式却并无被普遍采纳,由于人们一般但愿可以在同一时间内运行没有关联的程序,而这正是线程抽象所不能提供的。

运行多个程序

可是,即使没有存储器抽象,同时运行多个程序也是有可能的。操做系统只须要把当前内存中全部内容保存到磁盘文件中,而后再把程序读入内存便可。只要某一时刻内存只有一个程序在运行,就不会有冲突的状况发生。

在额外特殊硬件的帮助下,即便没有交换功能,也能够并行的运行多个程序。IBM 360 的早期模型就是这样解决的

System/360是 IBM 在1964年4月7日,推出的划时代的大型电脑,这一系列是世界上首个指令集可兼容计算机。

在 IBM 360 中,内存被划分为 2KB 的区域块,每块区域被分配一个 4 位的保护键,保护键存储在 CPU 的特殊寄存器(SFR)中。一个内存为 1 MB 的机器只须要 512 个这样的 4 位寄存器,容量总共为 256 字节 (这个会算吧) PSW(Program Status Word, 程序状态字) 中有一个 4 位码。一个运行中的进程若是访问键与其 PSW 中保存的码不一样,360 硬件会捕获这种状况。由于只有操做系统能够修改保护键,这样就能够防止进程之间、用户进程和操做系统之间的干扰。

这种解决方式是有一个缺陷。以下所示,假设有两个程序,每一个大小各为 16 KB

从图上能够看出,这是两个不一样的 16KB 程序的装载过程,a 程序首先会跳转到地址 24,那里是一条 MOV 指令,然而 b 程序会首先跳转到地址 28,地址 28 是一条 CMP 指令。这是两个程序被前后加载到内存中的状况,假如这两个程序被同时加载到内存中而且从 0 地址处开始执行,内存的状态就如上面 c 图所示,程序装载完成开始运行,第一个程序首先从 0 地址处开始运行,执行 JMP 24 指令,而后依次执行后面的指令(许多指令没有画出),一段时间后第一个程序执行完毕,而后开始执行第二个程序。第二个程序的第一条指令是 28,这条指令会使程序跳转到第一个程序的 ADD 处,而不是事先设定好的跳转指令 CMP,因为这种不正确访问,可能会形成程序崩溃。

上面两个程序的执行过程当中有一个核心问题,那就是都引用了绝对物理地址,这不是咱们想要看到的。咱们想要的是每个程序都会引用一个私有的本地地址。IBM 360 在第二个程序装载到内存中的时候会使用一种称为 静态重定位(static relocation) 的技术来修改它。它的工做流程以下:当一个程序被加载到 16384 地址时,常数 16384 被加到每个程序地址上(因此 JMP 28会变为JMP 16412 )。虽然这个机制在不出错误的状况下是可行的,但这不是一种通用的解决办法,同时会减慢装载速度。更近一步来说,它须要全部可执行程序中的额外信息,以指示哪些包含(可重定位)地址,哪些不包含(可重定位)地址。毕竟,上图 b 中的 JMP 28 能够被重定向(被修改),而相似 MOV REGISTER1,28 会把数字 28 移到 REGISTER 中则不会重定向。因此,装载器(loader)须要必定的能力来辨别地址和常数。

一种存储器抽象:地址空间

把物理内存暴露给进程会有几个主要的缺点:第一个问题是,若是用户程序能够寻址内存的每一个字节,它们就能够很容易的破坏操做系统,从而使系统中止运行(除非使用 IBM 360 那种 lock-and-key 模式或者特殊的硬件进行保护)。即便在只有一个用户进程运行的状况下,这个问题也存在。

第二点是,这种模型想要运行多个程序是很困难的(若是只有一个 CPU 那就是顺序执行)。在我的计算机上,通常会打开不少应用程序,好比输入法、电子邮件、浏览器,这些进程在不一样时刻会有一个进程正在运行,其余应用程序能够经过鼠标来唤醒。在系统中没有物理内存的状况下很难实现。

地址空间的概念

若是要使多个应用程序同时运行在内存中,必需要解决两个问题:保护重定位。咱们来看 IBM 360 是如何解决的:第一种解决方式是用保护密钥标记内存块,并将执行过程的密钥与提取的每一个存储字的密钥进行比较。这种方式只能解决第一种问题(破坏操做系统),可是不能解决多进程在内存中同时运行的问题。

还有一种更好的方式是创造一个存储器抽象:地址空间(the address space)。就像进程的概念建立了一种抽象的 CPU 来运行程序,地址空间也建立了一种抽象内存供程序使用。地址空间是进程能够用来寻址内存的地址集。每一个进程都有它本身的地址空间,独立于其余进程的地址空间,可是某些进程会但愿能够共享地址空间。

基址寄存器和变址寄存器

最简单的办法是使用动态重定位(dynamic relocation)技术,它就是经过一种简单的方式将每一个进程的地址空间映射到物理内存的不一样区域。从 CDC 6600(世界上最先的超级计算机)Intel 8088(原始 IBM PC 的核心)所使用的经典办法是给每一个 CPU 配置两个特殊硬件寄存器,一般叫作基址寄存器(basic register)变址寄存器(limit register)。当使用基址寄存器和变址寄存器时,程序会装载到内存中的连续位置而且在装载期间无需重定位。当一个进程运行时,程序的起始物理地址装载到基址寄存器中,程序的长度则装载到变址寄存器中。在上图 c 中,当一个程序运行时,装载到这些硬件寄存器中的基址和变址寄存器的值分别是 0 和 16384。当第二个程序运行时,这些值分别是 16384 和 32768。若是第三个 16 KB 的程序直接装载到第二个程序的地址之上而且运行,这时基址寄存器和变址寄存器的值会是 32768 和 16384。那么咱们能够总结下

  • 基址寄存器:存储数据内存的起始位置
  • 变址寄存器:存储应用程序的长度。

每当进程引用内存以获取指令或读取、写入数据时,CPU 都会自动将基址值添加到进程生成的地址中,而后再将其发送到内存总线上。同时,它检查程序提供的地址是否大于或等于变址寄存器 中的值。若是程序提供的地址要超过变址寄存器的范围,那么会产生错误并停止访问。这样,对上图 c 中执行 JMP 28 这条指令后,硬件会把它解释为 JMP 16412,因此程序可以跳到 CMP 指令,过程以下

使用基址寄存器和变址寄存器是给每一个进程提供私有地址空间的一种很是好的方法,由于每一个内存地址在送到内存以前,都会先加上基址寄存器的内容。在不少实际系统中,对基址寄存器和变址寄存器都会以必定的方式加以保护,使得只有操做系统能够修改它们。在 CDC 6600 中就提供了对这些寄存器的保护,但在 Intel 8088 中则没有,甚至没有变址寄存器。可是,Intel 8088 提供了许多基址寄存器,使程序的代码和数据能够被独立的重定位,可是对于超出范围的内存引用没有提供保护。

因此你能够知道使用基址寄存器和变址寄存器的缺点,在每次访问内存时,都会进行 ADDCMP 运算。CMP 指令能够执行的很快,可是加法就会相对慢一些,除非使用特殊的加法电路,不然加法因进位传播时间而变慢。

交换技术

若是计算机的物理内存足够大来容纳全部的进程,那么以前说起的方案或多或少是可行的。可是实际上,全部进程须要的 RAM 总容量要远远高于内存的容量。在 Windows、OS X、或者 Linux 系统中,在计算机完成启动(Boot)后,大约有 50 - 100 个进程随之启动。例如,当一个 Windows 应用程序被安装后,它一般会发出命令,以便在后续系统启动时,将启动一个进程,这个进程除了检查应用程序的更新外不作任何操做。一个简单的应用程序可能会占用 5 - 10MB 的内存。其余后台进程会检查电子邮件、网络链接以及许多其余诸如此类的任务。这一切都会发生在第一个用户启动以前。现在,像是 Photoshop 这样的重要用户应用程序仅仅须要 500 MB 来启动,可是一旦它们开始处理数据就须要许多 GB 来处理。从结果上来看,将全部进程始终保持在内存中须要大量内存,若是内存不足,则没法完成。

因此针对上面内存不足的问题,提出了两种处理方式:最简单的一种方式就是交换(swapping)技术,即把一个进程完整的调入内存,而后再内存中运行一段时间,再把它放回磁盘。空闲进程会存储在磁盘中,因此这些进程在没有运行时不会占用太多内存。另一种策略叫作虚拟内存(virtual memory),虚拟内存技术可以容许应用程序部分的运行在内存中。下面咱们首先先探讨一下交换

交换过程

下面是一个交换过程

刚开始的时候,只有进程 A 在内存中,而后从建立进程 B 和进程 C 或者从磁盘中把它们换入内存,而后在图 d 中,A 被换出内存到磁盘中,最后 A 从新进来。由于图 g 中的进程 A 如今到了不一样的位置,因此在装载过程当中须要被从新定位,或者在交换程序时经过软件来执行;或者在程序执行期间经过硬件来重定位。基址寄存器和变址寄存器就适用于这种状况。

交换在内存建立了多个 空闲区(hole),内存会把全部的空闲区尽量向下移动合并成为一个大的空闲区。这项技术称为内存紧缩(memory compaction)。可是这项技术一般不会使用,由于这项技术回消耗不少 CPU 时间。例如,在一个 16GB 内存的机器上每 8ns 复制 8 字节,它紧缩所有的内存大约要花费 16s。

有一个值得注意的问题是,当进程被建立或者换入内存时应该为它分配多大的内存。若是进程被建立后它的大小是固定的而且再也不改变,那么分配策略就比较简单:操做系统会准确的按其须要的大小进行分配。

可是若是进程的 data segment 可以自动增加,例如,经过动态分配堆中的内存,确定会出现问题。这里仍是再提一下什么是 data segment 吧。从逻辑层面操做系统把数据分红不一样的段(不一样的区域)来存储:

  • 代码段(codesegment/textsegment):

又称文本段,用来存放指令,运行代码的一块内存空间

此空间大小在代码运行前就已经肯定

内存空间通常属于只读,某些架构的代码也容许可写

在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。

  • 数据段(datasegment):

可读可写

存储初始化的全局变量和初始化的 static 变量

数据段中数据的生存期是随程序持续性(随进程持续性) 随进程持续性:进程建立就存在,进程死亡就消失

  • bss段(bsssegment):

可读可写

存储未初始化的全局变量和未初始化的 static 变量

bss 段中数据的生存期随进程持续性

bss 段中的数据通常默认为0

  • rodata段:

只读数据 好比 printf 语句中的格式字符串和开关语句的跳转表。也就是常量区。例如,全局做用域中的 const int ival = 10,ival 存放在 .rodata 段;再如,函数局部做用域中的 printf("Hello world %d\n", c); 语句中的格式字符串 "Hello world %d\n",也存放在 .rodata 段。

  • 栈(stack):

可读可写

存储的是函数或代码中的局部变量(非 static 变量)

栈的生存期随代码块持续性,代码块运行就给你分配空间,代码块结束,就自动回收空间

  • 堆(heap):

可读可写

存储的是程序运行期间动态分配的 malloc/realloc 的空间

堆的生存期随进程持续性,从 malloc/realloc 到 free 一直存在

下面是咱们用 Borland C++ 编译事后的结果

_TEXT	segment dword public use32 'CODE'
_TEXT	ends
_DATA	segment dword public use32 'DATA'
_DATA	ends
_BSS	segment dword public use32 'BSS'
_BSS	ends

段定义( segment ) 是用来区分或者划分范围区域的意思。汇编语言的 segment 伪指令表示段定义的起始,ends 伪指令表示段定义的结束。段定义是一段连续的内存空间

因此内存针对自动增加的区域,会有三种处理方式

  • 若是一个进程与空闲区相邻,那么可把该空闲区分配给进程以供其增大。

  • 若是进程相邻的是另外一个进程,就会有两种处理方式:要么把须要增加的进程移动到一个内存中空闲区足够大的区域,要么把一个或多个进程交换出去,已变成生成一个大的空闲区。

  • 若是一个进程在内存中不能增加,并且磁盘上的交换区也满了,那么这个进程只有挂起一些空闲空间(或者能够结束该进程)

上面只针对单个或者一小部分须要增加的进程采用的方式,若是大部分进程都要在运行时增加,为了减小因内存区域不够而引发的进程交换和移动所产生的开销,一种可用的方法是,在换入或移动进程时为它分配一些额外的内存。然而,当进程被换出到磁盘上时,应该只交换实际上使用的内存,将额外的内存交换也是一种浪费,下面是一种为两个进程分配了增加空间的内存配置。

若是进程有两个可增加的段,例如,供变量动态分配和释放的做为堆(全局变量)使用的一个数据段(data segment),以及存放局部变量与返回地址的一个堆栈段(stack segment),就如图 b 所示。在图中能够看到所示进程的堆栈段在进程所占内存的顶端向下增加,紧接着在程序段后的数据段向上增加。当增加预留的内存区域不够了,处理方式就如上面的流程图(data segment 自动增加的三种处理方式)同样了。

空闲内存管理

在进行内存动态分配时,操做系统必须对其进行管理。大体上说,有两种监控内存使用的方式

  • 位图(bitmap)
  • 空闲列表(free lists)

下面咱们就来探讨一下这两种使用方式

使用位图的存储管理

使用位图方法时,内存可能被划分为小到几个字或大到几千字节的分配单元。每一个分配单元对应于位图中的一位,0 表示空闲, 1 表示占用(或者相反)。一块内存区域和其对应的位图以下

图 a 表示一段有 5 个进程和 3 个空闲区的内存,刻度为内存分配单元,阴影区表示空闲(在位图中用 0 表示);图 b 表示对应的位图;图 c 表示用链表表示一样的信息

分配单元的大小是一个重要的设计因素,分配单位越小,位图越大。然而,即便只有 4 字节的分配单元,32 位的内存也仅仅只须要位图中的 1 位。32n 位的内存须要 n 位的位图,因此1 个位图只占用了 1/32 的内存。若是选择更大的内存单元,位图应该要更小。若是进程的大小不是分配单元的整数倍,那么在最后一个分配单元中会有大量的内存被浪费。

位图提供了一种简单的方法在固定大小的内存中跟踪内存的使用状况,由于位图的大小取决于内存和分配单元的大小。这种方法有一个问题是,当决定为把具备 k 个分配单元的进程放入内存时,内容管理器(memory manager) 必须搜索位图,在位图中找出可以运行 k 个连续 0 位的串。在位图中找出制定长度的连续 0 串是一个很耗时的操做,这是位图的缺点。(能够简单理解为在杂乱无章的数组中,找出具备一大长串空闲的数组单元)

使用链表进行管理

另外一种记录内存使用状况的方法是,维护一个记录已分配内存段和空闲内存段的链表,段会包含进程或者是两个进程的空闲区域。可用上面的图 c 来表示内存的使用状况。链表中的每一项均可以表明一个 空闲区(H) 或者是进程(P)的起始标志,长度和下一个链表项的位置。

在这个例子中,段链表(segment list)是按照地址排序的。这种方式的优势是,当进程终止或被交换时,更新列表很简单。一个终止进程一般有两个邻居(除了内存的顶部和底部外)。相邻的多是进程也多是空闲区,它们有四种组合方式。

当按照地址顺序在链表中存放进程和空闲区时,有几种算法能够为建立的进程(或者从磁盘中换入的进程)分配内存。咱们先假设内存管理器知道应该分配多少内存,最简单的算法是使用 首次适配(first fit)。内存管理器会沿着段列表进行扫描,直到找个一个足够大的空闲区为止。除非空闲区大小和要分配的空间大小同样,不然将空闲区分为两部分,一部分供进程使用;一部分生成新的空闲区。首次适配算法是一种速度很快的算法,由于它会尽量的搜索链表。

首次适配的一个小的变体是 下次适配(next fit)。它和首次匹配的工做方式相同,只有一个不一样之处那就是下次适配在每次找到合适的空闲区时就会记录当时的位置,以便下次寻找空闲区时从上次结束的地方开始搜索,而不是像首次匹配算法那样每次都会从头开始搜索。Bays(1997) 证实了下次算法的性能略低于首次匹配算法。

另一个著名的而且普遍使用的算法是 最佳适配(best fit)。最佳适配会从头至尾寻找整个链表,找出可以容纳进程的最小空闲区。最佳适配算法会试图找出最接近实际须要的空闲区,以最好的匹配请求和可用空闲区,而不是先一次拆分一个之后可能会用到的大的空闲区。好比如今咱们须要一个大小为 2 的块,那么首次匹配算法会把这个块分配在位置 5 的空闲区,而最佳适配算法会把该块分配在位置为 18 的空闲区,以下

那么最佳适配算法的性能如何呢?最佳适配会遍历整个链表,因此最佳适配算法的性能要比首次匹配算法差。可是使人想不到的是,最佳适配算法要比首次匹配和下次匹配算法浪费更多的内存,由于它会产生大量无用的小缓冲区,首次匹配算法生成的空闲区会更大一些。

最佳适配的空闲区会分裂出不少很是小的缓冲区,为了不这一问题,能够考虑使用 最差适配(worst fit) 算法。即老是分配最大的内存区域(因此你如今明白为何最佳适配算法会分裂出不少小缓冲区了吧),使新分配的空闲区比较大从而能够继续使用。仿真程序代表最差适配算法也不是一个好主意。

若是为进程和空闲区维护各自独立的链表,那么这四个算法的速度都能获得提升。这样,这四种算法的目标都是为了检查空闲区而不是进程。但这种分配速度的提升的一个不可避免的代价是增长复杂度和减慢内存释放速度,由于必须将一个回收的段从进程链表中删除并插入空闲链表区。

若是进程和空闲区使用不一样的链表,那么能够按照大小对空闲区链表排序,以便提升最佳适配算法的速度。在使用最佳适配算法搜索由小到大排列的空闲区链表时,只要找到一个合适的空闲区,则这个空闲区就是能容纳这个做业的最小空闲区,所以是最佳匹配。由于空闲区链表以单链表形式组织,因此不须要进一步搜索。空闲区链表按大小排序时,首次适配算法与最佳适配算法同样快,而下次适配算法在这里毫无心义。

另外一种分配算法是 快速适配(quick fit) 算法,它为那些经常使用大小的空闲区维护单独的链表。例如,有一个 n 项的表,该表的第一项是指向大小为 4 KB 的空闲区链表表头指针,第二项是指向大小为 8 KB 的空闲区链表表头指针,第三项是指向大小为 12 KB 的空闲区链表表头指针,以此类推。好比 21 KB 这样的空闲区既能够放在 20 KB 的链表中,也能够放在一个专门存放大小比较特别的空闲区链表中。

快速匹配算法寻找一个指定代销的空闲区也是十分快速的,但它和全部将空闲区按大小排序的方案同样,都有一个共同的缺点,即在一个进程终止或被换出时,寻找它的相邻块并查看是否能够合并的过程都是很是耗时的。若是不进行合并,内存将会很快分裂出大量进程没法利用的小空闲区。

虚拟内存

尽管基址寄存器和变址寄存器用来建立地址空间的抽象,可是这有一个其余的问题须要解决:管理软件的不断增大(managing bloatware)。虽然内存的大小增加迅速,可是软件的大小增加的要比内存还要快。在 1980 年的时候,许多大学用一台 4 MB 的 VAX 计算机运行分时操做系统,供十几个用户同时运行。如今微软公司推荐的 64 位 Windows 8 系统至少须要 2 GB 内存,而许多多媒体的潮流则进一步推进了对内存的需求。

这一发展的结果是,须要运行的程序每每大到内存没法容纳,并且必然须要系统可以支持多个程序同时运行,即便内存能够知足其中单独一个程序的需求,可是从整体上来看内存仍然知足不了日益增加的软件的需求(感受和xxx和xxx 的矛盾很类似)。而交换技术并非一个颇有效的方案,在一些中小应用程序尚可以使用交换,若是应用程序过大,难道还要每次交换几 GB 的内存?这显然是不合适的,一个典型的 SATA 磁盘的峰值传输速度高达几百兆/秒,这意味着须要好几秒才能换出或者换入一个 1 GB 的程序。

SATA(Serial ATA)硬盘,又称串口硬盘,是将来 PC 机硬盘的趋势,已基本取代了传统的 PATA 硬盘。

那么还有没有一种有效的方式来应对呢?有,那就是使用 虚拟内存(virtual memory),虚拟内存的基本思想是,每一个程序都有本身的地址空间,这个地址空间被划分为多个称为页面(page)的块。每一页都是连续的地址范围。这些页被映射到物理内存,但并非全部的页都必须在内存中才能运行程序。当程序引用到一部分在物理内存中的地址空间时,硬件会马上执行必要的映射。当程序引用到一部分不在物理内存中的地址空间时,由操做系统负责将缺失的部分装入物理内存并从新执行失败的指令。

在某种意义上来讲,虚拟地址是对基址寄存器和变址寄存器的一种概述。8088 有分离的基址寄存器(但不是变址寄存器)用于放入 text 和 data 。

使用虚拟内存,能够将整个地址空间以很小的单位映射到物理内存中,而不是仅仅针对 text 和 data 区进行重定位。下面咱们会探讨虚拟内存是如何实现的。

虚拟内存很适合在多道程序设计系统中使用,许多程序的片断同时保存在内存中,当一个程序等待它的一部分读入内存时,能够把 CPU 交给另外一个进程使用。

分页

大部分使用虚拟内存的系统中都会使用一种 分页(paging) 技术。在任何一台计算机上,程序会引用使用一组内存地址。当程序执行

MOV REG,1000

这条指令时,它会把内存地址为 1000 的内存单元的内容复制到 REG 中(或者相反,这取决于计算机)。地址能够经过索引、基址寄存器、段寄存器或其余方式产生。

这些程序生成的地址被称为 虚拟地址(virtual addresses) 并造成虚拟地址空间(virtual address space),在没有虚拟内存的计算机上,系统直接将虚拟地址送到内存中线上,读写操做都使用一样地址的物理内存。在使用虚拟内存时,虚拟地址不会直接发送到内存总线上。相反,会使用 MMU(Memory Management Unit) 内存管理单元把虚拟地址映射为物理内存地址,像下图这样

下面这幅图展现了这种映射是如何工做的

页表给出虚拟地址与物理内存地址之间的映射关系。每一页起始于 4096 的倍数位置,结束于 4095 的位置,因此 4K 到 8K 实际为 4096 - 8191 ,8K - 12K 就是 8192 - 12287

在这个例子中,咱们可能有一个 16 位地址的计算机,地址从 0 - 64 K - 1,这些是虚拟地址。然而只有 32 KB 的物理地址。因此虽然能够编写 64 KB 的程序,可是程序没法所有调入内存运行,在磁盘上必须有一个最多 64 KB 的程序核心映像的完整副本,以保证程序片断在须要时被调入内存。

存在映射的页如何映射

虚拟地址空间由固定大小的单元组成,这种固定大小的单元称为 页(pages)。而相对的,物理内存中也有固定大小的物理单元,称为 页框(page frames)。页和页框的大小同样。在上面这个例子中,页的大小为 4KB ,可是实际的使用过程当中页的大小范围多是 512 字节 - 1G 字节的大小。对应于 64 KB 的虚拟地址空间和 32 KB 的物理内存,可获得 16 个虚拟页面和 8 个页框。RAM 和磁盘之间的交换老是以整个页为单元进行交换的。

程序试图访问地址时,例如执行下面这条指令

MOV REG, 0

会将虚拟地址 0 送到 MMU。MMU 看到虚拟地址落在页面 0 (0 - 4095),根据其映射结果,这一页面对应的页框 2 (8192 - 12287),所以 MMU 把地址变换为 8192 ,并把地址 8192 送到总线上。内存对 MMU 一无所知,它只看到一个对 8192 地址的读写请求并执行它。MMU 从而有效的把全部虚拟地址 0 - 4095 映射到了 8192 - 12287 的物理地址。一样的,指令

MOV REG, 8192

也被有效的转换为

MOV REG, 24576

虚拟地址 8192(在虚拟页 2 中)被映射到物理地址 24576(在物理页框 6 中)上。

经过恰当的设置 MMU,能够把 16 个虚拟页面映射到 8 个页框中的任何一个。可是这并无解决虚拟地址空间比物理内存大的问题。

上图中有 8 个物理页框,因而只有 8 个虚拟页被映射到了物理内存中,在上图中用 X 号表示的其余页面没有被映射。在实际的硬件中,会使用一个 在/不在(Present/absent bit)位记录页面在内存中的实际存在状况。

未映射的页如何映射

当程序访问一个未映射的页面,如执行指令

MOV REG, 32780

将会发生什么状况呢?虚拟页面 8 (从 32768 开始)的第 12 个字节所对应的物理地址是什么?MMU 注意到该页面没有被映射(在图中用 X 号表示),因而 CPU 会陷入(trap)到操做系统中。这个陷入称为 缺页中断(page fault) 或者是 缺页错误。操做系统会选择一个不多使用的页并把它的内容写入磁盘(若是它不在磁盘上)。随后把须要访问的页面读到刚才回收的页框中,修改映射关系,而后从新启动引发陷入的指令。有点不太好理解,举个例子来看一下。

例如,若是操做系统决定放弃页框 1,那么它将把虚拟机页面 8 装入物理地址 4096,并对 MMU 映射作两处修改。首先,它要将虚拟页中的 1 表项标记为未映射,使之后任何对虚拟地址 4096 - 8191 的访问都将致使陷入。随后把虚拟页面 8 的表项的叉号改成 1,所以在引发陷阱的指令从新启动时,它将把虚拟地址 32780 映射为物理地址(4096 + 12)。

下面查看一下 MMU 的内部构造以便了解它们是如何工做的,以及了解为何咱们选用的页大小都是 2 的整数次幂。下图咱们能够看到一个虚拟地址的例子

虚拟地址 8196 (二进制 0010000000000100)用上面的页表映射图所示的 MMU 映射机制进行映射,输入的 16 位虚拟地址被分为 4 位的页号和 12 位的偏移量。4 位的页号能够表示 16 个页面,12 位的偏移能够为一页内的所有 4096 个字节。

可用页号做为页表(page table) 的索引,以得出对应于该虚拟页面的页框号。若是在/不在位则是 0 ,则引发一个操做系统陷入。若是该位是 1,则将在页表中查到的页框号复制到输出寄存器的高 3 位中,再加上输入虚拟地址中的低 12 位偏移量。如此就构成了 15 位的物理地址。输出寄存器的内容随即被做为物理地址送到总线。

页表

在上面这个简单的例子中,虚拟地址到物理地址的映射能够总结以下:虚拟地址被分为虚拟页号(高位部分)偏移量(低位部分)。例如,对于 16 位地址和 4 KB 的页面大小,高 4 位能够指定 16 个虚拟页面中的一页,而低 12 位接着肯定了所选页面中的偏移量(0-4095)。

虚拟页号可做为页表的索引用来找到虚拟页中的内容。由页表项能够找到页框号(若是有的话)。而后把页框号拼接到偏移量的高位端,以替换掉虚拟页号,造成物理地址。

所以,页表的目的是把虚拟页映射到页框中。从数学上说,页表是一个函数,它的参数是虚拟页号,结果是物理页框号。

经过这个函数能够把虚拟地址中的虚拟页转换为页框,从而造成物理地址。

页表项的结构

下面咱们探讨一下页表项的具体结构,上面你知道了页表项的大体构成,是由页框号和在/不在位构成的,如今咱们来具体探讨一下页表项的构成

页表项的结构是与机器相关的,可是不一样机器上的页表项大体相同。上面是一个页表项的构成,不一样计算机的页表项可能不一样,可是通常来讲都是 32 位的。页表项中最重要的字段就是页框号(Page frame number)。毕竟,页表到页框最重要的一步操做就是要把此值映射过去。下一个比较重要的就是在/不在位,若是此位上的值是 1,那么页表项是有效的而且可以被使用。若是此值是 0 的话,则表示该页表项对应的虚拟页面不在内存中,访问该页面会引发一个缺页异常(page fault)

保护位(Protection) 告诉咱们哪种访问是容许的,啥意思呢?最简单的表示形式是这个域只有一位,0 表示可读可写,1 表示的是只读

修改位(Modified)访问位(Referenced) 会跟踪页面的使用状况。当一个页面被写入时,硬件会自动的设置修改位。修改位在页面从新分配页框时颇有用。若是一个页面已经被修改过(即它是 的),则必须把它写回磁盘。若是一个页面没有被修改过(即它是 干净的),那么从新分配时这个页框会被直接丢弃,由于磁盘上的副本仍然是有效的。这个位有时也叫作 脏位(dirty bit),由于它反映了页面的状态。

访问位(Referenced) 在页面被访问时被设置,无论是读仍是写。这个值可以帮助操做系统在发生缺页中断时选择要淘汰的页。再也不使用的页要比正在使用的页更适合被淘汰。这个位在后面要讨论的页面置换算法中做用很大。

最后一位用于禁止该页面被高速缓存,这个功能对于映射到设备寄存器仍是内存中起到了关键做用。经过这一位能够禁用高速缓存。具备独立的 I/O 空间而不是用内存映射 I/O 的机器来讲,并不须要这一位。

在深刻讨论下面问题以前,须要强调一下:虚拟内存本质上是用来创造一个地址空间的抽象,能够把它理解成为进程是对 CPU 的抽象,虚拟内存的实现,本质是将虚拟地址空间分解成页,并将每一项映射到物理内存的某个页框。由于咱们的重点是如何管理这个虚拟内存的抽象。

加速分页过程

到如今咱们已经虚拟内存(virtual memory)分页(paging) 的基础,如今咱们能够把目光放在具体的实现上面了。在任何带有分页的系统中,都会须要面临下面这两个主要问题:

  • 虚拟地址到物理地址的映射速度必需要快
  • 若是虚拟地址空间足够大,那么页表也会足够大

第一个问题是因为每次访问内存都须要进行虚拟地址到物理地址的映射,全部的指令最终都来自于内存,而且不少指令也会访问内存中的操做数。

操做数:操做数是计算机指令中的一个组成部分,它规定了指令中进行数字运算的量 。操做数指出指令执行的操做所须要数据的来源。操做数是汇编指令的一个字段。好比,MOV、ADD 等。

所以,每条指令可能会屡次访问页表,若是执行一条指令须要 1 ns,那么页表查询须要在 0.2 ns 以内完成,以免映射成为一个主要性能瓶颈。

第二个问题是全部的现代操做系统都会使用至少 32 位的虚拟地址,而且 64 位正在变得愈来愈广泛。假设页大小为 4 KB,32 位的地址空间将近有 100 万页,而 64 位地址空间简直多到没法想象。

对大并且快速的页映射的须要成为构建计算机的一个很是重要的约束。就像上面页表中的图同样,每个表项对应一个虚拟页面,虚拟页号做为索引。在启动一个进程时,操做系统会把保存在内存中进程页表读副本放入寄存器中。

最后一句话是否是很差理解?还记得页表是什么吗?它是虚拟地址到内存地址的映射页表。页表是虚拟地址转换的关键组成部分,它是访问内存中数据所必需的。在进程启动时,执行不少次虚拟地址到物理地址的转换,会把物理地址的副本从内存中读入到寄存器中,再执行这一转换过程。

因此,在进程的运行过程当中,没必要再为页表而访问内存。使用这种方法的优点是简单并且映射过程当中不须要访问内存。缺点是 页表太大时,代价高昂,并且每次上下文切换的时候都必须装载整个页表,这样会形成性能的下降。鉴于此,咱们讨论一下加速分页机制和处理大的虚拟地址空间的实现方案

转换检测缓冲区

咱们首先先来一块儿探讨一下加速分页的问题。大部分优化方案都是从内存中的页表开始的。这种设计对效率有着巨大的影响。考虑一下,例如,假设一条 1 字节的指令要把一个寄存器中的数据复制到另外一个寄存器。在不分页的状况下,这条指令只访问一次内存,即从内存取出指令。有了分页机制后,会由于要访问页表而须要更多的内存访问。因为执行速度一般被 CPU 从内存中取指令和数据的速度所限制,这样的话,两次访问才能实现一次的访问效果,因此内存访问的性能会降低一半。在这种状况下,根本不会采用分页机制。

什么是 1 字节的指令?咱们以 8085 微处理器为例来讲明一下,在 8085 微处理中,一共有 3 种字节指令,它们分别是 1-byte(1 字节)2-byte(2 字节)3-byte(3 字节),咱们分别来讲一下

1-byte:1 字节的操做数和操做码共同以 1 字节表示;操做数是内部寄存器,并被编码到指令中;指令须要一个存储位置来将单个寄存器存储在存储位置中。没有操做数的指令也是 1-byte 指令。

例如:MOV B,C 、LDAX B、NOP、HLT(这块不明白的读者能够自行查阅)

2-byte: 2 字节包括:第一个字节指定的操做码;第二个字节指定操做数;指令须要两个存储器位置才能存储在存储器中。

例如 MVI B, 26 H、IN 56 H

3-byte: 在 3 字节指令中,第一个字节指定操做码;后面两个字节指定 16 位的地址;第二个字节保存低位地址;第三个字节保存 高位地址。指令须要三个存储器位置才能将单个字节存储在存储器中。

例如 LDA 2050 H、JMP 2085 H

大多数程序老是对少许页面进行屡次访问,而不是对大量页面进行少许访问。所以,只有不多的页面可以被再次访问,而其余的页表项不多被访问。

页表项通常也被称为 Page Table Entry(PTE)

基于这种设想,提出了一种方案,即从硬件方面来解决这个问题,为计算机设置一个小型的硬件设备,可以将虚拟地址直接映射到物理地址,而没必要再访问页表。这种设备被称为转换检测缓冲区(Translation Lookaside Buffer, TLB),有时又被称为 相联存储器(associate memory)

有效位 虚拟页面号 修改位 保护位 页框号
1 140 1 RW 31
1 20 0 R X 38
1 130 1 RW 29
1 129 1 RW 62
1 19 0 R X 50
1 21 0 R X 45
1 860 1 RW 14
1 861 1 RW 75

​ TLB 加速分页

TLB 一般位于 MMU 中,包含少许的表项,每一个表项都记录了页面的相关信息,除了虚拟页号外,其余表项都和页表是一一对应的

是否是你到如今仍是有点不理解什么是 TLB,TLB 其实就是一种内存缓存,用于减小访问内存所须要的时间,它就是 MMU 的一部分,TLB 会将虚拟地址到物理地址的转换存储起来,一般能够称为地址翻译缓存(address-translation cache)。TLB 一般位于 CPU 和 CPU 缓存之间,它与 CPU 缓存是不一样的缓存级别。下面咱们来看一下 TLB 是如何工做的。

当一个 MMU 中的虚拟地址须要进行转换时,硬件首先检查虚拟页号与 TLB 中全部表项进行并行匹配,判断虚拟页是否在 TLB 中。若是找到了有效匹配项,而且要进行的访问操做没有违反保护位的话,则将页框号直接从 TLB 中取出而不用再直接访问页表。若是虚拟页在 TLB 中可是违反了保护位的权限的话(好比只容许读可是是一个写指令),则会生成一个保护错误(protection fault) 返回。

上面探讨的是虚拟地址在 TLB 中的状况,那么若是虚拟地址再也不 TLB 中该怎么办?若是 MMU 检测到没有有效的匹配项,就会进行正常的页表查找,而后从 TLB 中逐出一个表项而后把从页表中找到的项放在 TLB 中。当一个表项被从 TLB 中清除出,将修改位复制到内存中页表项,除了访问位以外,其余位保持不变。当页表项从页表装入 TLB 中时,全部的值都来自于内存。

软件 TLB 管理

直到如今,咱们假设每台电脑都有能够被硬件识别的页表,外加一个 TLB。在这个设计中,TLB 管理和处理 TLB 错误彻底由硬件来完成。仅仅当页面不在内存中时,才会发生操做系统的陷入(trap)

在之前,咱们上面的假设一般是正确的。可是,许多现代的 RISC 机器,包括 SPARC、MIPS 和 HP PA,几乎全部的页面管理都是在软件中完成的。

精简指令集计算机或 RISC 是一种计算机指令集,它使计算机的微处理器的每条指令(CPI)周期比复杂指令集计算机(CISC)少

在这些计算机上,TLB 条目由操做系统显示加载。当发生 TLB 访问丢失时,再也不是由 MMU 到页表中查找并取出须要的页表项,而是生成一个 TLB 失效并将问题交给操做系统解决。操做系统必须找到该页,把它从 TLB 中移除(移除页表中的一项),而后把新找到的页放在 TLB 中,最后再执行先前出错的指令。然而,全部这些操做都必须经过少许指令完成,由于 TLB 丢失的发生率要比出错率高不少。

不管是用硬件仍是用软件来处理 TLB 失效,常见的方式都是找到页表并执行索引操做以定位到将要访问的页面,在软件中进行搜索的问题是保存页表的页可能不在 TLB 中,这将在处理过程当中致使其余 TLB 错误。改善方法是能够在内存中的固定位置维护一个大的 TLB 表项的高速缓存来减小 TLB 失效。经过首先检查软件的高速缓存,操做系统 可以有效的减小 TLB 失效问题。

TLB 软件管理会有两种 TLB 失效问题,当一个页访问在内存中而不在 TLB 中时,将产生 软失效(soft miss),那么此时要作的就是把页表更新到 TLB 中(咱们上面探讨的过程),而不会产生磁盘 I/O,处理仅仅须要一些机器指令在几纳秒的时间内完成。然而,当页自己不在内存中时,将会产生硬失效(hard miss),那么此时就须要从磁盘中进行页表提取,硬失效的处理时间一般是软失效的百万倍。在页表结构中查找映射的过程称为 页表遍历(page table walk)

上面的这两种状况都是理想状况下出现的现象,可是在实际应用过程当中状况会更加复杂,未命中的状况可能既不是硬失效又不是软失效。一些未命中可能更或更(偷笑)。好比,若是页表遍历的过程当中没有找到所须要的页,那么此时会出现三种状况:

  • 所需的页面就在内存中,可是却没有记录在进程的页表中,这种状况多是由其余进程从磁盘掉入内存,这种状况只须要把页正确映射就能够了,而不须要在从硬盘调入,这是一种软失效,称为 次要缺页错误(minor page fault)
  • 基于上述状况,若是须要从硬盘直接调入页面,这就是严重缺页错误(major page falut)
  • 还有一种状况是,程序可能访问了一个非法地址,根本无需向 TLB 中增长映射。此时,操做系统会报告一个 段错误(segmentation fault) 来终止程序。只有第三种缺页属于程序错误,其余缺页状况都会被硬件或操做系统以下降程序性能为代价来修复

针对大内存的页表

还记得咱们讨论的是什么问题吗?(捂脸),可能讨论的太多你有所不知道了,我再提醒你一下,上面加速分页过程讨论的是虚拟地址到物理地址的映射速度必需要快的问题,还有一个问题是 若是虚拟地址空间足够大,那么页表也会足够大的问题,如何处理巨大的虚拟地址空间,下面展开咱们的讨论。

多级页表

第一种方案是使用多级页表(multi),下面是一个例子

32 位的虚拟地址被划分为 10 位的 PT1 域,10 位的 PT2 域,还有 12 位的 Offset 域。由于偏移量是 12 位,因此页面大小是 4KB,公有 2^20 次方个页面。

引入多级页表的缘由是避免把所有页表一直保存在内存中。不须要的页表就不该该保留

多级页表是一种分页方案,它由两个或多个层次的分页表组成,也称为分层分页。级别1(level 1)页面表的条目是指向级别 2(level 2) 页面表的指针,级别2页面表的条目是指向级别 3(level 3) 页面表的指针,依此类推。最后一级页表存储的是实际的信息。

下面是一个二级页表的工做过程

在最左边是顶级页表,它有 1024 个表项,对应于 10 位的 PT1 域。当一个虚拟地址被送到 MMU 时,MMU 首先提取 PT1 域并把该值做为访问顶级页表的索引。由于整个 4 GB (即 32 位)虚拟地址已经按 4 KB 大小分块,因此顶级页表中的 1024 个表项的每个都表示 4M 的块地址范围。

由索引顶级页表获得的表项中含有二级页表的地址或页框号。顶级页表的表项 0 指向程序正文的页表,表项 1 指向含有数据的页表,表项 1023 指向堆栈的页表,其余的项(用阴影表示)表示没有使用。如今把 PT2 域做为访问选定的二级页表的索引,以便找到虚拟页面的对应页框号。

倒排页表

针对分页层级结构中不断增长的替代方法是使用 倒排页表(inverted page tables)。采用这种解决方案的有 PowerPC、UltraSPARC 和 Itanium。在这种设计中,实际内存中的每一个页框对应一个表项,而不是每一个虚拟页面对应一个表项。

虽然倒排页表节省了大量的空间,可是它也有本身的缺陷:那就是从虚拟地址到物理地址的转换会变得很困难。当进程 n 访问虚拟页面 p 时,硬件不能再经过把 p 看成指向页表的一个索引来查找物理页。而是必须搜索整个倒排表来查找某个表项。另外,搜索必须对每个内存访问操做都执行一次,而不是在发生缺页中断时执行。

解决这一问题的方式时使用 TLB。当发生 TLB 失效时,须要用软件搜索整个倒排页表。一个可行的方式是创建一个散列表,用虚拟地址来散列。当前全部内存中的具备相同散列值的虚拟页面被连接在一块儿。以下图所示

若是散列表中的槽数与机器中物理页面数同样多,那么散列表的冲突链的长度将会是 1 个表项的长度,这将会大大提升映射速度。一旦页框被找到,新的(虚拟页号,物理页框号)就会被装在到 TLB 中。

页面置换算法

当发生缺页异常时,操做系统会选择一个页面进行换出从而为新进来的页面腾出空间。若是要换出的页面在内存中已经被修改,那么必须将其写到磁盘中以使磁盘副本保持最新状态。若是页面没有被修改过,而且磁盘中的副本也已是最新的,那么就不须要进行重写。那么就直接使用调入的页面覆盖须要移除的页面就能够了。

当发生缺页中断时,虽然能够随机的选择一个页面进行置换,可是若是每次都选择一个不经常使用的页面会提高系统的性能。若是一个常用的页面被换出,那么这个页面在短期内又可能被重复使用,那么就可能会形成额外的性能开销。在关于页面的主题上有不少页面置换算法(page replacement algorithms),这些已经从理论上和实践上获得了证实。

须要指出的是,页面置换问题在计算机的其余领域中也会出现。例如,多数计算机把最近使用过的 32 字节或者 64 字节的存储块保存在一个或多个高速缓存中。当缓存满的时候,一些块就被选择和移除。这些块的移除除了花费时间较短外,这个问题同页面置换问题彻底同样。之因此花费时间较短,是由于丢掉的高速缓存能够从内存中获取,而内存没有寻找磁道的时间也不存在旋转延迟。

第二个例子是 Web 服务器。服务器会在内存中缓存一些常用到的 Web 页面。然而,当缓存满了而且已经引用了新的页面,那么必须决定退出哪一个 Web 页面。在高速缓存中的 Web 页面不会被修改。所以磁盘中的 Web 页面常常是最新的,一样的考虑也适用在虚拟内存中。在虚拟系统中,内存中的页面可能会修改也可能不会修改。

下面咱们就来探讨一下有哪些页面置换算法。

最优页面置换算法

最优的页面置换算法很容易描述但在实际状况下很难实现。它的工做流程以下:在缺页中断发生时,这些页面之一将在下一条指令(包含该指令的页面)上被引用。其余页面则可能要到 十、100 或者 1000 条指令后才会被访问。每一个页面均可以用在该页首次被访问前所要执行的指令数做为标记。

最优化的页面算法代表应该标记最大的页面。若是一个页面在 800 万条指令内不会被使用,另一个页面在 600 万条指令内不会被使用,则置换前一个页面,从而把须要调入这个页面而发生的缺页中断推迟。计算机也像人类同样,会把不肯意作的事情尽量的日后拖。

这个算法最大的问题时没法实现。当缺页中断发生时,操做系统没法知道各个页面的下一次将在何时被访问。这种算法在实际过程当中根本不会使用。

最近未使用页面置换算法

为了可以让操做系统收集页面使用信息,大部分使用虚拟地址的计算机都有两个状态位,R 和 M,来和每一个页面进行关联。每当引用页面(读入或写入)时都设置 R,写入(即修改)页面时设置 M,这些位包含在每一个页表项中,就像下面所示

由于每次访问时都会更新这些位,所以由硬件来设置它们很是重要。一旦某个位被设置为 1,就会一直保持 1 直到操做系统下次来修改此位。

若是硬件没有这些位,那么能够使用操做系统的缺页中断时钟中断机制来进行模拟。当启动一个进程时,将其全部的页面都标记为不在内存;一旦访问任何一个页面就会引起一次缺页中断,此时操做系统就能够设置 R 位(在它的内部表中),修改页表项使其指向正确的页面,并设置为 READ ONLY 模式,而后从新启动引发缺页中断的指令。若是页面随后被修改,就会发生另外一个缺页异常。从而容许操做系统设置 M 位并把页面的模式设置为 READ/WRITE

能够用 R 位和 M 位来构造一个简单的页面置换算法:当启动一个进程时,操做系统将其全部页面的两个位都设置为 0。R 位按期的被清零(在每一个时钟中断)。用来将最近未引用的页面和已引用的页面分开。

当出现缺页中断后,操做系统会检查全部的页面,并根据它们的 R 位和 M 位将当前值分为四类:

  • 第 0 类:没有引用 R,没有修改 M
  • 第 1 类:没有引用 R,已修改 M
  • 第 2 类:引用 R ,没有修改 M
  • 第 3 类:已被访问 R,已被修改 M

尽管看起来好像没法实现第一类页面,可是当第三类页面的 R 位被时钟中断清除时,它们就会发生。时钟中断不会清除 M 位,由于须要这个信息才能知道是否写回磁盘中。清除 R 但不清除 M 会致使出现一类页面。

NRU(Not Recently Used) 算法从编号最小的非空类中随机删除一个页面。此算法隐含的思想是,在一个时钟内(约 20 ms)淘汰一个已修改可是没有被访问的页面要比一个大量引用的未修改页面好,NRU 的主要优势是易于理解而且可以有效的实现

先进先出页面置换算法

另外一种开销较小的方式是使用 FIFO(First-In,First-Out) 算法,这种类型的数据结构也适用在页面置换算法中。由操做系统维护一个全部在当前内存中的页面的链表,最先进入的放在表头,最新进入的页面放在表尾。在发生缺页异常时,会把头部的页移除而且把新的页添加到表尾。

还记得缺页异常何时发生吗?咱们知道应用程序访问内存会进行虚拟地址到物理地址的映射,缺页异常就发生在虚拟地址没法映射到物理地址的时候。由于实际的物理地址要比虚拟地址小不少(参考上面的虚拟地址和物理地址映射图),因此缺页常常会发生。

先进先出页面多是最简单的页面替换算法了。在这种算法中,操做系统会跟踪链表中内存中的全部页。下面咱们举个例子看一下(这个算法我刚开始看的时候有点懵逼,后来才看懂,我仍是很菜)

  • 初始化的时候,没有任何页面,因此第一次的时候会检查页面 1 是否位于链表中,没有在链表中,那么就是 MISS,页面1 进入链表,链表的先进先出的方向如图所示。
  • 相似的,第二次会先检查页面 2 是否位于链表中,没有在链表中,那么页面 2 进入链表,状态为 MISS,依次类推。
  • 咱们来看第四次,此时的链表为 1 2 3 ,第四次会检查页面 2 是否位于链表中,通过检索后,发现 2 在链表中,那么状态就是 HIT,并不会再进行入队和出队操做,第五次也是同样的。
  • 下面来看第六次,此时的链表仍是 1 2 3,由于以前没有执行进入链表操做,页面 5 会首先进行检查,发现链表中没有页面 5 ,则执行页面 5 的进入链表操做,页面 2 执行出链表的操做,执行完成后的链表顺序为 2 3 5

第二次机会页面置换算法

咱们上面学到的 FIFO 链表页面有个缺陷,那就是出链和入链并不会进行 check 检查,这样就会容易把常用的页面置换出去,为了不这一问题,咱们对该算法作一个简单的修改:咱们检查最老页面的 R 位,若是是 0 ,那么这个页面就是最老的并且没有被使用,那么这个页面就会被马上换出。若是 R 位是 1,那么就清除此位,此页面会被放在链表的尾部,修改它的装入时间就像刚放进来的同样。而后继续搜索。

这种算法叫作 第二次机会(second chance)算法,就像下面这样,咱们看到页面 A 到 H 保留在链表中,并按到达内存的时间排序。

a)按照先进先出的方法排列的页面;b)在时刻 20 处发生缺页异常中断而且 A 的 R 位已经设置时的页面链表。

假设缺页异常发生在时刻 20 处,这时最老的页面是 A ,它是在 0 时刻到达的。若是 A 的 R 位是 0,那么它将被淘汰出内存,或者把它写回磁盘(若是它已经被修改过),或者只是简单的放弃(若是它是未被修改过)。另外一方面,若是它的 R 位已经设置了,则将 A 放到链表的尾部而且从新设置装入时间为当前时刻(20 处),而后清除 R 位。而后从 B 页面开始继续搜索合适的页面。

寻找第二次机会的是在最近的时钟间隔中未被访问过的页面。若是全部的页面都被访问过,该算法就会被简化为单纯的 FIFO 算法。具体来讲,假设图 a 中全部页面都设置了 R 位。操做系统将页面依次移到链表末尾,每次都在添加到末尾时清除 R 位。最后,算法又会回到页面 A,此时的 R 位已经被清除,那么页面 A 就会被执行出链处理,所以算法可以正常结束。

时钟页面置换算法

即便上面提到的第二次页面置换算法也是一种比较合理的算法,但它常常要在链表中移动页面,既下降了效率,并且这种算法也不是必须的。一种比较好的方式是把全部的页面都保存在一个相似钟面的环形链表中,一个表针指向最老的页面。以下图所示

当缺页错误出现时,算法首先检查表针指向的页面,若是它的 R 位是 0 就淘汰该页面,并把新的页面插入到这个位置,而后把表针向前移动一位;若是 R 位是 1 就清除 R 位并把表针前移一个位置。重复这个过程直到找到了一个 R 位为 0 的页面位置。了解这个算法的工做方式,就明白为何它被称为 时钟(clokc)算法了。

最近最少使用页面置换算法

最近最少使用页面置换算法的一个解释会是下面这样:在前面几条指令中频繁使用的页面和可能在后面的几条指令中被使用。反过来讲,已经好久没有使用的页面有可能在将来一段时间内仍不会被使用。这个思想揭示了一个能够实现的算法:在缺页中断时,置换未使用时间最长的页面。这个策略称为 LRU(Least Recently Used) ,最近最少使用页面置换算法。

虽然 LRU 在理论上是能够实现的,可是从长远看来代价比较高。为了彻底实现 LRU,会在内存中维护一个全部页面的链表,最频繁使用的页位于表头,最近最少使用的页位于表尾。困难的是在每次内存引用时更新整个链表。在链表中找到一个页面,删除它,而后把它移动到表头是一个很是耗时的操做,即便使用硬件来实现也是同样的费时。

然而,还有其余方法能够经过硬件实现 LRU。让咱们首先考虑最简单的方式。这个方法要求硬件有一个 64 位的计数器,它在每条指令执行完成后自动加 1,每一个页表必须有一个足够容纳这个计数器值的域。在每次访问内存后,将当前的值保存到被访问页面的页表项中。一旦发生缺页异常,操做系统就检查全部页表项中计数器的值,找到值最小的一个页面,这个页面就是最少使用的页面。

用软件模拟 LRU

尽管上面的 LRU 算法在原则上是能够实现的,可是不多有机器可以拥有那些特殊的硬件。上面是硬件的实现方式,那么如今考虑要用软件来实现 LRU 。一种能够实现的方案是 NFU(Not Frequently Used,最不经常使用)算法。它须要一个软件计数器来和每一个页面关联,初始化的时候是 0 。在每一个时钟中断时,操做系统会浏览内存中的全部页,会将每一个页面的 R 位(0 或 1)加到它的计数器上。这个计数器大致上跟踪了各个页面访问的频繁程度。当缺页异常出现时,则置换计数器值最小的页面。

NFU 最主要的问题是它不会忘记任何东西,想一下是否是这样?例如,在一个屡次(扫描)的编译器中,在第一遍扫描中频繁使用的页面会在后续的扫描中也有较高的计数。事实上,若是第一次扫描的执行时间刚好是各次扫描中最长的,那么后续遍历的页面的统计次数总会比第一次页面的统计次数。结果是操做系统将置换有用的页面而不是再也不使用的页面。

幸运的是只须要对 NFU 作一个简单的修改就可让它模拟 LRU,这个修改有两个步骤

  • 首先,在 R 位被添加进来以前先把计数器右移一位;
  • 第二步,R 位被添加到最左边的位而不是最右边的位。

修改之后的算法称为 老化(aging) 算法,下图解释了老化算法是如何工做的。

咱们假设在第一个时钟周期内页面 0 - 5 的 R 位依次是 1,0,1,0,1,1,(也就是页面 0 是 1,页面 1 是 0,页面 2 是 1 这样类推)。也就是说,在 0 个时钟周期到 1 个时钟周期之间,0,2,4,5 都被引用了,从而把它们的 R 位设置为 1,剩下的设置为 0 。在相关的六个计数器被右移以后 R 位被添加到 左侧 ,就像上图中的 a。剩下的四列显示了接下来的四个时钟周期内的六个计数器变化。

CPU正在以某个频率前进,该频率的周期称为时钟滴答时钟周期。一个 100Mhz 的处理器每秒将接收100,000,000个时钟滴答。

当缺页异常出现时,将置换(就是移除)计数器值最小的页面。若是一个页面在前面 4 个时钟周期内都没有被访问过,那么它的计数器应该会有四个连续的 0 ,所以它的值确定要比前面 3 个时钟周期内都没有被访问过的页面的计数器小。

这个算法与 LRU 算法有两个重要的区别:看一下上图中的 e,第三列和第五列

它们在两个时钟周期内都没有被访问过,在此以前的时钟周期内都引用了两个页面。根据 LRU 算法,若是须要置换的话,那么应该在这两个页面中选择一个。那么问题来了,我萌应该选择哪一个?如今的问题是咱们不知道时钟周期 1 到时钟周期 2 内它们中哪一个页面是后被访问到的。由于在每一个时钟周期内只记录了一位,因此没法区分在一个时钟周期内哪一个页面最先被引用,哪一个页面是最后被引用的。所以,咱们能作的就是置换页面3由于页面 3 在周期 0 - 1 内都没有被访问过,而页面 5 却被引用过

LRU 与老化以前的第 2 个区别是,在老化期间,计数器具备有限数量的位(这个例子中是 8 位),这就限制了以往的访问记录。若是两个页面的计数器都是 0 ,那么咱们能够随便选择一个进行置换。实际上,有可能其中一个页面的访问次数实在 9 个时钟周期之前,而另一个页面是在 1000 个时钟周期以前,可是咱们却没法看到这些。在实际过程当中,若是时钟周期是 20 ms,8 位通常是够用的。因此咱们常常拿 20 ms 来举例。

工做集页面置换算法

在最单纯的分页系统中,刚启动进程时,在内存中并无页面。此时若是 CPU 尝试匹配第一条指令,就会获得一个缺页异常,使操做系统装入含有第一条指令的页面。其余的错误好比 全局变量堆栈 引发的缺页异常一般会紧接着发生。一段时间之后,进程须要的大部分页面都在内存中了,此时进程开始在较少的缺页异常环境中运行。这个策略称为 请求调页(demand paging),由于页面是根据须要被调入的,而不是预先调入的。

在一个大的地址空间中系统的读全部的页面,将会形成不少缺页异常,所以会致使没有足够的内存来容纳这些页面。不过幸运的是,大部分进程不是这样工做的,它们都会以局部性方式(locality of reference) 来访问,这意味着在执行的任何阶段,程序只引用其中的一小部分。

一个进程当前正在使用的页面的集合称为它的 工做集(working set),若是整个工做集都在内存中,那么进程在运行到下一运行阶段(例如,编译器的下一遍扫面)以前,不会产生不少缺页中断。若是内存过小从而没法容纳整个工做集,那么进程的运行过程当中会产生大量的缺页中断,会致使运行速度也会变得缓慢。由于一般只须要几纳秒就能执行一条指令,而一般须要十毫秒才能从磁盘上读入一个页面。若是一个程序每 10 ms 只能执行一到两条指令,那么它将须要很长时间才能运行完。若是只是执行几条指令就会产生中断,那么就称做这个程序产生了 颠簸(thrashing)

在多道程序的系统中,一般会把进程移到磁盘上(即从内存中移走全部的页面),这样可让其余进程有机会占用 CPU 。有一个问题是,当进程想要再次把以前调回磁盘的页面调回内存怎么办?从技术的角度上来说,并不须要作什么,此进程会一直产生缺页中断直到它的工做集 被调回内存。而后,每次装入一个进程须要 20、100 甚至 1000 次缺页中断,速度显然太慢了,而且因为 CPU 须要几毫秒时间处理一个缺页中断,所以由至关多的 CPU 时间也被浪费了。

所以,很多分页系统中都会设法跟踪进程的工做集,确保这些工做集在进程运行时被调入内存。这个方法叫作 工做集模式(working set model)。它被设计用来减小缺页中断的次数的。在进程运行前首先装入工做集页面的这一个过程被称为 预先调页(prepaging),工做集是随着时间来变化的。

根据研究代表,大多数程序并非均匀的访问地址空间的,而访问每每是集中于一小部分页面。一次内存访问可能会取出一条指令,也可能会取出数据,或者是存储数据。在任一时刻 t,都存在一个集合,它包含所哟欧最近 k 次内存访问所访问过的页面。这个集合 w(k,t) 就是工做集。由于最近 k = 1次访问确定会访问最近 k > 1 次访问所访问过的页面,因此 w(k,t) 是 k 的单调递减函数。随着 k 的增大,w(k,t) 是不会无限变大的,由于程序不可能访问比所能容纳页面数量上限还多的页面。

事实上大多数应用程序只会任意访问一小部分页面集合,可是这个集合会随着时间而缓慢变化,因此为何一开始曲线会快速上升而 k 较大时上升缓慢。为了实现工做集模型,操做系统必须跟踪哪些页面在工做集中。一个进程从它开始执行到当前所实际使用的 CPU 时间总数一般称做 当前实际运行时间。进程的工做集能够被称为在过去的 t 秒实际运行时间中它所访问过的页面集合。

下面来简单描述一下工做集的页面置换算法,基本思路就是找出一个不在工做集中的页面并淘汰它。下面是一部分机器页表

由于只有那些在内存中的页面才能够做为候选者被淘汰,因此该算法忽略了那些不在内存中的页面。每一个表项至少包含两条信息:上次使用该页面的近似时间和 R(访问)位。空白的矩形表示该算法不须要其余字段,例如页框数量、保护位、修改位。

算法的工做流程以下,假设硬件要设置 R 和 M 位。一样的,在每一个时钟周期内,一个周期性的时钟中断会使软件清除 Referenced(引用)位。在每一个缺页异常,页表会被扫描以找出一个合适的页面把它置换。

随着每一个页表项的处理,都须要检查 R 位。若是 R 位是 1,那么就会将当前时间写入页表项的 上次使用时间域,表示的意思就是缺页异常发生时页面正在被使用。由于页面在当前时钟周期内被访问过,那么它应该出如今工做集中而不是被删除(假设 t 是横跨了多个时钟周期)。

若是 R 位是 0 ,那么在当前的时钟周期内这个页面没有被访问过,应该做为被删除的对象。为了查看是否应该将其删除,会计算其使用期限(当前虚拟时间 - 上次使用时间),来用这个时间和 t 进行对比。若是使用期限大于 t,那么这个页面就再也不工做集中,而使用新的页面来替换它。而后继续扫描更新剩下的表项。

然而,若是 R 位是 0 可是使用期限小于等于 t,那么此页应该在工做集中。此时就会把页面临时保存起来,可是会记生存时间最长(即上次使用时间的最小值)的页面。若是扫描完整个页表却没有找到适合被置换的页面,也就意味着全部的页面都在工做集中。在这种状况下,若是找到了一个或者多个 R = 0 的页面,就淘汰生存时间最长的页面。最坏的状况下是,在当前时钟周期内,全部的页面都被访问过了(也就是都有 R = 1),所以就随机选择一个页面淘汰,若是有的话最好选一个未被访问的页面,也就是干净的页面。

工做集时钟页面置换算法

当缺页异常发生后,须要扫描整个页表才能肯定被淘汰的页面,所以基本工做集算法仍是比较浪费时间的。一个对基本工做集算法的提高是基于时钟算法可是却使用工做集的信息,这种算法称为WSClock(工做集时钟)。因为它的实现简单而且具备高性能,所以在实践中被普遍应用。

与时钟算法同样,所需的数据结构是一个以页框为元素的循环列表,就像下面这样

最初的时候,该表是空的。当装入第一个页面后,把它加载到该表中。随着更多的页面的加入,它们造成一个环形结构。每一个表项包含来自基本工做集算法的上次使用时间,以及 R 位(已标明)和 M 位(未标明)。

与时钟算法同样,在每一个缺页异常时,首先检查指针指向的页面。若是 R 位被是设置为 1,该页面在当前时钟周期内就被使用过,那么该页面就不适合被淘汰。而后把该页面的 R 位置为 0,指针指向下一个页面,并重复该算法。该事件序列化后的状态参见图 b。

如今考虑指针指向的页面 R = 0 时会发生什么,参见图 c,若是页面的使用期限大于 t 而且页面为被访问过,那么这个页面就不会在工做集中,而且在磁盘上会有一个此页面的副本。申请从新调入一个新的页面,并把新的页面放在其中,如图 d 所示。另外一方面,若是页面被修改过,就不能从新申请页面,由于这个页面在磁盘上没有有效的副本。为了不因为调度写磁盘操做引发的进程切换,指针继续向前走,算法继续对下一个页面进行操做。毕竟,有可能存在一个老的,没有被修改过的页面能够当即使用。

原则上来讲,全部的页面都有可能由于磁盘I/O 在某个时钟周期内被调度。为了下降磁盘阻塞,须要设置一个限制,即最大只容许写回 n 个页面。一旦达到该限制,就不容许调度新的写操做。

那么就有个问题,指针会绕一圈回到原点的,若是回到原点,它的起始点会发生什么?这里有两种状况:

  • 至少调度了一次写操做
  • 没有调度过写操做

在第一种状况中,指针仅仅是不停的移动,寻找一个未被修改过的页面。因为已经调度了一个或者多个写操做,最终会有某个写操做完成,它的页面会被标记为未修改。置换遇到的第一个未被修改过的页面,这个页面不必定是第一个被调度写操做的页面,由于硬盘驱动程序为了优化性能可能会把写操做重排序。

对于第二种状况,全部的页面都在工做集中,不然将至少调度了一个写操做。因为缺少额外的信息,最简单的方法就是置换一个未被修改的页面来使用,扫描中须要记录未被修改的页面的位置,若是不存在未被修改的页面,就选定当前页面并把它写回磁盘。

页面置换算法小结

咱们到如今已经研究了各类页面置换算法,如今咱们来一个简单的总结,算法的总结概括以下

算法 注释
最优算法 不可实现,但能够用做基准
NRU(最近未使用) 算法 和 LRU 算法很类似
FIFO(先进先出) 算法 有可能会抛弃重要的页面
第二次机会算法 比 FIFO 有较大的改善
时钟算法 实际使用
LRU(最近最少)算法 比较优秀,可是很难实现
NFU(最不常常食用)算法 和 LRU 很相似
老化算法 近似 LRU 的高效算法
工做集算法 实施起来开销很大
工做集时钟算法 比较有效的算法
  • 最优算法在当前页面中置换最后要访问的页面。不幸的是,没有办法来断定哪一个页面是最后一个要访问的,所以实际上该算法不能使用。然而,它能够做为衡量其余算法的标准。

  • NRU 算法根据 R 位和 M 位的状态将页面氛围四类。从编号最小的类别中随机选择一个页面。NRU 算法易于实现,可是性能不是很好。存在更好的算法。

  • FIFO 会跟踪页面加载进入内存中的顺序,并把页面放入一个链表中。有可能删除存在时间最长可是还在使用的页面,所以这个算法也不是一个很好的选择。

  • 第二次机会算法是对 FIFO 的一个修改,它会在删除页面以前检查这个页面是否仍在使用。若是页面正在使用,就会进行保留。这个改进大大提升了性能。

  • 时钟 算法是第二次机会算法的另一种实现形式,时钟算法和第二次算法的性能差很少,可是会花费更少的时间来执行算法。

  • LRU 算法是一个很是优秀的算法,可是没有特殊的硬件(TLB)很难实现。若是没有硬件,就不能使用 LRU 算法。

  • NFU 算法是一种近似于 LRU 的算法,它的性能不是很是好。

  • 老化 算法是一种更接近 LRU 算法的实现,而且能够更好的实现,所以是一个很好的选择

  • 最后两种算法都使用了工做集算法。工做集算法提供了合理的性能开销,可是它的实现比较复杂。WSClock 是另一种变体,它不只可以提供良好的性能,并且能够高效地实现。

总之,最好的算法是老化算法和WSClock算法。他们分别是基于 LRU 和工做集算法。他们都具备良好的性能而且可以被有效的实现。还存在其余一些好的算法,但实际上这两个多是最重要的。

文章参考:

https://www.informit.com/articles/article.aspx?p=25260&seqNum=9

https://gerardnico.com/computer/clock/tick

https://en.wikipedia.org/wiki/Page_replacement_algorithm

http://faculty.salina.k-state.edu/tim/ossg/Memory/virt_mem/page_replace.html

https://www.geeksforgeeks.org/page-replacement-algorithms-in-operating-systems/

https://www.geeksforgeeks.org/multilevel-paging-in-operating-system/

https://en.wikipedia.org/wiki/Translation_lookaside_buffer

https://electricalvoice.com/instruction-word-size-8085-microprocessor/

https://en.wikipedia.org/wiki/Page_table

https://www.javatpoint.com/os-page-table

https://baike.baidu.com/item/内存/103614?fr=aladdin

https://baike.baidu.com/item/数据段/5136260?fromtitle=data%20segment&fromid=18082638&fr=aladdin

https://blog.csdn.net/One_L_Star/article/details/81901186

《现代操做系统》第四版

《Modern Operation System》fourth

https://baike.baidu.com/item/SATA硬盘/3947233?fr=aladdin

https://baike.baidu.com/item/虚拟地址/1329947?fr=aladdin

原文出处:https://www.cnblogs.com/cxuanBlog/p/12448298.html

相关文章
相关标签/搜索