当前位置: 首页 > news >正文

笔记:现代操作系统:原理与实现(3)

第四章 内存管理

操作系统究竟是如何让不同的应用程序能够既高效又安全地共同使用物理内存资源的?现代操作系统的一个普遍做法是在应用程序与物理内存之间加入一个新的抽象:虚拟内存

虚拟内存的设计具有如下三个方面的目标:

  • 高效性:一方面,虚拟内存抽象不能在应用程序运行过程中造成明显的性能开销;另一方面,虚拟内存抽象不应该占用过多的物理内存资源,从而导致物理内存的有效利用率(即存储应用程序数据的物理内存大小占总物理内存大小的比例)明显降低。
  • 安全性:虚拟内存抽象需要使不同应用程序的内存互相隔离,即一个应用程序只能访问属于自己的物理内存区域。
  • 透明性:虚拟内存抽象需要考虑到对应应用程序的透明性,使得应用程序开发者在编程时无须考虑虚拟内存抽象。

虚拟地址与物理地址

初识物理地址与虚拟地址

  • 物理地址:物理内存中的每个字节都可以通过与之唯—对应的物理地址进行访问。

在应用程序或操作系统运行过程中,CPU通过总线发送访问物理地址的请求,从内存中读取数据或者向其中写入数据。

CPU地址翻译示意图:

在这里插入图片描述

  • 虚拟地址:应用程序使用虚拟地址(virtual address)访问存储在内存中的数据和代码。在程序执行过程中,CPU 会把虚拟地址转换成物理地址,然后通过后者访问物理内存。
  • 地址翻译:虚拟地址转换成物理地址的过程。

使用虚拟地址访问物理内存

  • 内存管理单元(Memory Management Unit, MMU):CPU 中的重要部件,负责虚拟地址到物理地址的转换。
  • 转址旁路缓存(Translation Lookaside Buffer, TLB):为了加速地址翻译的过程,现代 CPU 都引入了转址旁路缓存(Translation Lookaside Buffer, TLB)。TLB 是属于 MMU 内部的单元。

分段与分页机制

  • 分段机制:在分段机制下,操作系统以“段”(一段连续的物理内存)的形式分配物理内存。应用程序的虚拟地址空间由若干个不同大小的段组成,比如代码段、数据段等。
  • 虚拟地址:虚拟地址由两部分构成,第一部分表示段号,第二部分表示段内地址,或称段内偏移,即相对于该段起始地址的偏移量。
  • 段表:段表中存储着一个虚拟地址空间中每一个分段的信息,其中包括段起始地址(对应于物理内存中段的起始物理地址)和段长。

在翻译虚拟地址的过程中,MMU 首先通过段表基址寄存器找到段表的位置,结合待翻译虚拟地址中的段号,可以在段表中定位到对应段的信息(步骤一);然后取出该段的起始地址(物理地址),加上待翻译虚拟地址中的段内地址(偏移量),就能够得到最终的物理地址(步骤二)。段表中还存有诸如段长(用于检查虚拟地址是否超出合法范围)等信息。

分段机制下地址翻译规则示意图:

在这里插入图片描述

由于分段机制中,相邻的段所对应的物理内存中的段可以不相邻,因此,操作系统能够实现物理内存资源的离散分配。但是,这种段式分配方式容易导致在物理内存上出现外部碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段),从而造成物理内存资源利用率的降低。

  • 分页机制:分页机制的基本思想是将应用程序的虚拟地址空间划分为连续的、等长的虚拟页(显著区别于分段机制下不同长度的段),同时物理内存也被划分成连续的、等长的物理页。虚拟页和物理页的页长固定且相等,从而使操作系统能够很方便地为每个应用程序构造页表,即虚拟页到物理页的映射关系表。
  • 虚拟地址:该机制下虚拟地址由两部分组成:第一部分标识着虚拟地址的虚拟页号;第二部分标识着虚拟地址的页内偏移量。

在具体的地址翻译过程中,MMU 首先解析得到虚拟地址中的虚拟页号,并通过虚拟页号去该应用程序的页表(页表起始地址存放在页表基址寄存器中)中找到对应条目,然后取出条目中存储的物理页号,最后用该物理页号对应的物理页起始地址加上虚拟地址中的页内偏移量得到最终的物理地址

由于分页机制按照固定页大小分配物理内存,使得物理内存资源易于管理,可有效避免分段机制中外碎片的问题。

分页机制下地址翻译规则示意图。图中所示页表为单级页表:

在这里插入图片描述

墓于分页的虚拟内存

当使用一张简单的单级页表来记录映射关系,那么对于 64 位的虚拟地址空间,这个页表有多大?假设页的大小为 4 KB,页表中的每一项大小为 8 字节(主要用于存储物理地址),那么一张页表的大小为 264/4KB×82^{64} /4KB \times 8264/4KB×8字节 ,即 33 554 432 GB!

相对应的是,在使用多级页表(假设为k级)的时候,一个虚拟地址中依然包括虚拟页号和页内偏移量,其中虚拟页号将进一步划分成k个部分(虚拟页号0 …虚拟页号k−1 ,0≤i<k)。虚拟页号i 对应于该虚拟地址在第i级页表中的索引。当任意一级页表中的某一个条目为空时,该条目对应的下一级页表不需要存在,依此类推,接下来的页表同样不需要存在。因此,多级页表的设计极大减少了页表占用的空间大小。

AArch64架构下的4级页表

