理解内存管理

1,212 阅读28分钟

内存管理是什么

计算机程序在编写好后,存放于与硬盘中,在程序执行时,需要把描述程序如何执行的信息载入到内存中,然后CPU执行这部分内容做计算。在这里就包含一个问题,为什么要把程序载入到内存中执行,而不是直接利用硬盘来做计算。可以这么说,计算机的整体性能取决于 CPU 和 RAM 工作在一起的程度。相对于 RAM,硬盘的读写速度就要慢非常多,这会使CPU浪费更多的等待时间。而更多的 RAM,意味更昂贵的造价。

一般来说,各种程序的大小总和,总是大于 RAM 容量并且远大于 RAM 容量大小的。那么在程序运行时,如何并该把哪些程序信息载入内存中,就成了必须要考虑的问题。也因此,内存管理是指:软件运行时对计算机内存资源的分配和使用的技术。

存储器抽象

在操作系统中,负责管理存储体系的部分称为存储器管理。在一开始,是没有存储器抽象的,每一个程序直接访问物理内存,也就是0到某个上限的内存集合的某位置。

无存储器抽象

在单进程中,这样做是没问题的。但是对于多进程,这样做却不可行。程序能直接访问物理内存,意味着读写操作不被约束,一个程序使用的某个内存地址,很可能被另一个程序更改,这样的错误是致命的。

把物理地址暴露给程序会有如下严重问题:

  • 程序可以很容易地(无论有意无意)破坏操作系统,即使只有一个程序在运行
  • 同时运行多个程序是困难的,程序对于读写的内容不可信任

因此,需要对程序屏蔽物理地址,进行存储器抽象,以此保护对运行的程序内容的保护和重定位。

地址空间

地址空间是指:一个进程可用于寻址内存的一套地址集合,且这个地址空间独立于其他进程的地址空间。有地址,就要知道地址的访问范围,这样就可以使用动态重定位,简单地把每个进程的地址空间映射到物理内存的不同部分呢。

经典的方法是,给CPU配置两个特殊硬件寄存器,基址寄存器界限寄存器基址寄存器和界限寄存器

界限寄存器存储程序的长度,基址寄存器存储程序的起始地址。比如,当程序要跳转到地址28是,执行指令

JMP 28

将被翻译成

JMP 16408 (16408 = 16380 + 28)

此方法实现地址空间容易,缺点为:每次访问内存都需要进行加法和比较运算,比较运算可以很快,但加法运算如果没有特殊电路支持,会由于进位传递而导致时间过慢。

虽然此方法容易时间,但隐含了一个条件,物理内存要足够大,大到足以容纳所有进程,而这却是不切实际的。

多个应用程序运行时,占用的总容量大小大于物理内存的大小视为更一般的情况,把所有进程一直保存在内存中不切实际,需要有一种方式来决定某一时间内什么程序应该存在内存中。通用的两种方法为 交换技术虚拟内存

交换技术

交换技术会把一个进程完整地载入内存中,然后运行一段时间,再把它存回磁盘。 交换技术内存分配

随着时间,进程交替载入内存,会在内存中产生多个空闲区(灰色部分),过多的小空闲区可能导致某次的分配没有合适的空间区域,因此将通过内存紧缩将各进程占用的内空间向下移动,使这些小空闲区合并成大的空闲区。但是,此操作需要耗费大量的CPU时间,并随着内存空间越大,耗时越多。

如果进程的数据段可以增长(许多语言都允许动态地从堆中分派内存),那么当进程空间试图增常时,会出现问题。若相邻的是一个空闲区,将空闲区进行分配即可;若相邻的是另一个进程,就需要把若干个进程交换出去,以产生更大的空闲区;更甚的是,如果进程在内存不能增长,并且磁盘上的交换区也满了,那么就只能挂起直到有空间空闲或是结束这个进程。

当大部分的程序都需要动态地分配内存时,问题就会变得尖锐。容易做到的是,可以预先为进程分配额外的内存,但是,当进程要被交换到磁盘时,应知交换实际上使用的内容。要动态分配内存,就需要对内存进行管理,记录哪部分内存被占用,哪部分内存空闲。可用的两种方法为位图的存储管理链表的存储管理

