地址空间

1 内存管理

有了进程抽象,内存就有了 “语义”:当前进程的 “状态”,而这个 “状态” 应该遵循状态机(即进程)的规约而改变

早期操作系统直接使用物理内存:

  • 使用装载器/链接器:在程序加载到内存时转换为绝对地址(静态重定位)
    • 一个程序中的 bug 会导致其他程序也 crash,程序也没有被禁止访问其他进程的地址
  • 添加保护位,进程访问一块内存时 CPU 检查进程 Key 与内存 Key 是否匹配
    • 需要太多寄存器,不现实
  • 利用基址和界限来保护各自内存
    • 超出则抛出异常

使用物理内存的缺点:

  • 一个应用会因其他应用的加载而受到影响(加载器压力骤增)
  • 一个应用可通过自身的内存地址,猜测出其他应用的加载位置

1.1 虚拟内存抽象

以虚拟内存抽象为核心的内存管理:

  • CPU:支持虚拟内存功能,新增了虚拟地址空间
    • 通过 MMU 翻译为物理地址
  • 操作系统: 配置并使能虚拟内存机制
  • 所有软件: 均使用虚拟地址,无法直接访问物理地址

每个应用程序拥有独立的虚拟地址空间

  • 应用程序认为自己独占整个内存(透明性)
  • 应用程序不再看到物理地址
  • 应用加载时不用再为地址增加一个偏移量

1.2 地址翻译

翻译就是一个函数 f: 其将 <pid, virtual address> 映射到物理地址

alt text

优点:可以很自然地提供保护重定位数据共享连续空间假象

  • 保护:让不同的进程映射到不同的区域即可(即两个进程的映射函数的值域不相交)