对于包括 Linux 在内的主流操作系统上的用户地址空间来说,这个页表基址寄存器是 TTBR0_EL1。第 0 级(顶级)页表有且仅有一个页表页,页表基址寄存器存储的就是该页的物理地址。其余每一级页表拥有若干个离散的页表页,每一个页表页也占用物理内存中的一个物理页(4 KB)。每个页表项占用 8 个字节,用于存储物理地址和相应的访问权限,故一个页表页包含 512 个页表项(4 KB / 8 = 512)。由于 512 项对应于 9 位(292^929=512 ),因此虚拟地址中对应于每一级页表的索引都是 9 位。具体来说,一个 64 位的虚拟地址在逻辑上被划分成如下几个部分:

  • 第 63 至 48 位:全为 0 或者全为 1(硬件要求)。通常操作系统的选择是,应用程序使用的虚拟地址的这些位都是 0。同时也意味着应用程序的虚拟地址空间大小被限制在 2482^{48}248字节以内。
  • 第 47 至 39 位:这 9 位作为该虚拟地址在第 0 级页表中的索引值,对应于图中的虚拟页号0。
  • 第 38 至 30 位:这 9 位作为该虚拟地址在第 1 级页表中的索引值,对应于图 中的虚拟页号1。
  • 第 29 至 21 位:这 9 位作为该虚拟地址在第 2 级页表中的索引值,对应于图 中的虚拟页号2。
  • 第 20 至 12 位:这 9 位作为该虚拟地址在第 3 级页表中的索引值,对应于图 中的虚拟页号2。
  • 第 11 至 0 位:由于页的大小是 4 KB,所以低 12 位代表页内偏移量。

AArch64 体系结构中基于 4 级页表的地址翻译:

在这里插入图片描述

当MMU翻译一个虚拟地址的时候,首先根据页表基地址寄存器中的物理地址找到第0级页表页,然后将虚拟页号0作为页表索引读取第1级页表页,以此类推最后在虚拟页号3中的地址对应的物理地址加上页内偏移地址则为最终的物理地址。

加速地址翻译的重要硬件:TLB

多级页表结构能够显著地压缩页表大小,但是会导致地址翻译时长的增加。因此MMU 引入转址旁路缓存(Translation Lookaside Buffer, TLB)部件来加速地址翻译的过程。TLB 缓存了虚拟页号到物理页号的映射关系,MMU 会先把虚拟页号作为键去查询 TLB 中的缓存项,若找到则可直接获得对应的物理页号而无需再查询页表。我们称通过 TLB 能够直接完成地址翻译的过程为 TLB 命中(TLB hit);反之,为 TLB 未命中(TLB miss)。

TLB的结构示意图:

在这里插入图片描述

TLB 硬件采用分层的架构(类似于 CPU 缓存),分为 L1 和 L2 两层。其中 L1 又分为数据 TLB 和指令 TLB,分别用于缓存数据和指令的地址翻译结果;L2 不区分数据和指令(也存在分离的设计)。作为 CPU 内部的硬件部件,TLB 的体积实际上是极小的,这也意味着其缓存项的数量是极其有限的。

TLB刷新

为了避免“两个应用程序 A 和 B 使用了同样的虚拟地址 VA,但是对应于不同的物理地址 PA1 和 PA2。A或B访问TLB时造成内存访问错误“,操作系统在进行页表切换(应用程序切换)的时候需要主动刷新 TLB。

若操作系统在切换应用程序的过程中刷新 TLB,那么应用程序开始执行(被切换)的时候总是会发生 TLB 未命中的情况,进而不可避免地造成性能损失。那么是否能够避免在页表切换过程中 TLB 刷新的开销呢?一种为 TLB 缓存性能带来“标量”的设计正是为了避免切换时的开销。 ASID(Address Space Identifier)(ARM)与PCID(Process Context IDentifier)(X86)提供了解决方案。

具体来说,操作系统可以为不同的应用程序分配不同的 ASID 作为应用程序的身份标签,再将这个标签写入应用程序的页表基址寄存器中的空闲位(如 TTBR0_EL1 的高 16 位)。同时,TLB 中的缓存项也会包含 ASID 这个标签,从而使得 TLB 中属于不同应用程序的缓存项可以被区分开。因此,在切换应用程序的过程中,操作系统不再需要清空 TLB 缓存项。

在修改页表内容之后,操作系统还是需要主动刷新 TLB 以保证 TLB 缓存与页表项内容一致。在 AArch64 体系结构中提供了多种不同粒度的刷新 TLB 的指令,包括刷新全部 TLB、刷新指定 ASID 的 TLB、刷新指定虚拟地址的 TLB 等。操作系统可以根据不同场景选用合适的指令,最小化 TLB 刷新的开销,以获得更好的性能。

换页与缺页异常

  • 虚拟内存中的**换页机制(page swapping)**的基本思想是当物理内存容量不够的时候,操作系统应该把若干物理页的内容写到类似磁盘这种容量更大且更便宜的存储设备中,然后就可以回收这些物理页并继续使用了。
  • 缺页异常(page fault)是与换页机制密不可分的,也是换页机制能够工作的前提。当应用程序访问已分配但未映射至物理内存的虚拟页时,就会触发缺页异常。此时 CPU 会运行操作系统预先设置的缺页异常处理函数(page fault handler),该函数会找到(也可能是通过换页的方式)一个空闲的物理页,将之前写到磁盘上的数据内容重新加载到该物理页中,并且在页表中填写虚拟地址到这一物理页的映射。该过程被称为换入(swap in)。之后,CPU 可以回到发生缺页异常的地方继续运行。

换页机制中的换出与换入:

在这里插入图片描述