位图的内存管理

位图的存储管理

使用位图来记录内存使用情况是,会将内存分割成个存储单元,每单元可以为几个字到几千个字节均可。每单元对应位图的中一位,0表示空闲区,1表示被占用。分配单元的大小十分重要,越小则位图越大;越小则造成的浪费越多(为进程分配的单元的最后一块,往往没有占用完)。另一个缺点为,当进程要载入到内存是,需要通过位图搜索连续k个为0的单元,即可找到可存储的位置,但此操作耗时。

链表的存储管理

链表的存储管理

使用链表来记录已分配内存情况,每节点需要记录指示标志、起始位置、占用单元大小、下一节点。其中P为占用,H为空闲。使用链表使当进程交替时更新变得直接,但是寻找相邻节点是否可以合并耗时,如果不合并,随着时间将很能找不到合适的节点以存储进程。

要为进入内存的进程寻找可分配的内存区域时,有一些算法可对链表节点进行搜索。

首次适配算法 最简单的算法,搜索链表节点,找到第一个能分配的节点,然后将这个节点分成两部分,新的P节点和新的H节点。此算法最快,因它尽可能少地搜索。

下次适配算法 与首次适配算法有区别的是,下次适配算法会记录当前的空闲区,这样当需要分配内存时,将从记录的位置开始搜索而不是从头。

最佳适配算法 搜索整个链表,找到最合适的空闲区,避免大的H节点先分裂而导致后续找不到合适的空间区时,触发合并。意外的是,实际执行的结果,会产生很多的零碎H节点,浪费很多的内存。

快速适配算法 为常规大小的空闲区维护单独的链表,如4KB、8KB、12KB等个维护链表,这样,找到一个指定大小的空闲区将非常快。

无论哪种算法,共同的缺点都为,合并过程耗时。因此,考量一个算法的匹配程度时,更少地触发合并过程就成为重要的考核点。

虚拟内存

基址寄存器和界限寄存器能作为地址空间的抽象,但是解决不了总进程大小大于内存的问题;交换技术能解决总进程大小大于内存的问题,但随着程序大小的膨胀速度,每次完整地载入载出程序所花费的时间随之增长,耗时过多终将导致不可接受。

实际上,在程序的运行过程中,一段时间内只会执行一部分代码,把程序完成地载入内存实则没必要。那么,只要把程序分割成各个部分,在运行时只要把涉及到的部分载入内存即可。此时,虚拟内存粉墨登场。

虚拟内存是指,每个程序拥有自己的地址空间,这个空间被分割成多块,每一块称作一页或页面。每一页有连续的地址范围。在程序执行时,只要把页映射到物理内存,然后继续执行程序即可。虚拟内存的实现,一般使用分页技术分段技术

分页

每个程序认为自己拥有实际的地址空间,而这些地址是虚拟的,并没有实际地存在内存中,因此也叫虚拟地址空间。那么要执行某虚拟地址空间的代码时,就需要把这部分内容映射到内存中。MMU(内存管理单元)就负责了这部分工作,把虚拟地址映射到物理内存。

MMU映射

虚拟地址按照固定大小或成各个单元,即页面,而在物理地址中,对应的单元成为页框。首先,CPU 将页面的虚拟地址发送给 MMU, MMU 转换出可用的物理地址发送给存储器,就得到了页面所在的页框,也就是实际被装入的物理地址。

如上图中,指令

MOV REG,8192 将转换为 MOV REG,16384 (虚拟地址8K~12K 处于页框4,也就是物理地址16K~20K)

通过合理地MMU管理,可以把比页框多得多的页面进行映射。当然,因为页面更多,就意味着页面可能会被换入换出,也就需要在硬件上有 Bit 位标志页面在不在内存中。当要访问的页面不再内存中时,CPU 陷入内核,称为缺页中断缺页错误,然后,MMU挑选出一个合适的页框,把旧的页面换出,新的页面换入,修改映射关系,最后重新执行引起缺页中断的指令。

页表