alt text

  • 重地位:进程被映射到的物理地址可以在运行时不断变化(运行时,不是编译时,因此是动态重定位

alt text

  • 数据共享:将不同进程的不同虚拟地址映射到同一个物理地址

alt text

  • 构建连续空间假象:虚拟地址中的连续地址空间(编程友好),映射到物理内存可以不必连续

alt text

2 连续内存分配*

进程(包括操作系统内核)被分配一个连续的物理内存地址

  • 利用基址和界限机制隔离用户进程之间的地址空间,以及防止用户进程修改操作系统的代码和数据

多个进程时需要将物理内存进行分割,每个进程占据一个连续的物理内存分区,有两种分割方式

2.1 可变分区

固定分区

物理内存一开始就被分为 “固定” 大小的区域,各个区域的大小可以相同,也可以不同,一个进程选择一个空闲区域进行分配。固定分区的问题:

  1. 无法放下所需空间过大的进程
  2. 内部碎片
  3. 固定分区数限制了进程数

可变分区:物理内存区域大小和数量是可变的,根据进程的具体需要分配相应的空间

  • 当进程需要加载到内存时,操作系统从一个足够大的空闲块分配内存
  • 当进程终止时释放其分区,并且与相邻的空闲分区合并
  • 鉴于大多数进程在运行时会增长,在加载进程时会分配一些额外的内存
  • 如果给进程所分配的区域用完,那么该进程将可能:
    • 被移动到一个有足够空间的空闲区域中
    • 被交换出内存(至磁盘),直到能够创建一个足够大的空洞
    • 或者直接被终止

2.2 碎片

无法被分配的未使用内存

  • 外部碎片(空洞):由于分散的小的不连续的空闲空间导致的内存浪费,发生在分区之间,通常是由于进程的不断加载和释放造成
  • 内部碎片:由于分区大小和加载的进程大小之间的差异(即进程小于分配的分区)导致的内存浪费,发生在分区内

2.3 外部碎片的处理方法

内部碎片只能靠好的分配策略解决

  • 进程终止或暂时交换出磁盘,可以释放内存,可能会合并一些碎片形成大的可分配区域
  • 通过紧缩减少外部碎片化
    • 重排内存内容以将所有空闲内存放在一起
    • 时间复杂度较高(一般在系统实在没有内存的情况下才会做)

2.4 空闲内存管理

为了实现动态可变分区内存分配,操作系统应维护以下信息:

  • 已分配的分区
  • 空闲的分区(空洞)

一种简单的办法:位图

  • 内存被划分为分配单元(几字节到几千字节)
  • 每个分配单元对应位图中的一位,如果该单元空闲则该位为 0,如果被占用则该位为 1

必须搜索位图以找到 k 个连续的 0 位来加载一个k单元的进程

  • 一个更加常用的做法:空闲内存链表

alt text

还需要跟踪已分配区域的大小,这样 free(*p) 时才能正确的返回一个正确的空闲结点

  • 方法:malloc 时多分配一点空间记录元信息

2.5 基本分配策略

空闲内存列表有很多都可以分配给一个内存的申请需求,该选择哪个?

不同的策略会影响分配和回收的性能和有效性(比如碎片数量)

  • Best-fit分配最小的足够大的空闲区域(尽可能物尽其用),需要遍历整个列表
    • 但可能导致产生微小且无用的外部碎片
  • Worst-fit分配最大的空闲区域
    • 剩余的部分最大,可以为其他进程所占用,而不是形成碎片,但同样遍历整个列表
  • First-fit分配第一个足够大的空闲区域(尽可能少搜索)
  • Next-fit:跟踪上次适配的位置,并从上次搜索结束的地方开始搜索(尽可能均匀的搜索整个空间)

alt text

  • 链表查找还是低效的,使用红黑树会更好
  • free 后的合并?
    • 链表空闲节点按地址进行链接,free 时扫描整个链表
    • 更高效的合并:伙伴系统

2.6 伙伴系统

使用 2 的幂分配器分配内存

  • 按 2 的幂大小单位满足请求:请求向上舍入到下一个最高的 2 的幂
  • 当需要比可用空间更小的分配时,当前块被分割为两个下一个较低 2 的幂的伙伴
  • 持续进行直到有适当大小的块可用

alt text

  • 合并效率高:一个块被归还后,检查其伙伴是否空闲,若是则合二为一,并递归上溯
  • 伙伴的地址非常容易得到
  • 只有一位不同,这一位决定了它们在整个伙伴树中的层次
    • 大小为 \(2^k\) 的块的地址是 \(2^k\) 的倍数(右边有 \(k\) 个零)
    • 比如一个 \(2^5\) 的地址:xxx...xx00000
    • 分割之后,两个伙伴块大小为 \(2^4\),地址分别为:
      • xxx...xx0000
      • xxx...xx1000

内部碎片问题无法解决

2.7 Slab 分配器

经验观察:系统频繁分配的对象大小相对比较较小且固定 目标:快速分配小内存对象

  • 从伙伴系统获得大块内存
  • 进一步细分成固定大小的小块内存进行管理
  • 块大小通常是 \(2^n\) 个字节(一般来说,\(3 \le n \le 12\))
    • 可以额外增加特殊大小(频繁使用的数据结构大小)如 198 字节从而减小内部碎片
  • 对于每个固定块大小,Slab 分配器都会使用独立的内存资源池进行分配
  • 采用 best fit 定位资源池

alt text

三个指针:

  • current 仅指向一个 slab
  • partial 指向未满 slab 链表
  • full 指向全满 slab 链表

分配使用 current slab (per-cpu)

  • 若满:移动到 full slab
  • 从 partial 里申请一个

释放时放到对应的 slab

  • 若是 full,移到 partial
  • 如果 partial 全是 free,则还给 buddy system

alt text

多线程中
  • Fast path:Per CPU 从当前 slab 中取出一个适合大小的块
  • low path:需要从一个全局的 partial 里去找一个作为当前的 slab,不巧的话(paritial 里也没空闲内存),甚至需要从 buddy system 里重新分配连续空间,再次分割为可用的 slab

kmalloc 是内核运行时申请内存的通用 slab

2.8 连续内存分配的问题

  • 如果直接分配给进程内存的话,stack 和 heap 中间的部分都被浪费了
    • 内部碎片无法避免
  • 无法和其他进程共享内存(比如代码和共享库)
    • 主要是保护机制粗糙,整体空间的保护,没有精细到具体的部分内存

3 分段*

用户视角的内存管理

  • 将程序视为一组段
  • 段是虚拟内存空间中连续区域的一个逻辑单元
    • 例如代码段、栈、堆等
  • 每个段独立映射到物理内存中的一组连续地址
    • 没有特定的顺序
    • 不需要映射未使用的虚拟地址
      • 可以消除内部碎片
    • 不同的段可以独立增长或缩减

3.1 分段底层机制

  • 虚拟地址空间分成若干个不同大小的段
  • 段表(每个进程一个)存储着每个分段的信息(由段号索引),可供 MMU 查询
    • 段基址:段在内存中所在的起始物理地址
    • 段界限:段的大小
  • 虚拟地址分为: 段号 + 段内地址(偏移)
  • 物理内存也是以段为单位进行分配
    • 虚拟地址空间中相邻的段,对应的物理内存可以不相邻

alt text

3.2 支持共享

  • 多个虚拟地址空间可以映射到内存中的同一物理段
    • 如:只读代码的一个副本在进程间共享,一个可执行文件被加载多次
    • 也可以用作进程间通信
  • 段表需要增加保护位
    • 指示程序是否具有相应段的读/写/执行权限
  • 每个进程仍然认为它在访问自己的私有内存(透明性)

alt text

3.3 OS 对段表机制的支持

  • 操作系统应在上下文切换时保存和恢复段表(指向段表的寄存器)
  • 当段增长或缩小时,操作系统应进行交互(更新段表)
  • 创建新的地址空间时,操作系统应在物理内存中为其段找到空间
    • 操作系统维护着空闲内存块
  • 由于段的长度各不相同,内存分配是一个动态内存分配问题
    • 需要分割和合并
存在问题:分配的粒度太粗,随着时间的推移会产生外部碎片

4 分页

更细粒度的内存管理

  • 物理内存被划分成连续的、等长的物理页(也叫帧 frame)
    • 大小一般为 2 的幂,比如默认是 4KB (\(2^{12}\) Byte)
  • 虚拟内存也被划分为相同大小的虚拟页(page)
  • 任意虚拟页可以映射到任意物理页
  • 没有外部碎片:都是按照页为单位分配内存

4.1 分页机制

虚拟地址分为:虚拟页号 + 页内偏移

每个进程都有一个页表,每个页表项包含一个物理页号,指示每个页在物理内存中的基地址

alt text alt text alt text

页表使能:CPU 启动流程,上电后默认进入物理寻址模式,系统软件配置控制寄存器,使能页表,进入虚拟寻址模式

4.2 页表

页表最简单的形式称为线性页表,存储在物理内存中

  • 页表项(PTE)的具体布局高度依赖于机器
    • 需要足够的位来标识物理页
    • 应包括一些控制位

页表项中的常见控制位:

  • 有效位:转换是否有效(某些地址需保留)
  • 存在位:页面是否实际存储在内存中
  • 保护位:页面是否可以被读取、写入或执行
  • 引用位:页面是否已被访问(内存紧张切磁盘)
  • 脏(修改)位:页面自被载入内存以来是否已被修改(从磁盘调出,修改了内存,需写回磁盘)

4.3 支持共享

共享相同的物理页面:在两个页表中的条目指向相同的页帧(带有某些保护位)

回顾 Copy-On-Write: 尽可能共享,并仅在需要时创建自己的副本
  • fork() 通过完全复制父进程来创建一个新进程
  • 父进程和子进程是相同的,除了 fork 的返回值(共享大量内存)
  • 复制父进程的页表
  • 所有共享的页面都标记为 “只读”
  • 如果任何一方修改了页面中的数据,就会引发异常,并创建该页面的完整内存副本

4.4 多级页表

若使用单级页表结构,一个页表有多大?

  • 32 位地址空间,页 4K,页表项 4B,
    • 页表大小: \(\displaystyle \frac{2^{32}}{4K} \times 4 = 4\text{MB}\)
  • 64 位地址空间,页 4K,页表项 8B,
    • 页表大小: \(\displaystyle \frac{2^{64}}{4K} \times 8 = 33,554,432\ \text{GB}\)

这还只是一个进程的页表

使用多级页表减少空间占用

  • 若某级页表中的某条目为空,那么对应的下一级页表无需存在
  • 实际应用的虚拟地址空间大部分都未被使用,因此无需分配页表

alt text

4.5 倒排页表*

  • 每级页表有若干离散的页表页
    • 每个页表页占用一个物理页
  • 第 0 级(顶层)页表有且仅有一个页表页
    • 页表基地址寄存器(TTBR)存储的就是该页的物理地址
  • 每项为 8 个字节
    • 即总共 4096/8 = 512 项,用于存储物理地址和权限
  • 可以不只是两级的页表

一个问题是:太多虚拟页了!而物理页是少的!

解决方案:倒排页表,不再为每个进程分别维护多个页表,而是保留一个单一的页表,每个物理页对应一个条目(物理页映射回虚拟页)

  • 每个页表条目包括:
    • 使用该物理页的进程(Pid)
    • 该进程的哪个虚拟页映射到该物理页
  • 减少了存储页表所需的内存,但增加了在发生页引用时查找表所需的时间
    • 使用哈希表来加速查找
  • 此外一个问题就是无法共享(哈希冲突)

alt text

地址翻译比较
方法 优点 缺点
分段 快速上下文切换(段映射由CPU维护) 外部碎片
单级页表 无外部碎片 快速且易于分配 表的规模巨大,内部碎片
多级页表 表大小约为虚拟内存中的需要用的页面数量 快速且易于分配 每次页面访问涉及多次内存引用
倒排页表 表大小约为物理内存中的页面数量 需要复杂的哈希函数,页表没有缓存局部性

段页:虚拟地址 = 段号 + 虚拟页号 + 偏移,段号定位段表以得到 Base、Bound,页逻辑同上

(多级)页表不是完美的

  • 多级页表的设计是典型的用时间换空间的设计
  • 增加了访存次数(逐级查询,级数越多越慢)
  • 即使是单级页表,也需要访存两次才能真正得到物理内存上的数据
如何降低地址翻译的开销?

4.6 TLB:地址转换旁路缓冲

TLB 应用了局部性原理

  • 缓存最近地址转换
  • 如果 TLB 命中,直接应用转换(fast path)
  • 如果 TLB 未命中,则在页表中查找映射(页表遍历),并更新 TLB(slow path)

alt text

一个典型的 TLB 缓存的项包括

  • 页号及其对应的帧号
  • 有效位:条目是否有有效的转换
  • 保护位:页面的访问方式
  • 脏(修改)位:页面是否已被修改
TLB 的有效位与页表项的有效位不同
  • TLB:进程上下文切换时,旧进程的虚拟地址映射对新进程无效,会清空 TLB,将其所有条目的有效位置 0
  • 页表项:表示是否已经被真正加载到了内存中,若 0 则缺页异常

TLB 通常很小,包含 64 到 1024 个条目

  • 存储最可能被多的选中的地址翻译才能高效发挥 TLB 的作用
  • 支持某些条目可以固定下来以便永久快速访问

有效访问时间(EAT)

\[\text{EAT}=(\varepsilon + t)\alpha + (\varepsilon + 2t)(1 - \alpha)\]
  • 命中率(\(\alpha\)):在 TLB 中找到页号的百分比
  • 内存访问时间(\(t\))
  • TLB 查找时间(\(\varepsilon\))

处理 TLB Miss

在 TLB 缺失时,值被加载到 TLB 中,以便下次更快地访问

但如果 TLB 已满,应该替换谁呢?

  • 先来先出
  • 最近最少用(LRU)
  • 随机

谁来处理 TLB miss?

  • 硬件处理:当 TLB 缺失时,硬件进行页面遍历,获取页表项,并将其插入 TLB
    • 硬件必须确切地知道页表在内存中的位置,以及它们的确切格式
    • 更加迅速,对系统软件透明地完成
  • 软件处理:当 TLB 缺失时,硬件会引发异常,操作系统内部的代码处理 TLB 缺失
    • 更加灵活(如可以定制替换策略)

TLB 一致性

上下文切换时,旧进程的虚拟到物理地址的转换不再有效,否则就会出现同样的虚拟地址映射到不同物理地址的问题,解决方案有两种:

  1. 清空 TLB:简单地将所有有效位设置为 0
  2. 带标记的 TLB:在每个 TLB 条目中添加一个地址空间标识符(ASID)字段,该字段唯一地标识每个进程,为该进程提供地址空间保护
    • 有效位也依然置 0,只是不用清除这个页表项,待该进程切回时有效位重新置 1

4.7 交换*

当没有足够的空间时,一个进程可以被暂时换出内存到磁盘,然后在需要继续执行时再换回内存

进程的总物理内存空间可以超过物理内存

  • 交换时间的主要部分是磁盘传输时间
  • 交换会对正在等待 I/O 操作的进程产生负面影响
    • 一般设计 I/O 操作的页需要被锁定在内存中,防止操作完成前被换出
  • 交换通常是禁用的,分配的内存超过阈值才会启动,低于后又禁用

如果单个进程本身超过物理内存,只要 “活跃” 的占用内存量适合物理内存即可

虚拟内存
  • 每个进程都有一个大地址空间的幻觉
  • 支持多个并发运行的进程使用大虚拟地址空间:只将常用的页面保留在内存中
  • 具备 “部分” 加载程序的执行能力
    • 程序不再受物理内存限制(可以运行无法完全放入物理内存的程序)
    • 每个程序在运行时占用更少的内存(可以同时运行更多的程序)
    • 加载或交换程序到内存中所需的 I/O 更少(每个程序启动更快)
  • 使用页表项的存在位来跟踪哪些页面存在于物理内存中
  • 当程序引用其地址空间的一部分时:
    • 如果页面在物理内存中,则直接进行地址转换
    • 如果不在,则发生缺页异常,操作系统被调用来处理该异常
      • 检测并将页面加载到内存中,然后重新执行指令(引用该地址空间的指令)

5 缺页异常

5.1 具体流程

CPU 控制流传递、提前注册缺页异常处理函数

alt text

  1. 硬件陷入内核,进行上下文切换
    • 将程序计数器保存在栈上,保存通用寄存器和其他易失性信息
  2. 系统发现缺页异常事件,尝试确定所需的虚拟页面
  3. 一旦知道引发缺页异常的虚拟地址,系统检查地址是否有效,并且保护是否与访问一致
  4. 找到一个空闲帧
    • 如果没有空闲帧,则运行页面置换以选择一个受害者
    • 如果所选帧是脏的,则将页面安排转移到磁盘,进行上下文切换,暂停引发异常的进程
  5. 一旦帧变为干净状态,系统查找所需页面的磁盘地址,并安排磁盘操作将其调入(引发缺页异常的进程仍处于暂停状态)
  6. 当磁盘中断指示页面已经到达时,更新页表,并将帧标记为正常状态
  7. 将引发缺页异常的指令恢复到其原始状态,并重置程序计数器
  8. 引发缺页异常的进程被调度,上下文切换回去

5.2 性能

处理缺页异常的三个主要活动:

  • 服务中断:一般只需要几百条指令
  • 读取页面:需要大量时间
  • 恢复进程:需要少量时间

缺页错误率(\(0 \le p \le 1\)) 有效访问时间 \(\text{EAT}=(1 - p) \times\) 访存时间 \(+\ p\ \times\) 缺页异常开销

  • 内存访问时间 = 200 ns
  • 平均缺页异常服务时间 = 8 ms
  • \(\text{EAT} = (1 - p) \times 200 + p \times 8,000,000 = 200 + p \times 7,999,800 \text{ ns}\)
  • 如果 1,000 次访问中有一次引发缺页异常 (\(p = 0.001\)),那么 \(\text{EAT} = 8200 \text{ ns}\)(减速了 40 倍)
  • 如果想要性能降级 < 10%
    • \((1-p) \times 200 + p \times 7,999,800 < 220\)
    • \(p < 0.0000025\)(每 400,000 次内存访问中不到一次缺页异常)
缺页异常的其他用法

除了作为构建虚拟内存的一个机制外,缺页异常还有其他巧妙用法

  • Copy-On-Write:写时复制
  • Zero-filled-on-Demand:按需零填充
  • memory-mapped files:内存映射文件

5.3 按需零填充

非常特殊的写时复制情况

许多进程页是 “空白” 的:

  • 所有的 bss(通常是指用来存放程序中未初始化的全局变量的一块内存区域)
  • 新的堆页面
  • 新的栈页面

按需零填充实现:

  • 有一个系统范围内的全零帧
  • 所有的全 0 页帧都指向它
  • 标记为只读
  • 读取零是自由的
  • 但写入会导致缺页错误以及克隆操作

5.4 内存映射文件

将文件内容映射到进程的地址空间的机制。通过内存映射文件,可以将文件视为一块内存区域,而不需要显式地使用 read()write() 的接口 mmap()

  • 当进程访问映射区域时,如果所访问的页面尚未在内存中,则会发生页面错误。此时,操作系统会将相应的文件内容 read() 到内存中,以满足进程的访问请求
  • 进程对映射区域的写操作会导致页面被标记为脏页,并在必要时通过 write() 系统调用将页面内容写回文件
  • 如果多个进程使用相同的 mmap() 调用映射了相同的文件,它们将共享同一份文件内容,即它们的映射区域指向同一块内存区域,从而实现了共享内存的效果

6 页面置换

何时将页面调入内存?
  • 按需分页 —— 出现缺页异常后进行调入页面
    • 最简单的方法
    • 为了提高效率,调入是异步进行的:
      • 中断处理程序应快速响应,只需启动磁盘 I/O 并阻塞该进程,让其他进程运行
  • 预取
    • 猜测即将使用哪些页面,因此提前将其调入内存
    • 往往是基于历史的缺页记录来预测
如果没有空闲的页会发生什么?
  • 页面置换:找到内存中的一个页面(一般来说该页面近期最不可能再次被访问),将其换出
  • 更新页表:找到所有引用旧页面的页表项(因为帧可以共享),并将每个设置为不可见
  • 移除任何 TLB 条目
    • 在多处理器系统中,必须从所有处理器的 TLB 中消除 TLB 条目
  • 将页面写回磁盘(如果需要,页表项中的脏位)
  • 重新启动引发陷阱的指令(需要备份指令)
如果不想将某些页面换出怎么办?
  • 将页面固定到内存中以锁定
  • 有时必须将页面锁定到内存中
    • 总是将(部分)内核页面放入物理内存中
    • 用于从设备复制文件的页面必须锁定,以防止被选择用于驱逐(I/O)
    • 低优先级进程交换入一个页面,然后被高优先级进程抢占并请求一个新的页
  • 需要小心使用
操作系统会等到内存完全满了吗?
  • 保留一组空闲页(Buffering) 以确保在需要时总有可用的页在合适的时候

此外有一个交换页面的守护进程(后台进程)定期运行(类似于调度程序)

  • 如果空闲物理帧的数量 < “低水位标记”,则一次性换出一些页面,直到数量达到 “高水位标记”
    • 大块数据写入磁盘更有效(批量传输)
    • 需要维护一个修改页面的列表,将页面写入其中并设置为非脏
  • 页面交换的守护进程可以以低优先级调度
    • 利用空闲时间准备未来的工作
哪个页应该被替换?
  • 页面置换策略,目标是实现最低的缺页异常率(尽量减少从磁盘获取页面的次数)
  • 如果选择一个不常被使用的页面,系统性能会更好
  • 如果删除一个频繁使用的页面,它可能很快就需要被重新调入

6.1 先进先出(FIFO)

替换最老的页面,使用一个 FIFO 队列来跟踪页面的老化程度

alt text

Belady 异常:增加帧数反而可能会降低命中率(帧数越少,缺页异常越少)

  • 给定 4 个帧,有 10 个缺页异常;给定 3 个帧,有 9 个缺页异常

Belady 异常会发生在任何页面替换算法中(比如随机替换),只要它不遵循 “栈算法” 属性

栈算法属性:当页面帧数量增加时,先前存在的页面集合始终是当页面帧更多时存在的页面集合的子集

  • 换句话说,随着页面帧数的增加,先前存在的页面应始终保留在内存中
  • 没有这个性质,增加页面数导致删除先前在内存中被频繁使用的页面

6.2 最佳算法

替换在最长时间内不会被使用的页面

  • 但这个方法只存在理想中,无法预知未来
  • 因此通常用于衡量算法的性能(和最优的差距有多大)

alt text

6.3 最近最少用(LRU)

替换那些在最长时间内没有被使用的页面

  • 使用历史而不是未来:很长时间没有被使用的页面可能会保持长时间未使用
  • 基于局部性原理

alt text

使用计数器跟踪页面最近最少被使用

  • 每个页面都有一个对应的计数器项
  • 每次页面被引用时,通过硬件将时钟寄存器的值复制到计数器中
  • 当需要更换页面时,查看计数器以找到最小的值

开销太大

6.4 二次机会算法

通过引用位来近似 LRU:寻找一个在最近的时钟周期内没有被引用的老页面

  • 系统中每个页面有一个引用位(R)
  • 每当引用页面(即读取或写入),引用位被设置为 1(由硬件完成)
  • 如果要被替换的页面:
    • R = 1:将引用位设置为 0,将其放在 FIFO 队列的末尾;并检查下一个页面
    • R = 0:替换它
  • 如果所有页面都被引用,那么第二次机会等于 FIFO

具体实现可以将所有页面帧以时钟形式放在一个循环列表中(避免在队列上移动页面)

  • 发生缺页异常时,检查当前指向的页面
    • R = 0:驱逐该页面
    • R = 1:将 R = 0 并将指针向前移

alt text

6.5 最近未使用(NRU)

通过两个状态位(引用位、修改位)来近似 LRU:为那些被修改的页面赋予更高的优先级以减少 I/O 负担(增强的第二次机会)

  • (0, 0):既不是最近使用的也没有被修改过(最适合替换)
  • (0, 1):不是最近使用的但被修改过(不太理想,需要在替换前写出)
  • (1, 0):最近被使用但是干净的(但可能会很快再次被使用)
  • (1, 1):最近被使用且被修改过(可能会很快再次被使用,并且需要在替换前写出)

从最低编号的非空类中随机移除一个页面(可能需要多次在循环队列中搜索)

6.6 抖动

抖动(也叫颠簸)

一个进程花费所有时间在页面间进行交换(大多数引用导致缺页异常)

  • 如果一个进程没有足够的页面,缺页率可能会非常高
  • 此时大部分 CPU 时间都被用来处理缺页异常
    • 等待缓慢的磁盘 I/O 操作
  • 调度器甚至还会造成问题加剧
    • 等待磁盘 I/O 导致 CPU 利用率下降
    • 调度器载入更多的进程以期提高 CPU 利用率
    • 本来物理页面就不足的局面 “雪上加霜”
    • 触发更多的缺页异常、进一步降低 CPU 利用率、导致连锁反应

6.7 工作集模型

局部性原理
  1. 相同的内存位置在不久的将来会再次被访问
  2. 未来将会访问附近的内存位置

工作集(Working Set):

  • 其在时间段 \((t-x, t)\) 内使用的内存页集合也被视为其在未来(下一个 \(x\) 时间内)会访问的页集
  • 如果整个工作集都在内存中,那么进程将运行而不会引起太多缺页异常,直到它进入另一个执行阶段
  • 如果可用内存太小,无法容纳整个工作集,则会发生抖动

All-or-nothing 模型:

  • 进程工作集要不都在内存中,否则全都换出
  • 需要跟踪每个进程的工作集,并确保在运行之前将其加载到内存中。
  • 大大降低缺页异常率

跟踪工作集 \(w(t, x)\):工作集时钟中断固定间隔发生,处理函数扫描内存页

  • 访问位为 1,说明在此次 tick 中被访问,记录上次使用时间为当前时间
  • 访问位为 0,则此次 tick 中未访问
    • Age = 当前时间 – 上次使用时间
    • 若 Age 大于设置的 x,则替换出工作集
  • 将所有访问位清 0
    • 注意访问位需要硬件支持

替换页帧是全局(从所有进程中选择)还是本地(只挑自己的页帧)?

本地页面置换:每个进程从其分配的帧集中选择受害者

  • 每个进程的帧数固定分配
  • 每个进程的性能更加一致
  • 但可能导致内存利用不足

全局页面置换:从分配给任何进程的帧中选择受害者

  • 每个进程的帧数可变
  • 吞吐量更大,因此更为普遍

采用全局页面置换时,操作系统必须不断决定为每个进程分配的页面帧数

  • 平均分配:为每个进程分配相等的份额
  • 比例分配:根据进程大小进行分配
  • 优先级分配

这些静态的帧数分配无法解决不断变化的动态需求

缺页异常频率(PFF)算法用于帧分配

  • 缺页异常率 = 每秒平均缺页数
  • 分配帧以使进程的 PFF 相等

alt text

  • 如果实际速率太低,则进程丢失帧
  • 如果实际速率太高,则进程获得帧

7 分页总结

进程创建

  • 确定程序和数据的大小并创建页表
  • 为页表分配空间并初始化
  • 为磁盘上的交换区域分配空间并初始化
  • 在进程表中记录有关页表和交换空间的信息

进程执行

  • 为新进程重置内存管理单元(页表基址寄存器)
  • 上下文切换:清除 TLB(除非它是带有标记的)
  • 可选地,将进程的一些或所有页面调入

页面守护进程

  • 大部分时间处于休眠状态,但定期唤醒以检查内存状态,并主动准备待驱逐的页面

缺页异常

  • 找到所需的页,并在磁盘上定位该页
  • 找到一个可用的物理页帧来放置新页帧(必要时替换旧页帧)
  • 将所需的页面读入该物理页帧
  • 备份程序计数器以再次执行指令

进程终止

  • 释放页表、页帧和交换空间
  • 共享页帧只能在使用它们的最后一个进程终止时释放

程序优化:应用局部性原理,程序员可进行优化

  • 内存的抽象
    • 屏蔽物理的局限(无限空间、连续、独享)
  • 实现机制:地址翻译
    • 需要硬件和 OS 配合
  • 底层内存的管理:连续分配、分段、分页
    • 利用 TLB 缓存
  • 实现虚拟内存的工程问题:
    • 换页、工作集

标题:地址空间

作者:Zwing

创建于:2026-08-09 00:08:00

更新于:2026-08-08 16:25:54

链接:https://zanytriumph.github.io/posts/虚拟化-地址空间.html

版权声明:本文章采用 CC BY-NC-SA 4.0 进行许可