由于换页过程涉及耗时的磁盘操作,因此操作系统往往会引入预取机制(prefetching)进行优化。预取机制的想法是:当发生换入操作时,预测还有哪些页即将被访问,提前将它们一并换入物理内存,从而减少发生缺页异常的次数。

虚拟内存的按需页分配(demand paging)机制有了用武之地。当应用程序申请分配内存时,操作系统可选择将新分配的虚拟页标记为已分配但未映射至物理内存状态,而不必为这个虚拟页分配对应的物理页。当应用程序访问这个虚拟页的时候,会触发缺页异常,此时操作系统才真正为这个虚拟页分配对应的物理页,并且在页表中填入对应的映射。

按需页分配机制节约了物理内存,提高了资源利用率,但会导致访问延迟增加。可将按需页分配机制与预取机制搭配使用,降低访问延迟。

当一个虚拟页处于未分配状态或者已分配但未映射至物理内存状态的时候,应用程序访问该虚拟页都会触发缺页异常,操作系统是如何区分的呢?

由于应用程序通常使用虚拟地址空间中的一些区域(比如数据和代码、栈、堆等)对应于三个互不连续的虚拟内存区域,所以在 Linux 中,应用程序的虚拟地址空间被实现成由多个虚拟内存区域(Virtual Memory Area, VMA)组成的 数据结构。每个虚拟内存区域中包含该区域的起始虚拟地址、结束虚拟地址、访问权限等信息。当应用程序发生缺页异常时(假设访问虚拟页 P),操作系统(缺页异常处理函数)通过判断虚拟页 P 是否属于该应用程序的某个虚拟内存区域来区分该页所处的分配状态:若属于,则说明该页处于已分配但未映射至物理内存状态;若不属于,则说明该页处于未分配状态。

替换策赂

  • MIN策赂/OPT策略
    • MIN 策略(Minimum 策略)又称为 OPT 策略(Optimal 策略,最优策略)。这一策略在选择被换出的页时,优先选择未来不会再访问的页,或者在最长时间内不会再访问的页。该策略是理论最优的页替换策略,但在实际场景中很难实现。这是因为页访问顺序取决于应用程序,而操作系统通常无法预先得知应用程序未来访问页的顺序。该策略主要用来作为一个标准,衡量其他页替换策略的优劣。
  • FIFO策略
    • FIFO(First-In First-Out,先进先出)策略是最简单的页替换策略之一,同时也正因为简单所以其带来的时间开销很低。该策略优先选择最先换入的页进行换出。
  • Second Chance策略
    • Second Chance策略在FIFO策略的基础上增加了访问位,当访问的页号在队列中时,则置上其访问标志位。在此基础上,换出操作首先选择位于队首的页号,若页号访问标志位为0,则将其换出;若页号访问标志位为1,则将其标志位置0,并移动到队尾,以此类推。

Belady 异常是指在某些页面置换算法(如FIFO或Second Chance)中,增加物理内存页框数量后,缺页率反而上升的反直觉现象。

  • LRU策赂
    • LRU(Least Recently Used,最近最少使用)策略在选择被换出的页时,优先选择最近最久未被访问的页。该策略的出发点在于:过去数条指令频繁访问的页很可能在后续的数条指令中也会被频繁使用。如果实际访问情况确实如此,则 LRU 策略能够提供接近于 MIN 策略的效果。
  • MRU策略
    • 与 LRU 策略相反,MRU(Most Recently Used,最近最常使用)策略在替换内存页时,优先换出最近访问的页。该策略所基于的假设是:程序不会反复地访问相同的地址。例如,一个视频播放器在播放影视作品的时候,影视作品的每一帧数据都只会读取一次,不会重复读取。
  • 时钟算法策赂
    • 时钟算法(clock algorithm)将换入物理内存的页排成一个时钟的形状。该时钟有一个“针臂”,指向新换入内存的页的后一个页。同时,也为每个页维护一个访问标志位(访问位)。时钟算法与 Second Chance 替换策略相同,不过 Second Chance 需要将页从队头移到队尾,而时钟算法并不需要,所以后者的实现更加高效一些。

工作集模型

在选择和实现页替换策略的时候,若选择的页替换策略与实际的工作负载不匹配,则有可能导致颠簸(thrashing)现象,造成严重的性能损失。例如,一个具有很好局部性的场景下使用了 MRU 策略,导致最近使用的内存页被换出又被换入。

工作模型(working set model)能够有效地避免颠簸现象的发生。工作集的定义是:“一个应用程序在时刻 t 的工作集 W 为它在时间区间 [tx,t] 使用的内页集合,也就是它在未来(下 x 时间内)会访问的内存页集合”。该模型认为,应当将应用程序的工作集同时保持在物理内存中。因此,早期工作集模型的原则是 all-or-nothing。

那么如何高效地追踪工作集呢?一种常见的实现方法是工作集时钟算法。操作系统设置一个定时器,每隔固定时间调用工作集追踪函数。该追踪函数为每个内存页维护两个状态:上次使用时间和访问位,均被初始化为 0。每次追踪函数被调用时,会检查内存页状态,如果内存页访问位为1,则说明在时间间隔内被访问过,将访问时间更新为当前时间;如果访问位为0,则将当前时间减去上次的访问时间,如果结果大于时间间隔,则将该内存移出工作集。检查完一个页的状态之后,工作集追踪函数将其访问位设置为 0。

虚拟内存功能

共享内存

共享内存(shared memory)允许一个物理页在不同的应用程序间共享。例如,应用程序 A 的虚拟页 V1 被映射到物理页 P,若应用程序 B 的虚拟页 V2 也被映射到物理页 P,则物理页 P 是被应用程序 A 和应用程序 B 共享的。基于共享内存的思想,操作系统可以从中衍生出诸如拷贝(copy-on-write)、内存去重(memory deduplication)等功能。