有了页面和页框之后,还要知道如何去寻找他们,寻找的方式便是通过页表进行查找。页表是一种特殊的数据结构,根据机器的不同可能不太一样,但页表项记录的信息大致相同,每一个页表项纪录一个页面:

  • 高速缓存禁用位:是否禁用高速缓存,有时需要直接地从I/O设备读取数据,而不是高速缓存中
  • 访问位:允许的访问类型,三位Bit,表示读、写、执行
  • 修改位:表示页面是否已经被修改过,修改过要写回磁盘,也叫脏位
  • 在/不在位:表示页面是否在内存中
  • 页框号:所在的页框位置

加速分页

有了对于分页的认识之后,接踵而至的两个问题是:

  1. 虚拟地址到物理地址的映射必须非常快
  2. 如果虚拟地址空间非常大,页表也就会非常大

对于第一个问题,每次访问内存都需要进行映射,并且大多数指令会访问内存中的操作数,因此每条指令进行一两次或更多的页表访问是必须的,那么,页表查询时间必须低于执行时间的1/5,否则映射将成为主要瓶颈。

对于第二个问题,每个进程需要有自己的页表,一个32位的地址空间能表达100万页,一个64位的地址空间能表达天文数字多的页表。如果把页表都放到内存中,那么当程序足够大时,进程上下文切换时页表的载入载出代价昂贵。

解决或缓和这两个问题势在必行,否则分页也难应用于实际。TLB多级页表应运而生。

TLB (转换检测缓冲区)

高价值的解法总是相通的 —— 为更可能被访问的单元提供更长的常驻周期是高效且值得的,这也是进行缓冲的意义之一。有趣的是,基于观察,发现大多数程序在一个可预期的时间内,总是对个别页面进行更多的访问而不是相反,对应的,少数页表将被多次访问。

计算机中设置了一个小型的硬件设备来进行映射,不必在访问页表,这设备即是TLB(转换检查缓冲区),也叫快表。TLB通常置于MMU中,仅包含很少的表项,通常不超过256个。

TLB中每个表项的重要信息记录项为:

  • 有效位:是否还能通过此表项访问页面
  • 虚拟页面号:虚拟地址来源
  • 修改位:即脏位,被修改时,置换出时要写回磁盘
  • 保护位:访问方式,读、写、执行
  • 页框号:处于的页框位置

现在,访问页面时,若发生了缺页中断,TLB将淘汰一个合适的表项,MMU进行新页面的映射,TLB更新新表项。当要访问的页面记录在TLB时,则不必MMU的参与,TLB仅做简单的统计更新。

当然,也提供了软件TLB。区别在于,软件TLB失效,称为软失效,要访问的页面没有记录在软件TLB中,更新软件TLB即可,不发生磁盘I/O。硬件发生的TLB失效,称为硬失效,需要发生磁盘I/O。硬失效的处理时间时软失效的处理时间的百万倍,因会发生页表遍历。

多级页表

TLB解决了多次交换高频访问的页面带来的问题,另一个要解决的问题时,如果去表达巨大的虚拟地址空间。多级页表即为解决方法之一。

多级页表避免了将全部页表保存在内存中,只保存当前合适的页表。 多级页表

以一个32位的虚拟地址为例,MMU收到了虚拟地址,首先将PT1的值作为索引,找到顶级页表,再以PT2的值,通过顶级页表找到二级页表,最后,通过Offset的值找到页表项,处理对应的映射逻辑。

相对于单页表,多级页表好处如下:

  1. 单页表需要一次性把页表存入内存中,页表占用的内存大小完全取决于程序大小。而多级页表,把单页表分割成了大小相同的多个页表,需要时,只需把要用的页表载入内存即可
  2. 虽然未映射的虚拟地址空间没有占用内存,但是存储上,虚拟地址空间所代表的实际存储的内容,需要连续的磁盘地址空间进行存储。在单页表情况下,连续空间的大小,取决于程序的大小。多级页表,则可以将程序内容分散存储,因为实际上,分割出的二级页表也代表着可以将实际存储的连续地址空间分割成了一块块更小的连续地址空间
  3. 当然,多级页表比单页表需要更多的寻址次数,延长了访问时间,而牺牲了一些访问时间降低与日俱增的内存压力,多数场景下,是值得的
  4. 多级页表还可以分解成更多层

分页置换算法