在这里插入图片描述

写时拷贝

写时拷贝适用的例子:

  1. 多个应用程序存在大量相同的内存数据
  2. fork创建子进程时,父进程与子进程的内存数据和地址空间相同

一个页表项的大小是 64 位,其中使用 12 位用来存储物理地址(物理页号),剩下的位为属性位(attribute bit),包括用于标识该页是否可写、可执行等的权限位。写时拷贝正是利用表示“是否可写”的权限位来实现的。

在这里插入图片描述

写时拷贝技术允许应用程序 A 和应用程序 B 以只读的方式(在页表中清除可写位)共享同一段虚拟内存。一旦某个应用程序对该内存区域进行修改,就会触发缺页异常。操作系统会检测到缺页异常是由应用程序写了只读内容,于是,操作系统会在物理内存中将缺页异常对应的物理页重新拷贝一份,并且将新拷贝的物理页以可读可写的方式重新映射给触发异常的应用程序,此后再恢复应用程序的执行。

内存去重

基于写时拷贝机制,操作系统进一步设计了内存去重功能。操作系统可以定期地在内存中扫描具有相同内容的物理页,并找到映射到这些物理页的虚拟页;然后只保留其中一个物理页,将其具有相同内容的其他虚拟页都用写时拷贝的方式映射到这个物理页,然后释放其他的物理页以供将来使用。

Linux 操作系统就实现了该功能,称为 KSM(Kernel Same-page Merging)。内存去重功能会对应用程序写时延造成影响:当应用程序写一个被去重的内存页时,既会触发缺页异常,又会导致内存拷贝,从而可能导致性能下降。

内存去重会带来安全问题:攻击者可以在内存中通过穷举的方式不断构造数据,然后等待操作系统去重,再通过访问延迟来确定是否发生了去重。再通过这种探测的方式去确认系统中是否存在某些敏感数据。一种防范这种攻击的可能方法是:操作系统仅在同一个用户的应用程序内内存之间进行内存去重,从而使得攻击者无法猜测到的应用程序中的数据。

内存压缩

基本原理:当内存资源不足的时候,操作系统选择一些“最近不太会使用”的内存页,压缩其中的数据,从而释放出更多空闲内存。当应用程序访问被压缩的数据时,操作系统将其解压即可,所有操作都在内存中完成。相比于换出内存页到磁盘上,这样的做法既能更迅速地腾出空闲内存空间,又能更快地恢复被压缩的数据。

linux中的zswap机制

在这里插入图片描述

大页

随着应用程序对内存容量需求的变大,CPU 中 TLB 缓存项的数量很难保证高 TLB 命中率。

大页(huge page)机制能够有效缓解 TLB 缓存项不够用的问题。大页的大小可以是 2 MB 甚至 1 GB,相比 4 KB 大小的页,使用大页可以大幅度减少 TLB 的占用量。以 AArch64 体系结构为例, L2 页表项中存在一个特殊的位(第 1 位),它标识着这个页表项中存储的物理地址(页号)是指向 L3 页表页(该位是 1)还是指向一个 2 MB 的大页(该位是 0)。同样,如果 L1 页表项的第 1 位是 0,就表明该页直接指向一个大小为 1 GB 的大页。Linux 还提供了透明大页(transparent huge page)机制,能够自动地将一个应用程序中连续的 4 KB 内存页合并成 2 MB 的内存页。

使用大页的好处显而易见:一方面它能够减少 TLB 缓存项的使用,从而有机会提高 TLB 命中率;另一方面它能减少页表级数,从而提升查页表的效率。

不过,过度的大页使用也会有弊端:一方面应用可能未使用整个大页而造成物理内存资源浪费,另一方面大页的使用也会增加操作系统管理内存的复杂度,Linux 中就存在与大页相关的漏洞。

AArch64 体系结构还能支持多种小页大小,包括 4 KB、16 KB、64 KB。操作系统可以通过 TCR_EL1 寄存器进行配置,选择想要的大小。

物理内存分配与管理

目标与评价维度

物理内存分配设计有两个重要的评价维度。一方面,物理内存分配要追求更高的内存资源利用率,即尽可能减少资源浪费。另一方面,物理内存分配器要追求更好的性能,主要是尽可能降低分配延迟和节约 CPU 资源。通过精细的算法细致地解决碎片问题固然能够有效提高内存资源利用率,但可能会带来高昂的性能代价,比如增加分配请求完成的时间,或者由于过多后台处理而导致占用更多的 CPU 资源。

内存碎片:无法被利用的内存,其直接导致内存资源利用率的下降。外部内存碎片通常会在多次分配和回收之后产生。一种解决外部内存碎片的方式是,将物理内存以固定大小进行分配,但这会导致内部内存碎片。

外部碎片与内部碎片

在这里插入图片描述

伙伴系统

伙伴系统的基本思想是将物理内存划分为一个连续的块,以块为基本单位进行管理。不同块的大小可以不同,但每一块都由一个或多个连续的物理页组成,物理页的数量必须是 2 的 n 次幂(0 ≤ n < 预设的最大值)。其中预设的最大值将决定能够分配的连续物理内存区域的最大大小,一般由开发者根据实际需要指定。

工作原理:当一个请求需要分配m个物理页时,伙伴系统将寻找一个大小合适的块,该块包含 2n2^n2n个物理页,且满足 2n−1<m≤2n2^n−1<m≤2^n2n1<m2n。在处理分配请求的过程中,大的块可以分裂成两个小的块,即两个小的块互为伙伴。分裂得到的块可以继续分裂,直到得到一个大小合适的块去服务相应的分配请求。在一个块被释放后,若其伙伴块也处于空闲的状态,则将这两个伙伴块进行合并,形成一个更大的空闲块,然后继续尝试向上合并。由于分裂操作和合并操作都是按幂级数进行的,因此能够很好地缓解外部碎片的问题。

伙伴系统的基本思想:基于伙伴块进行分裂与合并

在这里插入图片描述

在实际应用中,通常用空闲链表数组去实现伙伴系统。其中需要注意的最小块的单位为4KB,因此若查找16KB的空间,首先需要去222^222的链表数组位置寻找,若找不到则将更大的一级块分裂为两个相等大小的小块。回收数据空间时,若该块的伙伴块为空间,则将其合并为一个完整的块释放。

伙伴系统的空闲链表数组

在这里插入图片描述

SLAB分配器

伙伴系统最小的分配单位是一个物理页(4KB),当需要分配远小于4KB的内存时需要使用另外—套内存分配机制用于分配小内存,该机制经过演化就形成了本小节要介绍的SLAB分配器。

SLAB、SLUB、SLOB三种分配器往往被统称为SLAB分配器。SLUB与SLOB的特点是:

  • SLUB分配器极大简化了SLAB分配器的设计和数据结构,在降低复杂度的同时依然能够提供与原来相当甚至更好的性能,同时也继承了SLAB分配器的接口。
  • SLOB(Simple List Of Blocks)的出现主要是为了解决内存资源稀缺场景(比如嵌入式设备)的需求,它具有最小的存储开销,但在碎片问题的处理方面不比其他两种分配器

SLUB 分配器工作原理:

是为了满足操作系统(频繁)分配小对象的需求,其依赖于伙伴系统进行物理内存的分配。简单来说,SLUB 分配器做的事情是把伙伴系统分配的大块内存进一步划分成小块内存进行管理。一方面由于操作系统频繁分配的对象大小相对比较固定,另一方面为了避免外部碎片问题,所以 SLUB 分配器只分配固定大小的内存块,块大小通常是 2n2^n2n个字节(一般来说,3≤n<12 )。在具体实现过程中,程序员可以根据实际需要设置一些块的大小以减少内部碎片。对于每一种块大小,SLUB 分配器都会使用独立的内存资源池进行管理。

SLUB 分配器向伙伴系统申请一定大小的物理内存块(一个或多个连续的物理页),并将获得的内存块划分为一个 slab,slab 在这里指代这个物理内存块中存放对应的数据结构。slab 会被划分为若干个对象(object),并且其内部空闲的对象会以链表的形式组织。一个内存资源池通常还有 current 和 partial 两个重要指针。current 指针仅指向一个 slab,所有的分配请求都从该指针指向的 slab 中获得空闲内存块。partial 指针指向由所有拥有空闲块的 slab 组成的链表,当 SLUB 分配器接收到来自用户的分配请求时,若 current 所指向的 slab 已满,则会从 partial 链表中选择一个 slab 作为新的 current,以继续满足后续的分配需求。如果 partial 指针指向的链表为空,那么 SLUB 分配器就会向伙伴系统申请分配新的物理内存作为新的 slab。

SLUB 分配器主要数据结构示意图。图中每个 slab 的白色部分表示空闲部分,彩色部分表示已分配部分。

在这里插入图片描述

常用的空闲链表

除上述的伙伴系统和 SLAB 分配器之外,还有其他基于不同空闲链表的内存分配方法。它们在用户态的内存分配器(比如堆分配器)中常被用到,本小节将对它们进行介绍。

第一种简单的空闲链表称为隐式空闲链表(implicit free list),如图 a 所示,空闲(白色)和非空闲(彩色)的内存块混杂在同一条链表里。每个内存块头部存储了关于该块是否空闲、块大小的信息。通过块大小,可以找到下一个块的位置。在分配空闲块的时候,分配器在这条链表中依次查询,找到第一块大小足够的空闲内存块即可返回。如果找到的空闲块的大小不仅仅能够满足分配请求,还有足量剩余,则将该块进行分裂,一部分用于服务请求,剩余部分留作新的空闲块,从而缓解内存碎片问题。在释放内存块的时候,为了尽可能避免外部碎片问题,分配器会检查该内存块紧邻的前后(根据块地址)两个内存块是否空闲,如果有空闲块存在,则进行合并,产生更大的空闲块。

内存分配器中常用的三种空闲链表

在这里插入图片描述

与之很相似的另一种空闲链表称为显式空闲链表(explict free list),显式空闲链表仅把空闲的内存块(而不是所有内存块)放在一条链表中。由于一个空闲内存块可能在任何一个位置,每个空闲块不能仅依靠块大小找到下一个空闲块的位置。所以,除了块大小(合并时需要),每个空闲块需要额外维护两个指针(prev 和 next)分别指向前后空闲块。不过,分配器仅需要在空闲链表中维护分配和释放的过程,可以复用该块的数据结构,从而不占用额外空间。显式空闲链表中内存分配和释放的过程与隐式空闲链表类似,在此不再赘述。相比之下,显式空闲链表在分配速度上更具有优势,因为它的分配时间仅与空闲块数量成正相关,而隐式空闲链表的分配时间与所有有块的数量成正相关。这个优势在内存使用率高的情况下更加明显,因为空闲块的数量更少而非空闲块更多。