既已知分页技术对于实现虚拟内存如何工作,那么接下来的问题就是,当发生缺页中断,不得不从页框中挑选出合适的位置时,要如何确定合适的位置,也就是页面置换算法。

NUR(最近未使用页面置换算法)

大部分具有虚拟内存的计算机中,会为每一页面设置两个状态位,R位和M位,前者被读写时置为1,后者被修改时至为1。因此对于页框中的页面,有四种状态:

  1. 没有被访问,没有被修改
  2. 没有被访问,有被修改
  3. 已被访问,没有被修改
  4. 已被访问,已被修改

NRU 算法随机地从1、2中状态的页面中选择一个淘汰。此算法性能并不是最好,但足够使用,容易实现。

FIFO (先进先出页面算法)

顾名思义,维护一个页面链表,此算法淘汰内存中最老的页面,也就是链表头。缺点也很明显,被淘汰的页面很可能是很常用的页面。

第二次机会页面置换算法

FIFO的升级版,FIFO很可能淘汰常用的页面,那么在此算法中,会检查老页面的R位。如果R位为0,说明没有被使用,可以立刻置换;如果为1,那么就将老页面放到链表尾端;如果所有页面都被访问过,此算法就是纯粹的FIFO。

在每个时钟滴答时,会将所有的R位复位。

时钟页面置换算法

时钟页面置换算法

第二次机会页面置换算法需要不断地移动页面到链表尾,即无必要,又降低了效率。时钟页面置换算法维护了一个环形链表,并用表指针指一个位置。当发生缺页中断时,如果当前位置页面的R位为0,置换当前页,然后表指针向前移一位;如果当前页R位为1,那么R位置为0,表指针向前移动一位继续寻找。表指针会把访问过的页面的R位置为0,因此,总会找到一个位置将新的页面置换进来。

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

淘汰最少被访问的页面,理论上可以实现的算法,但代价很高。使用链表维护的页面,要此算法,需要在页面被访问时,将页面移动到链表的末端。如此频繁的删除添加操作时不可接受的。

当然,在硬件上,可以提供一个64位的计数器,页表项需要容纳这部分内容,每次访问时,计数器加1。放生页面中断时,检查所有页表项中的计数器的值,找到一个值最小的页面淘汰。

工作集页面置换算法

工作集模型基于这样的一种假设:程序在一段时间的执行过程中,会对某一些页面进行高频的访问。而在实际的程序运行中,也表现出了这样局部性。

工作集页面置换算法 既然如此,那么程序在执行的K次内存访问所访问过的页面,任意时刻t,都有W个页面来表示所有访问过的页面集合。因此,工作集页面算法属于预测的算法,预测W集合中,哪个页面在接下来的时间段内,最不可能被访问到,当发生页面中断时,替换这个页面。在实现时

  • K的值是可以任意的
  • 内存访问统计的是各个程序的各自内存访问统计

工作集页面置换算法工作

工作集页面置换算法如下工作:设定一个定期的时钟重置R位,在页面被访问时,记录访问时间,置R位为1。此外设置一个时间T作为分割页面存活时间是否达到,要将其继续存于工作集中的比较标准。当发生缺页中断时,扫描工作集中的页面:

  1. 如果R为1,设置上次的使用时间为当前实际时间
  2. 如果R为0,且生存时间大于T,移除这个页面
  3. 如果R为0,且生存时间小于T,记住这其中的最大生存时间的页面

那么,如果有2,从2中置换;如果有2无3,置换其中的最大生存时间的页面;如果2和3都没有,那么随机淘汰一个页面。

工作算法的目的,旨在预测未来更可能会被使用的页面,使其存于工作集中,以此降低缺页中断的几率。

工作集时钟置换算法

工作集页面置换算法有个问题,发生缺页中断时,需要扫描所有的页面,才能找到应该淘汰的页面,比较耗时。工作集时钟页面算法对工作集页面置换做了改进。

工作集时钟页面置换算法

工作集时钟页面置换算法与时钟算法工作机制类似:

  • 当发生缺页中断时,如果当前指针指向的页面R=0 并且生存时间大于T,淘汰当前页面,指针向前移动
  • 如果 R=1,将R置为0,指针向前移动
  • 如果 M=1,说明页面发生过改变,此时无论如何,都应该先将此页面写回磁盘,指针继续向前