另一种空闲链表数据结构称为分离空闲链表(segregated free list),是在显式空闲链表的基础上构建的。如图 c 所示,其基本思想是维护多条不同的显式空闲链表,每条链表服务固定范围大小的分配请求,这一点和之前介绍的伙伴系统或 SLAB 分配器相似。当分配内存块的时候,首先找到块大小对应的显式空闲链表,从中取出一个空闲块,若满足分配大小后有剩余,则将剩余部分插入相应大小的空闲链表中。如果在块大小对应的空闲链表中找不到合适的空闲块,则依次去更大的块大小对应的链表中寻找。当释放块的时候,分配器依然可以先采用单条显式空闲链表中的合并策略,然后将合并产生的空闲块插入对应大小的空闲链表中。

物理内存与CPU缓存

计算机的存储结构是多级的,CPU 通过缓存(cache)间接访问内存中的数据。这是因为在访问缓存比访问内存要快得多,从而能够极大提升计算机系统的性能。操作系统在给应用程序分配物理页的时候,如果能够分配尽量不会造成缓存冲突的物理页,那么就可以使得尽可能多的应用数据被放到缓存中,从而充分利用缓存大小来提升应用访问性能。

软件方案:染色机制

软件如何控制数据在 CPU 缓存中的位置呢?研究者提出了一种缓存着色(cache coloring/page coloring)的内存染色机制,并将其应用到操作系统中。该机制的基本思想很简单:把能够被放到缓存中不同位置(不造成缓存冲突)的物理页标记上不同的颜色,在为连续虚拟内存页分配物理页的时候,优先选择不同颜色的物理页进行分配。换句话说,同一种颜色的物理页会发生缓存冲突。由于连续的虚拟内存页通常可能在短时间内被相继访问,分配不同颜色的物理页可以让被访问的数据都处于缓存中,不引起冲突,从而避免缓存未命中带来的开销。

这种染色机制会导致物理页的分配变得复杂,但如果能够明确地得知应用对内存的访问模式,则可以有效提升内存访问的性能,FreeBSD、Solaris 等操作系统中就运用了该机制。

染色机制示意图

在这里插入图片描述

硬件方案: IntelCAT

CPU 的最末级缓存(Last Level Cache, LLC)会被多个 CPU 核心共享。由于每个 CPU 核心可以同时运行不同的应用程序,这些应用程序将会竞争最末级缓存的资源,从而可能由于互相影响导致应用程序产生性能抖动,甚至造成系统整体性能的下降。尤其在云计算多租户(multi-tenant)场景下,类似的问题更为严重。

Intel 缓存分配技术(Cache Allocation Technology, CAT)的出现为操作系统有效解决这些问题提供了一种方法。该技术允许操作系统设置应用程序所能使用的最末级缓存的大小和区域,从而实现最末级缓存资源在不同应用程序间的隔离。具体来说,CAT 提供若干服务类(Class of Service, CLOS),并允许通过设置特殊寄存器的方式把应用程序划分到某个 CLOS。每个 CLOS 有一个容量位掩码(Capacity Bitmask, CBM),标记着该 CLOS 能够使用的最末级缓存资源:CBM 中设置为 1 的位所对应的缓存资源,即为该 CLOS 能使用的最末级缓存资源。因此,CAT 支持按比例地限制每个 CLOS 可以使用的最末级缓存大小和区域,不同的 CLOS 所对应的缓存资源既可以是完全隔离的,也可以是部分重合的。

CAT样例

在这里插入图片描述

以上图为例,CLOS-0 与 CLOS-1各自能够使用最末级缓存的25%,CLOS-3能够使用最末级缓存的100%, CLOS-0 与CLOS-3能够使用的最末级缓存区域存在重合。

硬件方案:ARMv8-A MPAM

MPAM 支持配置多个分区 ID(Partition ID, PARTID),并且限制每个 PARTID 能够使用的缓存资源。操作系统可以把应用程序划分到某个 PARTID,从而限制该应用程序能够使用的缓存资源。

MPAM 支持两种缓存划分方案

  • 缓存局部划分(cache-portion partitioning)
  • 缓存最大容量划分(cache maximum-capacity partitioning)

缓存局部划分:与IntelCAT类似使用位图来按比例划分属于不同 PARTID 的可用缓存资源。

缓存最大容量划分:MPAM 通过配置 MPAMCFG_CMAX 寄存器的值来设定一个 PARTID 能够使用的最大缓存资源比例。

上述两种划分方案可以结合使用。假设共有三组应用程序(对应于三个 PARTID),用户通过局部划分使得优先级最高的一组独占 50% 的缓存资源,而剩余两组应用程序共享剩余的 50%。为了保证这两组应用程序均不会造成过度的资源抢占,可以再利用最大容量划分使得这两组应用程序均不得占用超过共享缓存 75% 的容量(即总缓存资源的 50% × 75% = 37.5%)。

思考题

  1. 在 AArch64 体系结构上,4 级页表结构中最后一级(第 3 级)的页表项属性位的第 11 位是 nG(not Global)位。通过查阅硬件手册,我们可以知道当 nG 位是 0 的时候,表示该项映射所对应的 TLB 缓存项对所有应用程序有效;反之,当 nG 位是 1 的时候,表示该项映射所对应的 TLB 缓存项只对指定(根据 ASID)的应用程序有效。请思考,为什么需要 nG 位呢?通常情况下 nG 位设置成 0 还是 1 呢?
    需要实现进程间数据隔离;通常为1。

  2. 当主流体系结构(包括 AArch64 和 x86-64)都会采用 TLB 分级结构,并且采用数据和指令分离的设计。请从 TLB 命中速度和 TLB 命中率的角度思考,为什么需要分级和分离的设计?
    分级设计:

    • 通过提高容量提高命中率
    • 最常用的映射放在L1 TLB,不常用的映射放在L2,其余通过页表访问物理内存,提高访问速度

    分离设计:

    • 分离指令与数据,防止相互换出导致降低命中率。
  3. 通过之前的介绍,我们知道页替换策略中的 Belady 异常。请尝试构造一种页访问顺序,使得 FIFO 策略触发 Belady 异常。

在这里插入图片描述

  1. 计算机发展到今天,物理内存的容量已经大大增加,而且单位价格还发生了下降,服务器的内存通常已经到达上百 GB,甚至更大。请思考,如今换页还重要吗?
    1. 不是所有程序都常驻内存
    2. 应用程序可能申请大量但未使用的内存
    3. linux支持内存超额分配,如果不存在换页机制,这种机制则不能实现
    4. 防止内存碎片化
    5. 保障系统稳定性:当内存紧张时, 操作系统优先换出不活跃页面,而不是直接杀死进程,给用户或管理员留出反应时间
  2. Intel 从 2019 年开始在市场上推广非易失性内存(NVM),它很有可能给存储架构方面带来新的革命。传统的存储层次我们可认为是:寄存器(register)→ 缓存(cache)→ 内存(memory)→ 硬盘(disk/SSD)。NVM 的速度接近于内存(稍慢),而容量却媲美硬盘,且访问方式和内存相同(按字节寻址)。请思考,NVM 可能会如何革新存储层次?
    1. 新增一层 —— “持久内存层”
    2. 程序可以直接将数据写入 NVM,无需先写入内存再刷盘,数据在写入后立即持久化
  3. 通过前面的学习,我们知道操作系统可以通过配合使用伙伴系统和 SLAB 分配器进行物理内存分配,实际上优秀的物理内存分配器还需要考虑到性能隔离,比如如何尽量避免缓存冲突等。请调研 Intel CAT 和 ARM MPAM 这两个硬件的特性,提出一种可能的操作系统利用这些特性保证性能隔离的方法。
    1. 在多租户、虚拟化或容器化场景下,不同应用或租户之间可能会相互影响,导致性能下降。其中一个重要的影响因素就是缓存(Cache)
      当多个进程的内存数据被分配在物理地址空间中过于接近的位置时,它们可能会映射到 L3 缓存的同一组(cache set),从而导致频繁的缓存行(cache line)替换,增加缓存失效(cache miss)率,这被称为缓存冲突(Cache Contention)。这不仅会降低单个应用的性能,还会使整体系统的性能变得不稳定。
    2. 操作系统可以根据性能类别,利用Intel CAT 和 ARM MPAM 动态地为进程分配不同的资源描述符。高优先级进程可以被分配一个包含更多 L3 缓存分片的描述符,甚至可以独占一个或多个分片。
  4. 物理内存设备上有一个控制器,它对操作系统屏蔽了硬件细节,为操作系统提供了易用的物理内存抽象,即逐字节可寻址的“大数组”,从而使得操作系统的物理内存管理变得简单。但是现代操作系统安全的研究者发现,实际上操作系统有时候不得不去“了解”一些现代内存的硬件细节,从而抵御 Rowhammer 和 Cache Side Channel 等攻击。请调研这两类攻击,并简述操作系统如何根据硬件细节去做出防御。(提示:在物理内存分配时主动加入一些 guard page;掌握 cache 如何映射。)
    1. Rowhammer是一种基于物理内存芯片缺陷的攻击。现代DRAM(动态随机存取内存)单元密度非常高,彼此紧密相邻。一个DRAM内存单元中的电荷代表一个比特(0或1)。当一个内存行(row)被频繁访问(“hammered”)时,其相邻行中的电荷会因物理电磁干扰而发生泄漏或改变,导致比特位被意外翻转。
      攻击者利用这一弱点,可以精心构造访问模式,反复读写一个内存行,从而在相邻的行中翻转一个比特。如果这个被翻转的比特恰好位于一个关键的内存区域(例如,页表中的权限位或一个内核数据结构),攻击者就可以获得本不应有的权限,例如将一个只读的内存页变为可写,或将用户模式的进程权限提升到内核模式。
    2. 物理内存分配时的“守卫页”(Guard Pages):操作系统可以在分配内存时,在关键数据(如页表、内核数据结构)所在的物理内存行两侧,预留出一些不使用的**“守卫页”**。
    3. 刷新与定期扫描: 操作系统可以利用DRAM的硬件指令,定期刷新内存。现代DRAM硬件本身也引入了**TRC(Targeted Row Refresh)**等机制来减轻Rowhammer效应。操作系统可以与硬件协同工作,对被频繁访问的内存区域执行额外的刷新操作,以防止比特翻转。
    4. 缓存侧信道攻击是一种利用CPU缓存层次结构(如L1、L2、L3缓存)的性能差异来推断秘密信息的攻击。
    5. 物理地址混淆(Physical Address Obfuscation): 操作系统可以在分配物理内存时,刻意将不同进程的敏感数据分配到物理地址上不连续的位置,从而使得它们不会映射到L3缓存的同一组。这样即使两个进程同时运行,它们的缓存访问也不会互相影响,从而切断了侧信道。
    6. 利用硬件特性: 现代CPU(如Intel的CAT和ARM的MPAM)提供了硬件层面的缓存分区功能。操作系统可以利用这些特性,为不同的进程分配独占的缓存资源。例如,将一个运行加密算法的进程分配到一个独立的、不与其他进程共享的L3缓存区域,从根本上杜绝了侧信道攻击的可能性。
  5. 我们知道硬件可提供大页机制,而且 AArch64 体系结构还支持不同的最小页大小。请思考,什么情况适合于使用大页?
    1. 大页能够减少地址翻译开销和页表的内存占用开销,因此对于大数据量的情况更友好
    2. 科学计算和高性能计算(HPC)、数据库和内存缓存(如 Redis)、虚拟化和容器化、内存映射文件(Memory Mapped Files)
  6. 假设物理内存足够大,虚拟内存是否还有存在的必要?如果不使用虚拟内存抽象,恢复到只用物理内存寻址,会带来哪些改变?
    1. 虚拟内存的作用:
      1. 进程隔离与安全: 虚拟内存为每个进程提供了一个独立的、私有的地址空间。一个进程的虚拟地址 0x1000 和另一个进程的虚拟地址 0x1000 映射到不同的物理内存位置。这种机制阻止了进程之间直接访问对方的内存,有效防止了恶意软件或缺陷程序破坏其他进程的数据,从而保障了系统的稳定性和安全性。
      2. 内存共享: 虚拟内存使得多个进程可以共享同一份物理内存。例如,多个进程可以共享同一份标准库(如 libc),而无需在物理内存中为每个进程都加载一份拷贝。这大大节省了物理内存,尤其是在有大量进程运行的系统中。
      3. 内存管理与布局灵活性: 虚拟内存允许操作系统以不连续的物理内存来模拟一个连续的虚拟地址空间。这极大地简化了内存分配和回收。当一个进程请求大块连续内存时,操作系统可以分配非连续的物理页,并利用页表将它们映射到进程的连续虚拟地址空间中。
      4. 按需分页(Demand Paging): 虚拟内存允许程序在执行时才将所需的代码和数据从磁盘加载到物理内存中。这意味着程序可以不必完全加载就能运行,显著加快了程序的启动速度,并允许程序的大小超过可用的物理内存。
    2. 放弃虚拟内存会带来哪些改变?
      1. 进程隔离将不复存在: 所有进程都将直接访问物理内存。一个进程可以轻易地访问、修改甚至破坏其他进程的内存,安全性和稳定性将无法保证。你需要一个完全可信赖的单任务环境,就像早期的 DOS 系统。
      2. 物理内存管理将变得复杂: 内存分配必须是物理上连续的,这将导致内存碎片问题。当一个进程退出时,它释放的内存块可能是物理上不连续的。如果一个新的进程需要一块大的连续内存,即使总内存足够,也可能因为碎片而无法分配,导致系统无法正常运行。
      3. 多任务处理效率降低: 任务切换(Context Switch)将变得更复杂。在切换进程时,需要将整个物理内存中的数据进行保存和加载,这将耗费巨大的开销。相比之下,使用虚拟内存,任务切换只需要切换页表,效率更高。
      4. 开发难度增加: 程序员需要关心物理内存的布局,并且无法再使用方便的抽象,例如 malloc。他们必须手动管理内存,确保不会与其他进程冲突。这将使程序开发变得非常困难且容易出错。
      5. 内存共享无法实现: 由于没有虚拟地址空间作为抽象层,多个进程无法轻松共享同一份代码和数据,导致内存资源的浪费。