在此工作机制下,需要注意每次写回磁盘的页面数应有上限n,以降低磁盘阻塞。如果指针走过了一圈,将发生:

  1. 至少有一次写操作。此情况下,指针只要继续向前不停移动,终能寻找到一个干净页面,因写操作终会完成
  2. 没有发生写操作。此情况下,可以随意置换一个干净页面,在扫描过程提前记住这个干净页面即可

分页系统面对的问题

分页系统能解决程序占用内存大于物理内存的问题,但分页也要面对本身的设计目的所带来的问题。

页面大小

页面大小是可以选择的参数,无论页面大小如何,往往装载程序的最后一个页面大部分内存是浪费的,这种浪费也叫内部碎片,这是不可避免的。页面大,意味着更多的浪费;而页面小,意味着需要更多的页面更多的页表。内存与磁盘进行传输时,是一页一页传输的,更多的时间消耗在寻道和旋转延迟,也因此大页面、小页面的传输时间相差很少。小页面能更好地利用TLB。在决定页面大小时,这些都是需要考虑的因素,此消彼长。

如果从数学上分析看,假设进程平均大小是s个字节,页面大小是p个字节,每个页表项需要e个字节。进程需要的页数大约为 s/p,占用 se/p 个页表空间。内部碎片为 p/2。因此:

开销 = se/p + p/2

大页面时,内部碎片大;小页面时,页表大。因此最优值处于中间的某值去的。因此做一次求导,得

  • se / p^2 + 1/2 = 0 p = √(2se)

对于 s = 1MB 和 e=8B,最优页面大小是4KB。 当然,此公式作为参考,只考虑碎片浪费和页表所需的内存。

共享页面

大型多道程序设计系统中,几个不同的用户运行一个程序是常见的,这些程序共享页面无疑是效率更高的。对于只读来说,不会带来太多问题。对于写操作来说,要满足共享页面要使用更复杂的机制。

比如,将程序占用的空间分离为指令空间和数据空间,也称I空间和D空间,他们拥有各自的空间页表。程序的各进程相同的I空间,访问各自的D空间。

要共享数据会更麻烦,可使用写时复制,只有当某一个进程更新的数据时,触发只读保护,引发操作系统陷阱。然后生成此页的副本,每个进程都有自己的专用副本。此后,任何对副本的写操作不再触发陷阱。优点在于,对于很少会触发写操作的程序,往往不需要生成副本。

共享库

对于程序的实现过程,将引用各种库来协助完成功能。对于一些可重复使用到的库,如果所有使用它的程序都为他们在磁盘预留空间,并在程序使用时一同载入内存,则不仅浪费大量磁盘空间,也浪费内存空间。如果程序知道这些库可以共享,将大大避免此种浪费。

共享库的意义在于,如果有其他程序已经装载了某些库,那么当前程序在使用这些库的时候,就没必要再加载它了。当然,共享库也不是一次性地将整个库载入内从中,而是以页面为单位,使用到的函数载入内存,而定义了但却还未使用到的函数或引入的其他库可以先不载入内存。

共享库的好处在于:

  • 可执行文件更小,节省内存空间
  • 共享库BUG更新时,不需要重新编译调用了这个库的程序,程序下次启动时能使用修复后的广告共享库

共享库在分页系统中,需要注意的问题是,共享库并不隶属于任何一个程序,无论共享库处于程序虚拟空间的任何位置,通过MMU映射时,不能直接映射到绝对物理地址空间(各程序访问到共享库的虚拟地址空间不同),因为多程序使用共享库访问到的物理地址空间将不同。因此,在变异共享库时,编译器不能产生使用绝对地址的指令,而是使用相对地址。这样,访问共享库时就能正确访问共享库在内存中正确的相对地址。

内存映射文件

共享库实际是内存映射文件的子集。内存映射文件的思想是:进程可以通过发起系统调用,将一个文件映射到虚拟地址空间的一部分。多数实现中,映射的页面不会读入实际的内容,只有在访问时才会每次一页地读入。当进程退出或解除文件映射时,所有改动到的页面会被写回到磁盘。