http://www.dtcms.com/a/392162.html

相关文章:

  • 【智能系统项目开发与学习记录】Docker 基础
  • 数据展示方案:Prometheus+Grafana+JMeter 备忘
  • flask获取ip地址各种方法
  • 17.6 LangChain多模态实战:语音图像文本融合架构,PPT生成效率提升300%!
  • MyBatis实战教程:SQL映射与动态查询技巧
  • 在 Windows Docker 中通过 vLLM 镜像启动指定大模型的方法与步骤
  • 分类预测 | Matlab实现SSA-BP麻雀搜索算法优化BP神经网络多特征分类预测
  • GO实战项目:基于 `HTML/CSS/JS + Gin + Gorm + 文心一言API`AI 备忘录应用
  • 数据结构【堆(⼆叉树顺序结构)和⼆叉树的链式结构】
  • 我爱学算法之—— 位运算(下)
  • LeetCode第364题_加权嵌套序列和II
  • 云计算和云手机之间的关系
  • 胡服骑射对中国传统文化的影响
  • leetcode-hot-100 (多维动态规划)
  • Chromium 138 编译指南 Ubuntu 篇:depot_tools安装与配置(三)
  • 在Ubuntu 16.04上安装openjdk-6/7/8-jdk的步骤
  • 小杰机器学习高级(four)——基于框架的逻辑回归
  • 基于AI分类得视频孪生鹰眼图像三维逆变换矫正算法
  • [Tongyi] 智能代理搜索范式 | 决策->行动->观察(循环迭代)
  • FLink:窗口分配器(Window Assigners)指定窗口的类型
  • GO实战项目:流量统计系统完整实现(Go+XORM+MySQL + 前端)
  • 零基础-动手学深度学习-13.10. 转置卷积
  • 【Math】初三第一、二单元测试卷(测试稿)
  • 2.Spring AI的聊天模型
  • 【连载6】 C# MVC 日志管理最佳实践:归档清理与多目标输出配置
  • autodl平台jupyterLab的使用
  • React学习教程,从入门到精通,React 开发环境与工具详解 —— 语法知识点、使用方法与案例代码(25)
  • 【C++】容器进阶:deque的“双端优势” vs list的“链式灵活” vs vector的“连续高效”
  • llm的ReAct
  • C++ 参数传递方式详解