内存映射文件的好处在于,如果两个程序映射了同一个文件,就可以通过共享内存进行通信。一个进程在共享内存上完成了写操作时,另一个进程在此文件映射的虚拟地址空间上执行读操作,就能读取到此文件的写操作结果。

清除策略

考虑一种情况,页框被占满了,并且所有的页面都被修改过,此时发生了缺页中断。若要将新页面载入内容,不能找到一个合适页面淘汰,需要等待这些被修改过的页面写回到磁盘中,在再次查询时,找到一个干净的页面为止。如果此时发生的缺页中断较多,那么需要等待的时间将变得客观。

为解决上面的问题,需要一个称为分页守护进程的后台进程,定期检查内存情况,如果空闲的页框过少,则通过预定的页面置换算法将一些页面换出内存。

一个可实现的清除策略为双指针时钟,前指针由分页守护进程空时,指向脏页面时,将其写回磁盘,后指针则向标准时钟算法一样工作。

指令备份

缺页中断可能发什么在指令执行的任意出,既可能是访问指令操作码时,也可能是访问操作数时。对于操作系统来说,要准确地判断指令是从哪儿发生中断时,指令时从哪里开始执行通常是不可能的。因此,为了保证缺页中断解决后,指令能够重新正确地执行,要对指令进行备份。如,通过使用一个隐藏的内部寄存器,在每条指令执行前,把程序计数器的内容复制到这个内部寄存器中。通过这部分信息,就能消除引起缺页中断的指令带来的影响。

分段

在分页中,虚拟地址是一维的,虚拟地址从0到某个最大地址,而对于一些问题来说,拥有多个独立的地址空间会比只有一个地址空间要好。

想象编译器对于程序的编译,在编译过程会建立许多表,如符号表、语法分析树、常量表、源程序正问、符号表等。如果随着编译过程或者运行过程,各表会进程增长并导致预期分配的虚拟地址空间不足时,需要一种程序员不用管理表的扩张和收缩的方法。

:指机器提供的互相独立的地址空间,每个段为0到最大的线性地址序列构成。 因为各个段的地址是独立的,在段上的增长和压缩不会影响与之共通构成程序的其他段内容。并且,段是一个逻辑实体,并且通常情况下足够大。对于段来说,共享变得简单,如共享库,可以把库涉及到的过程单独地放到一个段中,允许其他各个进程进行访问,而不需要在各个地址空间中都保存一份。

分段和分页的实现本质上是不同的。页面是定长的而段不是。段的问题在于,随着段的更新、交换,会在段与段之间产生一些空间区,也叫外部碎片。外部碎片使内存浪费,当然也可以通过内存紧缩来解决。

总结

内存管理,实际上是解决如何让一个正在执行的过程得以继续执行下去的问题。

  • 在一开始,相较于内存,程序不大,可以简单地把物理地址暴露给程序员,直接使用内存。
  • 接着发现,简单地暴露物理地址是不明智的行为,易造成安全问题。从而进行了存储器抽象,通过基址寄存器和界限寄存器来界定程序能访问的范围。
  • 而随着程序总大小的增大,基址寄存器和界限寄存器不能有效的将所有的程序放置于内存中,产生了交换技术,通过把要运行的程序载入到内存,暂未使用的程序再出到磁盘来缓解了这个问题。并可通过位图或链表来记录内存的使用情况。
  • 当单个程序的大小足够大时,交换技术整入整出的交换机制耗时过甚,需要有新的方式来解决耗时问题。再大多数程序表现出了”某时间内,会频繁访问某些过程“这一局部性,从而产生了虚拟空间地址来表达各个程序。通过分页技术,在运行时通过MMU把需要的部分页面载入到内存中。
  • 随之,为了让程序表现的局部性能更好地表达,产生了不同的页面置换算法,配合TLB,来让访问频率更高或者未来更可能被访问的页面,能更久地存在内存中,以此来降低因过多的缺页中断导致的页面载入载出带来的性能问题。

本文大多内容来自《现代操作系统 第四版》,错误之处,望请指出。

参考

内存管理

别再说你不懂Linux内存管理了,10张图给你安排的明明白白

为什么使用多级页表?

《现代操作系统 - 第四版》