资料来源:虚拟化部分 16 份课件,包括进程抽象、进程 API、受限直接执行、进程调度、MLFQ/比例份额、多处理器调度、地址空间、内存 API、地址转换、分段、分页、TLB、小页表、交换机制与交换策略。

复习主线

虚拟化要抓住一条线:操作系统把少量真实硬件包装成多个程序“各自独占”的假象,同时保持效率、控制和保护。

  1. CPU 虚拟化:用进程、上下文切换、受限直接执行、时钟中断和调度算法,让多个程序共享 CPU。
  2. 内存虚拟化:用地址空间、地址转换、分段、分页和 TLB,让每个进程看到独立、连续、受保护的虚拟内存。
  3. 超越物理内存:用交换空间、缺页异常和页面置换,让虚拟地址空间可以大于物理内存。

核心矛盾:

  • 透明性:进程最好感觉不到自己在共享资源。
  • 效率:虚拟化不能让程序慢太多。
  • 控制:操作系统必须能随时收回 CPU、限制内存访问、处理异常。
  • 保护:进程不能随便访问其他进程或内核。

一、CPU 虚拟化与进程

1. 为什么需要进程

早期单道程序系统中,内存里通常只装一个用户程序,CPU 只执行这一道程序。如果程序等待 I/O,CPU 就可能空闲,利用率低。

多道程序设计把多个程序同时放入内存,通过切换执行提高 CPU 利用率和系统吞吐量。但多个程序并发带来新问题:程序运行到哪里、寄存器是什么值、打开了哪些文件、占用了哪些资源、是否互相干扰,都需要被记录和管理。因此操作系统引入进程。

进程可以理解为:

$$ \text{进程}=\text{正在执行的程序}+\text{当前运行状态}+\text{操作系统管理的资源信息} $$

程序本身只是磁盘上的代码和静态数据;进程是程序运行起来之后的动态实体。

2. CPU 虚拟化

CPU 虚拟化是操作系统制造“很多虚拟 CPU”的假象。真实 CPU 数量有限,例如 8 个核心,但系统可以运行远多于 8 个进程。

实现方式是时分共享(Time Sharing):让一个进程运行一小段时间,再切换到另一个进程。用户看到的是多个程序似乎同时运行。

代价是性能损失:

  • 每个进程实际只能间歇性获得 CPU。
  • 上下文切换有开销。
  • 缓存和 TLB 可能被破坏,恢复局部性也要时间。

3. 进程控制块 PCB

PCB 是 Process Control Block,全称进程控制块。它是操作系统描述和管理进程的核心数据结构。

PCB 通常保存:

  • 进程标识信息:PID(Process Identifier,进程 ID)、PPID(Parent Process Identifier,父进程 ID)、UID(User Identifier,用户 ID)。
  • CPU 现场:PC(Program Counter,程序计数器)、通用寄存器、标志寄存器。
  • 栈相关寄存器:SP(Stack Pointer,栈指针)、FP(Frame Pointer,栈帧指针)。
  • 调度信息:进程状态、优先级、运行时间、队列位置。
  • 内存信息:地址空间、页表或段表相关指针。
  • I/O 信息:打开文件列表、文件描述符、当前目录等。

为什么进程被暂停后还能从原位置恢复?因为暂停时操作系统把 CPU 现场保存到 PCB;恢复时再把 PCB 中的现场装回 CPU。

4. 进程地址空间的基本组成

一个运行中的进程通常看到如下虚拟地址空间:

  • 代码段(Code Segment):程序指令,通常只读。
  • 已初始化数据段:已经赋初值的全局变量和静态变量。
  • 未初始化数据段:BSS(Block Started by Symbol),存放未初始化或初始化为 0 的全局/静态变量。
  • 堆(Heap):动态内存分配区域,通常向高地址增长。
  • 栈(Stack):函数调用、局部变量、参数、返回地址,通常向低地址增长。
  • 命令行参数与环境变量。
  • 内核映射区域:内核可能映射到每个进程地址空间中,但用户态进程不能直接访问。

5. 从程序到进程

程序变成进程时,操作系统通常要做这些事:

  1. 读取可执行文件,把代码和静态数据装入进程地址空间。Linux 常见格式是 ELF(Executable and Linkable Format,可执行与可链接格式),Windows 常见格式是 PE(Portable Executable,可移植可执行文件格式)。
  2. 为运行时栈分配内存,并把 main(argc, argv) 需要的命令行参数和环境变量放好。
  3. 为堆建立初始区域。堆通常按需增长,malloc() 请求时才真正分配更多空间。
  4. 初始化 I/O 环境。UNIX/Linux 进程默认打开 STDIN=0STDOUT=1STDERR=2
  5. 初始化 PCB、寄存器、页表/段表等内核数据结构。
  6. 把 CPU 控制权交给进程,从程序入口或 main() 附近开始执行。

“按需”是重要思想:代码、数据、堆页面不一定一次性全部装入物理内存,后续可通过分页和缺页异常逐步调入。

6. 进程状态

基本三状态:

  • 运行(Running):正在 CPU 上执行指令。
  • 就绪(Ready):已经准备好运行,但 CPU 当前没有分配给它。
  • 阻塞(Blocked):等待某个事件,例如 I/O 完成。

常见扩展状态:

  • 初始状态(Initial):进程正在创建。
  • 终止/最终状态(Final):进程已退出,等待资源回收。
  • 僵尸状态(Zombie):子进程已经结束,但父进程还没有通过 wait() 回收其退出状态,进程表项仍存在。

状态转换:

  • Ready -> Running:被调度(Scheduled)。
  • Running -> Ready:被取消调度或被抢占(Descheduled/Preempted)。
  • Running -> Blocked:发起 I/O 或等待事件。
  • Blocked -> Ready:等待事件完成,例如 I/O 中断到达。

二、进程 API 与系统调用

API 是 Application Programming Interface,全称应用程序编程接口。进程 API 是操作系统提供给程序管理进程的接口。

1. 常见进程 API

  • 创建:fork()exec()clone()
  • 销毁:kill(pid, SIGTERM) 等。
  • 等待:wait()waitpid()
  • 控制:SIGSTOP 暂停,SIGCONT 继续。
  • 状态查询:/proc/<pid>/statuspspstreetophtop

SIG 是 Signal,全称信号。信号是操作系统向进程传递事件的一种机制。

2. fork()

fork() 用于创建子进程。调用后,父进程和子进程都会从 fork() 返回处继续执行。

关键点:

  • 子进程拥有自己的地址空间、寄存器和程序计数器。
  • 父子进程共享相同的代码内容,但数据、堆、栈是逻辑独立的。
  • fork() 在子进程中返回 0,在父进程中返回子进程 PID。
  • 父子进程执行顺序不确定,由调度器决定。

易错点:fork() 不是“从 main 重新开始执行”,而是从 fork() 调用返回处继续执行。

3. wait()

wait() 让父进程等待子进程状态变化,常见情况是等待子进程结束。

作用:

  • 让输出顺序更确定。
  • 回收子进程退出状态。
  • 避免子进程长期处于僵尸状态。

如果父进程先运行并调用 wait(),它会阻塞,直到子进程终止。

4. exec()

exec() 用于在当前进程中加载并运行一个新的可执行程序。

关键点:

  • exec() 不创建新进程。
  • 成功后,当前进程的代码、静态数据、堆、栈、寄存器和 PC 都被新程序替换。
  • exec() 成功后不会返回到原程序后续代码。

fork()exec() 的区别:

调用 是否创建新进程 作用
fork() 复制当前进程,产生父子进程
exec() 用新程序替换当前进程映像

5. 为什么 UNIX/Linux 分离 fork() 和 exec()

Shell 通常采用 fork() + exec() 启动命令。

这样设计的好处是:Shell 在 fork() 之后、exec() 之前可以对子进程环境做准备,例如:

  • I/O 重定向:关闭 STDOUT,再打开目标文件,使新文件获得文件描述符 1。
  • 管道:用 pipe() 把一个进程的输出连接到另一个进程的输入。
  • 后台运行:父 Shell 不等待子进程,继续接收命令。

STDIN 是 Standard Input,标准输入,文件描述符 0。

STDOUT 是 Standard Output,标准输出,文件描述符 1。

STDERR 是 Standard Error,标准错误,文件描述符 2。

IPC 是 Inter-Process Communication,全称进程间通信。

6. 进程终止与资源回收

进程结束后,操作系统需要回收:

  • 物理内存或虚拟内存映射。
  • 打开的文件描述符。
  • PCB 和调度队列中的记录。
  • 子进程退出状态。

如果子进程结束但父进程未回收,子进程会短暂成为 zombie。

三、受限直接执行机制 LDE

LDE 是 Limited Direct Execution,全称受限直接执行。

它解决 CPU 虚拟化中的两个问题:

  1. 性能:程序大部分时间直接在 CPU 上运行,避免每条指令都由操作系统解释。
  2. 控制:通过硬件特权级、中断、异常和系统调用,保证操作系统能限制危险操作并收回 CPU。

1. 用户态与内核态

用户模式(User Mode):

  • 普通应用程序运行在用户态。
  • 不能直接执行特权指令。
  • 不能直接访问任意硬件和内核内存。

内核模式(Kernel Mode):

  • 操作系统内核运行在内核态。
  • 可以执行特权指令。
  • 可以访问硬件资源和内核数据结构。

这种模式隔离保证应用程序不能随便破坏系统。

2. 系统调用与 trap

系统调用(System Call)是用户程序请求内核服务的受控入口,例如文件 I/O、进程创建、内存映射。

trap 是陷入。用户程序执行 trap 指令后:

  1. CPU 从用户态切到内核态。
  2. 保存必要现场,例如 PC、标志寄存器、部分通用寄存器。
  3. 跳转到内核中预先注册的处理函数。
  4. 内核完成服务。
  5. 执行 return-from-trap,恢复用户现场并切回用户态。

x86 中常见系统调用相关指令:

  • INT:Interrupt,中断指令,早期系统调用入口。
  • SYSCALL:x86-64 快速系统调用指令。
  • IRET:Interrupt Return,中断返回。
  • SYSRET:System Call Return,系统调用返回。

3. Trap Table、IDT、IVT

Trap Table 是陷阱表,也可理解为异常、中断、系统调用的处理程序入口表。

IDT 是 Interrupt Descriptor Table,全称中断描述符表。现代 x86 保护模式下,CPU 根据中断向量号在 IDT 中找到处理函数入口。

IDTR 是 Interrupt Descriptor Table Register,全称中断描述符表寄存器,用于保存 IDT 的地址和界限。

IVT 是 Interrupt Vector Table,全称中断向量表。x86 实模式中,IVT 位于低地址,共 256 项。

中断向量号(Interrupt Vector Number)用于区分事件类型,例如除零、时钟中断、系统调用。

4. 中断、异常、trap、fault、abort

中断(Interrupt)通常是异步硬件事件,例如键盘输入、磁盘 I/O 完成、时钟中断。

异常(Exception)通常由当前执行指令触发,例如除零、非法地址访问。

x86 中异常常分为:

  • Trap:处理后继续执行下一条指令。
  • Fault:处理后重新执行导致异常的指令,例如缺页异常。
  • Abort:严重错误,通常无法可靠恢复。

5. 操作系统如何重新获得 CPU 控制权

合作式方式:

  • 进程主动发起系统调用,例如 yield()
  • 进程执行非法操作导致异常。
  • 问题:如果进程陷入无限循环且不系统调用,操作系统可能无法及时收回 CPU。

非合作式方式:

  • 使用时钟中断(Timer Interrupt)。
  • 硬件定期触发中断,CPU 进入内核。
  • 内核时钟中断处理程序调用调度器,必要时抢占当前进程。

PIT 是 Programmable Interval Timer,全称可编程间隔定时器,是早期 x86 上产生周期性时钟中断的硬件。

6. 上下文切换

上下文切换(Context Switch)是操作系统从一个进程切换到另一个进程的过程。

基本步骤:

  1. 保存当前进程上下文:PC、寄存器、SP、状态等。
  2. 把上下文写入当前进程 PCB。
  3. 调度器选择下一个进程。
  4. 恢复下一个进程上下文。
  5. 必要时切换地址空间,例如切换页表基址寄存器。
  6. 返回用户态执行新进程。

上下文切换的开销不仅是保存寄存器,还包括缓存、TLB 和分支预测状态受到影响。

7. 中断处理中的并发问题

如果内核正在处理中断,又来了另一个中断,可能破坏内核数据结构。常见处理方法:

  • 在关键中断处理期间禁用中断。
  • 使用锁保护共享内核数据结构。

CLI 是 Clear Interrupt Flag,全称清除中断标志,常用于禁用可屏蔽中断。

STI 是 Set Interrupt Flag,全称设置中断标志,常用于重新启用中断。

四、进程调度

调度器(Scheduler)根据调度策略(Scheduling Policy)决定哪个就绪进程获得 CPU。

1. 调度目标与指标

不同系统重视不同指标:

  • 吞吐量(Throughput):单位时间完成的任务数。
  • CPU 利用率(CPU Utilization):CPU 忙于有效工作的比例。
  • 周转时间(Turnaround Time):完成时间 - 到达时间。
  • 平均周转时间 ATT(Average Turnaround Time):所有周转时间的平均值。
  • 响应时间(Response Time):到达后到第一次被调度或第一次产生输出的时间。
  • 平均响应时间 ART(Average Response Time):所有响应时间的平均值。
  • 公平性(Fairness):各进程获得合理 CPU 机会。

常用数学表达式:

$$ \begin{gathered} X = \frac{N}{T} \ U = \frac{B}{T} \ T_i = C_i - A_i \ ATT = \frac{1}{n}\sum_{i=1}^{n}T_i \ R_i = F_i - A_i \ ART = \frac{1}{n}\sum_{i=1}^{n}R_i \ W_i = T_i - S_i \ NTT_i = \frac{T_i}{S_i} \ F = \frac{\min_i Q_i}{\max_i Q_i} \end{gathered} $$

符号说明:X 为吞吐量;N 为完成任务数;T 为总时间;U 为 CPU 利用率;B 为 CPU 忙碌时间;T_i 为作业 i 的周转时间;C_i 为完成时间;A_i 为到达时间;ATT 为平均周转时间;R_i 为响应时间;F_i 为首次运行时间;ART 为平均响应时间;W_i 为等待时间;S_i 为实际运行时间;NTT_i 为带权周转时间;F 为公平性比值;Q_i 为进程 i 获得的 CPU 时间或资源份额。

性能和公平性经常冲突:极端优化平均周转时间可能让长作业饥饿;极端公平可能降低短作业体验。

2. FCFS/FIFO

FCFS 是 First-Come, First-Served,全称先来先服务。

FIFO 是 First-In, First-Out,全称先进先出。FCFS 通常用 FIFO 队列实现。

特点:

  • 非抢占式。
  • 实现简单。
  • 如果长作业排在前面,短作业必须等待,产生护航效应。

Convoy Effect 是护航效应,指短任务排在长任务后面导致整体平均周转时间变差。

3. SJF

SJF 是 Shortest Job First,全称最短作业优先。

思想:先运行预计执行时间最短的作业。

特点:

  • 非抢占式。
  • 所有作业同时到达且运行时间已知时,平均周转时间最优。
  • 现实难点是运行时间通常未知。
  • 长作业可能饥饿。

4. STCF/PSJF

STCF 是 Shortest Time-to-Completion First,全称最短完成时间优先。

PSJF 是 Preemptive Shortest Job First,全称抢占式最短作业优先。

思想:每次调度时选择剩余运行时间最短的进程。新进程到达时,如果它的剩余时间更短,可以抢占当前进程。

优点:在关注平均周转时间时效果好。

缺点:对响应时间不一定好,长作业可能长期得不到运行。

5. RR

RR 是 Round Robin,全称轮转调度,也叫时间片调度(Time-Slicing Scheduling)。

思想:每个进程运行一个时间片 q,到期后切换到队列下一个进程。

特点:

  • 公平。
  • 响应时间较好,适合分时系统和交互系统。
  • 时间片太短,上下文切换开销占比高。
  • 时间片太长,响应时间变差,接近 FCFS。

时间片设计要权衡响应性和调度开销。

RR 中常考调度开销比例:

$$ \begin{gathered} O_r = \frac{O}{q+O} \ E = \frac{q}{q+O} \end{gathered} $$

符号说明:$O_r$ 为调度开销比例;O 为每个时间片对应的调度开销;q 为时间片长度;E 为有效 CPU 时间比例。

6. I/O 与调度

I/O 是 Input/Output,全称输入/输出。

当进程发起 I/O:

  1. 进程进入阻塞态。
  2. 调度器应运行其他就绪进程,避免 CPU 空闲。
  3. I/O 完成后设备发出中断。
  4. 内核把进程从阻塞态改为就绪态。

I/O 密集型进程经常短暂运行后阻塞;CPU 密集型进程长时间占用 CPU。调度器通常希望兼顾二者。

五、MLFQ 与比例份额调度

1. MLFQ

MLFQ 是 Multilevel Feedback Queue,全称多级反馈队列。

CTSS 是 Compatible Time-Sharing System,全称兼容分时系统。MLFQ 思想可追溯到早期分时系统。

MLFQ 的目标:

  • 在不知道作业长度的情况下近似 SJF/STCF。
  • 优先照顾交互型、短作业特征的进程,降低响应时间。
  • 根据进程过去的 CPU 使用行为动态调整优先级。

基本规则:

  1. 如果 Priority(A) > Priority(B),运行 A。
  2. 如果 Priority(A) = Priority(B),同队列内使用 RR。
  3. 新进程进入系统时放入最高优先级队列。
  4. 进程在某队列中消耗完整时间配额后,优先级降低。
  5. 每隔一段时间 S,把所有进程提升到最高优先级队列。

MLFQ 的直觉:

  • 交互型进程经常在时间片用完前等待 I/O,因此保持高优先级,响应快。
  • CPU 密集型进程经常用完整个时间片,因此逐步降级。

MLFQ 的问题和修正:

  • 饥饿:高优先级交互进程太多时,低优先级长作业可能一直等。解决方法是周期性优先级提升。
  • 行为变化:进程可能从 CPU 密集变成 I/O 密集。优先级提升可以让它重新获得机会。
  • 欺骗调度器:进程在时间片快用完前主动 I/O,避免降级。解决方法是累计某队列中总 CPU 使用量,而不是只看一次是否用完整个时间片。

2. nice 值

nice 是 UNIX/Linux 中影响普通进程调度权重的机制。

nice 值通常范围为 -20 到 +19:

  • nice 值越小,优先级越高,权重越大。
  • nice 值越大,优先级越低,权重越小。

renice 可以修改运行中进程的 nice 值。

3. 比例份额调度

比例份额调度也叫公平份额调度(Fair-Share Scheduling),目标是保证每个进程获得一定比例的 CPU 时间,而不一定优化周转时间或响应时间。

比例份额目标可写成:

$$ \begin{gathered} s_i = \frac{t_i}{\sum_j t_j} \ C_i \approx C_{total} \cdot s_i \end{gathered} $$

符号说明:s_i 为进程 i 的 CPU 份额;t_i 为进程 i 的彩票数;Σ t_j 为所有进程彩票总数;C_i 为进程 i 获得的 CPU 时间;C_{total} 为总 CPU 时间。

4. 彩票调度

彩票调度(Lottery Scheduling)用随机抽奖实现比例份额。

思想:

  • 每个进程持有若干张彩票。
  • 每个时间片随机抽一张中奖彩票。
  • 持有中奖彩票的进程运行。
  • 长期看,进程获得 CPU 的比例接近其彩票比例。

例:A 有 75 张票,B 有 25 张票,则长期 A 约获得 75% CPU,B 约获得 25% CPU。

相关机制:

  • 彩票货币(Ticket Currency):用户可以用自己的局部货币分配给进程,系统换算成全局彩票。
  • 彩票转让(Ticket Transfer):一个进程临时把彩票转给另一个进程,适合客户端-服务器场景。
  • 彩票通胀(Ticket Inflation):进程临时增加或减少彩票数量,但需要信任环境,否则可能滥用。

优点:简单、随机化可避免某些边界情况。

缺点:短时间内可能不公平,票数如何分配也是问题。

5. 步长调度

步长调度(Stride Scheduling)是确定性的公平份额调度。

基本公式:

$$ \begin{gathered} l_i = \frac{L}{t_i} \ p_i' = p_i + l_i \ k = \arg\min_i p_i \end{gathered} $$

符号说明:l_i 为进程 i 的步长;L 为足够大的常数;t_i 为进程 i 的票数;p_i 为进程 i 当前 pass 值;p_i' 为运行一次后的 pass 值;k 为下一个被调度的进程。

每个进程维护 pass 值:

  1. 选择 pass 值最小的进程运行。
  2. 运行后,p_i' = p_i + l_i
  3. 票越多,stride 越小,pass 增长越慢,因此更频繁被选中。

优点:比彩票调度更确定,短期公平性更好。

缺点:新进程如果 pass 初值处理不当,可能暂时垄断 CPU。

6. CFS

CFS 是 Completely Fair Scheduler,全称完全公平调度器,是 Linux 的普通进程调度器思想。

核心概念:

  • vruntime 是 Virtual Runtime,全称虚拟运行时间。
  • 调度器总是选择 vruntime 最小的进程运行。
  • nice 值映射为权重,权重高的进程 vruntime 增长更慢,因此更频繁运行。
  • 可运行进程按 vruntime 放入红黑树。

RB Tree 是 Red-Black Tree,全称红黑树。它是自平衡二叉搜索树,插入、删除、查找复杂度通常为 O(log n)。CFS 取最左节点作为 vruntime 最小的进程。

常见参数:

  • sched_latency:调度延迟,表示一轮公平调度周期的目标长度。
  • min_granularity:最小调度粒度,避免进程很多时每个时间片过小。

近似公式:

$$ \begin{gathered} time_slice(process) = sched_latency \cdot weight(process) / sum(weights) \ vruntime += actual_runtime \cdot 1024 / weight(process) \end{gathered} $$

权重越大,实际时间片越长,vruntime 增长越慢。

六、多处理器调度

1. 多核与线程

多核处理器(Multicore Processor)是在同一芯片上集成多个 CPU 核心。核心变多不会自动让单线程程序更快;程序需要通过线程(Threads)或多进程并行才能利用多个核心。

LWP 是 Light Weight Process,全称轻量级进程。在 Linux 工具输出中,LWP 常用于表示内核调度实体,也就是线程。

2. Cache 与局部性

Cache 是缓存,是小而快的硬件存储器,用于保存主存中常用数据副本。

局部性:

  • 时间局部性(Temporal Locality):最近访问过的数据或指令,短期内可能再次访问。
  • 空间局部性(Spatial Locality):访问某地址后,附近地址也可能很快被访问。

3. 缓存一致性

缓存一致性(Cache Coherence)要求多个 CPU 缓存中同一共享数据的副本保持一致。

问题例子:

  1. CPU0 和 CPU1 都缓存了地址 X 的旧值。
  2. CPU0 修改 X,但 CPU1 缓存未更新。
  3. CPU1 再读 X 时可能读到旧值。

常见硬件方案是总线嗅探(Bus Snooping):缓存监听总线上的读写事务,一旦发现其他 CPU 修改了自己缓存的共享块,就使本地副本失效或更新。

4. 同步

Synchronization 是同步。多核系统中多个 CPU 可能同时访问共享数据,必须使用互斥机制保证正确性。

Mutex 是 Mutual Exclusion,全称互斥锁。锁保护临界区,防止多个线程同时修改共享结构。

5. 缓存亲和性

缓存亲和性(Cache Affinity)指尽量让同一进程/线程继续在同一个 CPU 上运行。

原因:进程上次运行留下的缓存数据可能仍在该 CPU 的缓存中,再次在同一 CPU 上运行会更快。频繁迁移到其他 CPU 会导致缓存失效和更多 Cache Miss。

Cache Miss 是缓存未命中。

6. 单队列多处理器调度

单队列多处理器调度(Single-Queue Multiprocessor Scheduling)把所有可运行任务放入一个全局队列,多个 CPU 从同一个队列取任务。

优点:

  • 实现简单。
  • 全局负载比较容易平衡。

缺点:

  • 多 CPU 访问共享队列需要加锁,可扩展性差。
  • 任务可能频繁迁移,缓存亲和性差。

7. 多队列多处理器调度

多队列多处理器调度(Multi-Queue Multiprocessor Scheduling)为每个 CPU 维护本地运行队列。

优点:

  • 减少共享队列锁竞争,可扩展性更好。
  • 更容易保持缓存亲和性。

缺点:

  • 各队列可能负载不均。
  • 需要任务迁移(Migration)或工作窃取来做负载均衡。

负载均衡要在两件事之间权衡:

  • 迁移任务能提高 CPU 利用率。
  • 迁移任务会损失缓存亲和性并带来迁移开销。

七、内存虚拟化与地址空间

1. 内存虚拟化

Memory Virtualization 是内存虚拟化。操作系统和硬件共同为每个进程提供独立的虚拟地址空间,使进程看起来像独占一整块连续内存。

关键硬件:

  • MMU:Memory Management Unit,全称内存管理单元,负责地址转换和权限检查。
  • TLB:Translation Lookaside Buffer,全称转换后备缓冲,用于缓存地址转换结果。

内存虚拟化的优势:

  • 易用性:程序使用连续虚拟地址,不需要直接管理物理地址。
  • 效率:按需加载、共享代码、交换到磁盘,提高内存利用率。
  • 保护:隔离进程和内核,防止非法访问。

虚拟内存系统目标:

  • 透明性(Transparency):进程感觉自己拥有独立内存。
  • 效率(Efficiency):时间和空间开销可接受。
  • 保护与隔离(Protection and Isolation):非法访问会被阻止。

2. 虚拟地址与物理地址

VA 是 Virtual Address,全称虚拟地址。进程生成的地址都是虚拟地址。

PA 是 Physical Address,全称物理地址。真实内存硬件使用物理地址。

地址转换(Address Translation)是把 <pid, virtual address> 映射到 physical address 的过程。进程不能直接访问物理地址。

地址转换带来四个重要能力:

  • 保护:不同进程映射到不同物理内存区域。
  • 重定位:进程可加载到任意物理位置,虚拟地址不变。
  • 数据共享:不同进程的虚拟页可映射到同一物理页。
  • 多路复用:有限物理内存可在不同时间服务不同虚拟页。

八、内存操作 API 与空闲空间管理

1. 栈与堆

栈:

  • 存放函数参数、返回地址、局部变量。
  • 生命周期短,随函数调用自动分配和释放。
  • 由编译器和调用约定隐式管理。

堆:

  • 存放动态分配对象。
  • 生命周期由程序员控制。
  • 通过 malloc()free()calloc()realloc() 等 API 管理。
  • 易出现内存泄漏、悬空指针等错误。

2. malloc()

malloc() 在堆上分配指定字节数。

void *malloc(size_t size);

size_t 是无符号整数类型,用于表示对象大小。

返回值:

  • 成功:返回指向内存块的 void * 指针。
  • 失败:返回 NULL

建议用 sizeof() 计算大小,避免硬编码:

int *a = malloc(n * sizeof(int));

字符串分配要给结尾空字符 \0 留空间:

char *s = malloc(strlen(src) + 1);

3. free()

free() 释放由 malloc()calloc()realloc() 返回的堆内存。

void free(void *ptr);

注意:

  • 只能释放堆上分配得到的指针。
  • free(NULL) 是安全的,但无实际效果。
  • 释放后指针本身不会自动变成 NULL,可能成为悬空指针。

4. calloc() 与 realloc()

calloc() 分配数组并清零:

void *calloc(size_t num, size_t size);

realloc() 调整已分配内存块大小:

void *realloc(void *ptr, size_t size);

realloc() 可能原地扩展,也可能移动到新地址。失败返回 NULL,原内存块仍有效,因此实际代码中不要直接覆盖原指针。

5. 常见内存错误

  • 未初始化指针:把未定义地址当作目标地址写入。
  • 分配不足:例如忘记字符串结尾 \0
  • 未初始化读取:读取未赋值内存。
  • 内存泄漏(Memory Leak):分配的内存失去引用且未释放。
  • 悬空指针(Dangling Pointer):指向已经释放或失效对象。
  • 双重释放(Double Free):同一块内存释放两次。
  • 无效释放(Invalid Free):释放栈变量、未初始化指针或非分配起始地址。

Valgrind 的 Memcheck 可检测内存泄漏、无效访问、未初始化使用等问题。

6. brk/sbrk 与 mmap

brk() 设置进程的 program break,即数据段末尾位置。

sbrk() 相对移动 program break。

现代程序通常不直接调用 brk()/sbrk(),而是使用 malloc()/free()。现代分配器也会用 mmap() 处理大块或特殊分配。

mmap() 用于在进程地址空间中建立内存映射,可映射文件或匿名内存。

7. 空闲空间管理

Free-Space Management 是空闲空间管理。目标是在可变大小内存块中快速分配、释放,并尽量减少碎片。

两类碎片:

  • 内部碎片(Internal Fragmentation):已分配块内部有未使用空间。
  • 外部碎片(External Fragmentation):空闲总量足够,但分散成小块,无法满足大请求。

碎片指标可写成:

$$ \begin{gathered} I = A - R \ I_r = \frac{I}{A} \ E = \sum f_i \ M = \max(f_i) \ 外部碎片 \Longleftrightarrow E \ge R \land M < R \end{gathered} $$

符号说明:I 为内部碎片大小;I_r 为内部碎片率;A 为实际分配块大小;R 为请求大小;E 为空闲空间总量;f_i 为第 i 个空闲块大小;M 为最大空闲块大小。

8. 分割、合并与头部

Splitting 是分割:找到比请求更大的空闲块,把它切成“已分配部分”和“剩余空闲部分”。

Coalescing 是合并:释放后如果相邻块都是空闲块,就合并为更大空闲块,减少外部碎片。

free(ptr) 没有大小参数,分配器通常在用户指针前保存 header:

  • 块大小。
  • magic number,用于完整性检查。
  • 可能包含链表指针。

释放时,分配器通过 ptr 向前找到 header,从而知道释放范围。

9. 空闲链表策略

Best Fit,全称最佳适配:

  • 找到能容纳请求的最小空闲块。
  • 试图减少浪费,但可能产生很多很小碎片。

Worst Fit,全称最坏适配:

  • 找最大空闲块进行分割。
  • 试图留下较大的剩余块,但通常需要遍历,效果不一定好。

First Fit,全称首次适配:

  • 从链表头开始找第一个足够大的块。
  • 速度较快,但前部容易碎片化。

Next Fit,全称下次适配:

  • 从上次查找结束位置继续找。
  • 减少从头反复扫描的偏向。

10. 分离列表、Slab 与伙伴分配

Segregated Lists 是分离列表:按大小类维护多个空闲链表,例如 8B、16B、32B。常用于频繁分配固定大小对象。

Slab Allocator 是 Slab 分配器:为常见内核对象维护缓存,例如锁、inode 等。对象重复创建和销毁时可复用初始化好的内存。

Buddy Allocation 是伙伴分配。它把内存按 2 的幂大小管理:

  1. 请求到来时,找到能容纳请求的最小 2 的幂块。
  2. 如果块太大,就不断对半分裂。
  3. 释放时,如果伙伴块也空闲,就合并。

优点:合并简单。

缺点:可能产生内部碎片。

九、基址-界限与分段

1. 动态重定位

Dynamic Relocation 是动态重定位。最简单硬件机制是 base and bound,即基址加界限。

硬件寄存器:

  • Base Register:基址寄存器,保存进程虚拟地址 0 对应的物理起始地址。
  • Bounds Register:界限寄存器,保存地址空间大小或结束边界。

地址转换:

$$ \begin{gathered} 0 \le v < L \Rightarrow p = B + v \ v \ge L \Rightarrow \text{trap} \end{gathered} $$

符号说明:v 为虚拟地址;L 为界限寄存器值;p 为物理地址;B 为基址寄存器值;trap 表示越界异常。

优点:

  • 实现简单。
  • 支持重定位和保护。

缺点:

  • 每个进程需要连续物理内存。
  • 地址空间中未使用区域也占着物理范围,容易产生内部碎片。

上下文切换时,操作系统要保存和恢复 base/bound 寄存器。

2. 分段

Segmentation 是分段。它是基址-界限机制的泛化:不是给整个地址空间一对 base/bound,而是给每个逻辑段一对 base/bound。

典型段:

  • 代码段。
  • 数据段。
  • 堆段。
  • 栈段。

每个段在物理内存中各自连续,但不同段之间不必相邻。

地址转换需要确定:

  1. 段号(Segment Number)。
  2. 段内偏移(Offset)。
  3. 对应段的 base、bound 和权限。

物理地址:

$$ p = B_s + d $$

符号说明:p 为物理地址;B_s 为段 s 的基址;d 为段内偏移。前提是 0 ≤ d < L_s,且访问权限合法;L_s 为段 s 的界限。

3. 如何确定段号

显式方式:

  • 用虚拟地址高几位表示段号。
  • 剩余位表示段内偏移。

隐式方式:

  • 根据访问类型判断段。
  • 例如取指访问代码段,栈指针相关访问栈段。

显式方式简单,但段号位可能浪费,且偏移位数限制段最大大小。

4. 栈段反向增长

栈通常向低地址增长。硬件需要知道段增长方向。

对反向增长段,地址转换可能要使用负偏移:

$$ \begin{gathered} d' = d - M \ p = B_s + d' \end{gathered} $$

符号说明:d' 为反向增长段使用的负偏移;d 为地址中给出的偏移;M 为该段最大大小;p 为物理地址;B_s 为段基址。

5. 分段的共享与保护

段可以共享。例如多个进程的代码段可以映射到同一个物理位置,节省内存。

段表项通常包含保护位:

  • R:Read,读权限。
  • W:Write,写权限。
  • X:Execute,执行权限。

非法访问会触发段错误(Segmentation Fault)。

6. 分段的问题

分段减少了基址-界限的内部碎片,但引入外部碎片。因为段大小可变,物理内存中可能出现很多零散空洞。

解决思路:

  • 内存压缩(Memory Compaction):移动段,合并空闲空间。代价高,需要暂停进程、复制数据、更新段寄存器。
  • 空闲链表算法:Best Fit、Worst Fit、Buddy 等。

十、分页

1. 分页概念

Paging 是分页。它把虚拟地址空间切成固定大小的页(Page),把物理内存切成同样大小的页框(Page Frame)。

每个进程有页表(Page Table),记录虚拟页到物理页框的映射。

优点:

  • 不要求进程在物理内存中连续。
  • 空闲空间管理简单,按固定页框分配。
  • 支持稀疏地址空间和灵活映射。

缺点:

  • 页表可能很大。
  • 每次地址转换可能需要额外访存。
  • 页内部可能有内部碎片。

2. 虚拟地址划分

VPN 是 Virtual Page Number,全称虚拟页号。

Offset 是页内偏移。

PFN 是 Physical Frame Number,全称物理页框号。

页大小为 2^n 字节时:

$$ \begin{gathered} b = n \ v = a - b \ PA = (PFN << b) | d \end{gathered} $$

符号说明:b 为页内偏移位数;n 为页面大小的指数;v 为 VPN 位数;a 为虚拟地址位数;PA 为物理地址;PFN 为物理页帧号;d 为页内偏移。

页表负责把 VPN 映射到 PFN。

例:32 位虚拟地址,页大小 4KB:

  • 4KB = 2^12,所以 offset 为 12 位。
  • VPN 为 32 - 12 = 20 位。
  • 线性页表有 2^20 个页表项。

3. 页表项 PTE

PTE 是 Page Table Entry,全称页表项。

常见字段:

  • PFN:Physical Frame Number,物理页框号。
  • Valid Bit:有效位,表示该虚拟页映射是否合法。
  • Present Bit:存在位,表示页面当前是否在物理内存中。
  • Protection Bit:保护位,控制读/写/执行权限。
  • Dirty Bit:脏位,表示页面载入后是否被修改。
  • Reference Bit 或 Accessed Bit:引用位/访问位,表示页面是否被访问过。

x86 常见 PTE 标志:

  • P:Present,存在位。
  • R/W:Read/Write,读写位。
  • U/S:User/Supervisor,用户/超级用户权限位。
  • A:Accessed,访问位。
  • D:Dirty,脏位。

4. PTBR 与分页开销

PTBR 是 Page Table Base Register,全称页表基址寄存器,保存当前进程页表起始地址。

最基本分页中,每次访问内存可能需要两次内存访问:

  1. 访问页表,读取 PTE。
  2. 根据 PFN + offset 访问真实数据或指令。

所以分页若没有 TLB,会显著变慢。

5. 页表大小计算

公式:

$$ \begin{gathered} N_p = V / P \ S_{PT} = N_p \cdot S_{PTE} \ b = \log_2(P) \ v = a - b \ N_{PTE} = 2^v \ S_{PT} = N_{PTE} \cdot S_{PTE} \end{gathered} $$

符号说明:$N_p$ 为虚拟页数;V 为虚拟地址空间大小;$P$ 为页大小;$S_{PT}$ 为页表大小;$S_{PTE}$ 为单个 PTE 大小;b 为页内偏移位数;v 为 VPN 位数;a 为虚拟地址位数;$N_{PTE}$ 为页表项数量。

例:32 位地址空间,4KB 页,4B PTE:

$$ \begin{gathered} N_p = 2^32 / 2^12 = 2^20 \ S_{PT} = 2^20 \cdot 4B = 4\text{MB} \end{gathered} $$

注意:这是每个进程一张页表的大小。如果系统中有很多进程,页表内存开销很大。

十一、TLB 快速地址转换

1. TLB 基本作用

TLB 是 Translation Lookaside Buffer,全称转换后备缓冲。它是 MMU 中或与 MMU 协同工作的硬件缓存,用来缓存 VPN -> PFN 的地址转换结果。(系统级)

TLB 相关指标:

$$ \begin{gathered} h = \frac{H}{N} \ m = \frac{M}{N} \ h + m = 1 \ EAT = h \cdot C_h + m \cdot C_m \end{gathered} $$

符号说明:h 为 TLB 命中率;m 为 TLB 未命中率;H 为命中次数;M 为未命中次数;N 为地址转换总次数;EAT 为有效地址转换代价;$C_h$ 为命中代价;$C_m$ 为未命中代价。

TLB 命中流程:

  1. CPU 生成虚拟地址。
  2. 硬件提取 VPN。
  3. 查 TLB。
  4. 若命中,取出 PFN。
  5. 拼接 PFN 和 offset,访问物理内存。

TLB 未命中流程:

  1. 查页表。
  2. 如果 PTE 有效且权限合法,把映射插入 TLB。
  3. 重新执行原指令。
  4. 如果 PTE 无效或页面不在内存,触发异常,例如 page fault。

2. TLB 为什么有效

因为程序具有局部性:

  • 时间局部性:最近访问过的页可能再次访问。
  • 空间局部性:同一页内相邻地址可能连续访问。

例如顺序访问数组时,同一页内多个元素共享同一个 VPN -> PFN 映射。第一次访问某页 TLB miss,后续访问同页元素可能 hit。

3. TLB 未命中由谁处理

硬件管理 TLB(Hardware-Managed TLB):

  • 硬件知道页表结构。
  • TLB 未命中时硬件自动遍历页表。
  • 若 PTE 有效,则填入 TLB。
  • 若无效或权限错误,触发异常给 OS。

软件管理 TLB(Software-Managed TLB):

  • 硬件在 TLB 未命中时触发异常。
  • OS 的异常处理程序查页表并填 TLB。
  • 常见于一些 RISC 架构。

RISC 是 Reduced Instruction Set Computer,全称精简指令集计算机。

4. TLB 项内容

TLB Entry 通常包含:

  • VPN。
  • PFN。
  • Valid Bit。
  • Protection Bits。
  • ASID。

ASID 是 Address Space Identifier,全称地址空间标识符。它用于区分不同进程的同一 VPN。

没有 ASID 时,上下文切换后,旧进程的 TLB 项可能被新进程误用。因此系统可能需要 flush TLB,即清空 TLB。ASID 可以减少上下文切换时的 TLB 清空开销。

5. TLB 替换策略

TLB 容量有限,未命中后插入新项时可能需要淘汰旧项。

LRU 是 Least Recently Used,全称最近最久未使用。它淘汰最长时间没有被访问的项,利用局部性减少 miss。

也可以使用随机替换或近似策略,降低硬件实现复杂度。

十二、较小的页表

1. 为什么线性页表太大

线性页表为整个虚拟地址空间预留 PTE,即使大部分虚拟页未使用也占空间。

直接增大页面大小可以减少页表项数量,但会带来:

  • 更严重的内部碎片。
  • 换入换出粒度变大。
  • 可能浪费 I/O。

因此需要更好的页表压缩方法。

2. 分页分段结合

分页分段结合的思想:每个逻辑段单独维护页表,而不是整个虚拟地址空间一张页表。

段表项中:

  • base 不再指向段本身,而是指向该段页表的物理地址。
  • bound 表示该段页表长度或段界限。

地址转换流程:

  1. 从虚拟地址取段号 SN(Segment Number)。
  2. 查段表,得到该段页表基址和界限。
  3. 检查 VPN 是否越界。
  4. 根据 Base[SN] + VPN * sizeof(PTE) 找 PTE。
  5. 取 PFN,与 offset 拼接成物理地址。

优点:

  • 不为未使用的大段间空洞分配页表。
  • 相比单级线性页表节省空间。

缺点:

  • 如果某个段很大但使用稀疏,段内页表仍可能浪费。
  • 同时维护段表和多个页表,复杂度更高。

3. 多级页表

多级页表把线性页表拆成树状结构,只为实际使用的虚拟地址范围分配下级页表页。

PDE 是 Page Directory Entry,全称页目录项。

PDIndex 是 Page Directory Index,全称页目录索引。

PTIndex 是 Page Table Index,全称页表索引。

PDBR 是 Page Directory Base Register,全称页目录基址寄存器。

两级页表思路:

  • 一级页目录由 PDE 组成。
  • 每个 PDE 指向一个二级页表
  • 如果某个虚拟地址范围没有有效页,PDE 无效,对应二级页表页不分配。

PTE 地址计算:

$$ PTEAddr = (PDE.PFN << SHIFT) + PTIndex \cdot sizeof(PTE) $$

多级页表优点:

  • 按实际使用地址范围分配页表空间。
  • 页表页不需要物理连续,扩展方便。

多级页表缺点:

  • 地址翻译可能需要多次访存,是典型时间-空间权衡。
  • 实现更复杂。
  • 依赖 TLB 降低多次查表成本。

4. 多级页表计算套路

给定虚拟地址位数、页面大小、PTE 大小:

  1. b = log2(P)
  2. v = a - b
  3. $e = P / S_{PTE}$。
  4. p = log2(e)
  5. d = v - p

符号说明:b 为页内偏移位数;P 为页大小;v 为 VPN 位数;a 为虚拟地址位数;e 为一个页表页可容纳的 PTE 数;S_{PTE} 为单个 PTE 大小;p 为页表索引位数;d 为页目录索引位数。

例:32 位地址,4KB 页,4B PTE:

  • offset = 12 位。
  • VPN = 20 位。
  • 每页表页 4096 / 4 = 1024 = 2^10 项。
  • PTIndex = 10 位。
  • PDIndex = 10 位。
  • 地址划分:10 位页目录索引 + 10 位页表索引 + 12 位偏移。

5. 倒排页表

Inverted Page Table 是倒排页表。

普通页表:每个进程一张表,每个 PTE 对应一个虚拟页。

倒排页表:整个系统一张表,每个 PTE 对应一个物理页框。表项记录:

  • 哪个进程正在使用该物理页。
  • 该物理页对应进程的哪个虚拟页。

优点:

  • 表大小与物理内存大小相关,而不是与所有进程虚拟地址空间总量相关。
  • 可显著节省页表空间。

缺点:

  • 地址转换查找更复杂,通常需要哈希表或 TLB 辅助。
  • 不在内存中的页面仍需要其他结构记录磁盘位置。

十三、超越物理内存:交换机制

1. 为什么需要交换

物理内存有限,但进程虚拟地址空间可能很大,系统中也可能同时运行许多进程。

Swapping Mechanism 是交换机制。操作系统在磁盘上预留交换空间(Swap Space),把不活跃页面换出到磁盘,需要时再换入内存。

内存层次:

  • Register,寄存器:最快、最小。
  • Cache,缓存:快、小。
  • Main Memory,主存/RAM:容量较大、速度适中。
  • Mass Storage,大容量存储/磁盘:容量大、慢。

RAM 是 Random Access Memory,全称随机访问存储器。

2. 交换空间与存在位

Swap Space 是交换空间,磁盘上用于保存被换出页面的区域。

Present Bit 是存在位:

  • Present = 1:页面在物理内存中,PTE 包含 PFN。
  • Present = 0:页面不在物理内存中,PTE 可能包含磁盘位置或其他状态。

3. 缺页异常

Page Fault 是缺页异常。当进程访问的页面不在物理内存中,硬件触发 page fault,陷入操作系统。

缺页处理流程:

  1. CPU 访问虚拟地址,查 TLB/页表。
  2. PTE 表明页面不在内存,触发 page fault。
  3. OS 检查访问是否合法。
  4. 根据 PTE 或补充结构找到页面在磁盘的位置。
  5. 找一个空闲物理页框。
  6. 若没有空闲页框,运行页面置换算法选择牺牲页。
  7. 若牺牲页是脏页,先写回磁盘。
  8. 从磁盘读入目标页。
  9. 更新 PTE,设置 Present Bit 和 PFN。
  10. 重新执行导致缺页的指令。

缺页异常属于 fault:处理后通常重新执行原指令。

4. 页面回收

操作系统通常不会等内存完全耗尽才回收页面,而是维护空闲页水位线。

Page Daemon 是页守护线程。Linux 中类似角色是 kswapd

水位线:

  • 低水位线:空闲页低于该值时,后台回收开始。
  • 高水位线:回收到该值附近后,后台回收可停止。

提前回收可以减少前台缺页时的等待时间。

十四、页面置换策略

1. 目标与 AMAT

页面置换策略决定内存不够时淘汰哪个页面。目标是减少缺页次数,提高性能。

AMAT 是 Average Memory Access Time,全称平均内存访问时间。

简化理解:

$$ \begin{gathered} AMAT = H + mP \ m = \frac{F}{N} \ h = \frac{K}{N} \ h + m = 1 \ AMAT = M + fD \end{gathered} $$

符号说明:AMAT 为平均内存访问时间;H 为命中访问时间;m 为未命中率;P 为未命中惩罚;F 为缺页次数;N 为内存访问次数;h 为命中率;K 为命中次数;M 为内存访问时间;f 为缺页率;D 为缺页服务时间。

缺页代价很高,因为可能涉及磁盘 I/O。

2. OPT

OPT 是 Optimal Replacement,全称最优替换。

策略:淘汰未来最久才会再次访问的页面。

特点:

  • 理论上缺页次数最少。
  • 需要知道未来访问序列,实际系统无法实现。
  • 常作为评价其他算法的基准。

3. FIFO

FIFO 是 First-In, First-Out,全称先进先出。

策略:淘汰最早进入内存的页面。

优点:

  • 实现简单。

缺点:

  • 不考虑页面是否热门。
  • 可能淘汰正在频繁使用的页面。
  • 可能出现 Belady 异常。

Belady 异常:在 FIFO 等某些算法中,页框数增加后缺页次数反而增加。

数学表达:

$$ B(k + 1) > B(k) $$

符号说明:B(k) 为 k 个页框时的缺页次数;若页框增加后缺页次数反而变大,就出现 Belady 异常。

4. Random

Random Replacement 是随机替换。

策略:随机选择一个页面淘汰。

优点:

  • 实现简单。
  • 不需要维护访问历史。

缺点:

  • 性能不稳定。
  • 可能淘汰高频访问页面。

5. LRU 与 LFU

LRU 是 Least Recently Used,全称最近最久未使用。

策略:淘汰最长时间没有被访问的页面。它利用短期局部性。

选择规则:

$$ v_{LRU} = \arg\min_p last(p) $$

符号说明:v_{LRU} 为 LRU 淘汰页;last(p) 为页 p 最近一次被访问的时间。

LFU 是 Least Frequently Used,全称最不经常使用。

策略:淘汰访问次数最少的页面。它关注长期访问频率。

选择规则:

$$ v_{LFU} = \arg\min_p count(p) $$

符号说明:$v_{LFU}$ 为 LFU 淘汰页;count(p) 为页 p 的访问次数。

精确 LRU 代价高,因为每次内存访问都要维护访问顺序或时间戳。实际系统常使用近似 LRU。

6. 引用位与时钟算法

Use Bit 或 Reference Bit 是引用位。页面被读、写或取指时,硬件把引用位置 1;操作系统在扫描或周期性检查时清 0。

Clock Algorithm 是时钟算法,是近似 LRU

基本流程:

  1. 把候选页组织成循环列表。
  2. 时钟指针指向当前候选页。
  3. 如果引用位为 1,清 0,指针前进。
  4. 如果引用位为 0,选择该页淘汰。

时钟算法比精确 LRU 便宜,比完全不看历史的 FIFO/Random 通常更好。

7. 脏页与增强时钟

Dirty Bit 是脏位。页面被写过后,脏位置 1。

淘汰时:

  • 干净页:可直接丢弃,因为磁盘或文件中已有相同内容。
  • 脏页:必须写回磁盘或交换区,代价更高。

增强型时钟算法会优先选择“未被使用且干净”的页面,再考虑“未被使用但脏”的页面。

8. 预取、聚类和抖动

Prefetching 是预取。操作系统猜测某些页面即将被访问,提前调入内存。

Clustering 是聚类/分组。把多个待写回页面聚在一起,一次性发起较大磁盘 I/O,通常比多次小 I/O 更高效。

Thrashing 是抖动。系统频繁缺页,大部分时间都在换页,CPU 利用率显著下降。

可用指标表示为:

$$ \begin{gathered} f = \frac{F}{N} \ U = C / T \ D \cdot F \gg C \end{gathered} $$

符号说明:f 为缺页率;F 为缺页次数;N 为内存访问次数;U 为 CPU 利用率;C 为有效 CPU 工作时间;T 为总时间;D 为单次缺页服务时间;D · F >> C 表示系统大部分时间耗在换页上,容易发生抖动。

缓解抖动:

  • 降低多道程序度,减少并发进程数量。
  • 准入控制,暂缓启动新进程。
  • OOM Killer 终止部分进程释放内存。

OOM 是 Out Of Memory,全称内存耗尽。

十五、常考计算与答题模板

1. 调度题

周转时间:

$$ \begin{gathered} T_i = C_i - A_i \ ATT = \frac{1}{n}\sum_i T_i \end{gathered} $$

响应时间:

$$ \begin{gathered} R_i = F_i - A_i \ ART = \frac{1}{n}\sum_i R_i \end{gathered} $$

符号说明:$T_i$ 为作业 i 的周转时间;$C_i$ 为完成时间;$A_i$ 为到达时间;ATT 为平均周转时间;$R_i$ 为响应时间;$F_i$ 为首次运行时间;ART 为平均响应时间;n 为作业数。

答题顺序:

  1. 画时间轴。
  2. 标出每个进程到达时间、运行时间、I/O 时间。
  3. 根据算法决定每个时间点运行谁。
  4. 算完成时间、周转时间、响应时间。
  5. 说明是否抢占、是否产生饥饿或护航效应。

2. 分页地址转换

给定虚拟地址 VA、页大小 P、页表:

$$ \begin{gathered} b = \log_2(P) \ VPN = VA \gg b \ d = VA \bmod P \ PFN = PT[VPN] \ PA = PFN \cdot P + d \end{gathered} $$

符号说明:b 为页内偏移位数;P 为页大小;VPN 为虚拟页号;d 为页内偏移;PT 为页表;PFN 为物理页帧号;PA 为物理地址。

若 PTE 无效:

  • 地址非法:段错误或保护异常。
  • 页面合法但不在内存:缺页异常。

3. 页表大小

$$ \begin{gathered} N_p = V / P \ S_{PT} = N_p \cdot S_{PTE} \end{gathered} $$

符号说明:N_p 为虚拟页数;V 为虚拟地址空间大小;P 为页大小;S_{PT} 为页表大小;S_{PTE} 为单个 PTE 大小。

二级页表:

$$ \begin{gathered} e = P / S_{PTE} \ p = \log_2(e) \ b = \log_2(P) \ v = a - b \ d = v - p \end{gathered} $$

符号说明:e 为一个页表页可容纳的 PTE 数;p 为页表索引位数;b 为页内偏移位数;v 为 VPN 位数;a 为虚拟地址位数;d 为页目录索引位数。

4. TLB 命中率

$$ \begin{gathered} h = \frac{H}{N} \ m = \frac{M}{N} \end{gathered} $$

符号说明:h 为 TLB 命中率;m 为 TLB 未命中率;H 为命中次数;M 为未命中次数;N 为访问或地址转换总次数。

顺序数组访问常利用空间局部性:同一页内多个元素只有第一次访问可能 miss。

5. 页面置换

页面置换题答题顺序:

  1. 写出访问串。
  2. 逐步维护页框内容。
  3. 每次访问标注 hit 或 fault。
  4. 按算法更新队列、引用位、脏位或时间戳。
  5. 统计缺页次数和命中率。

算法关键判断:

  • FIFO:淘汰最早进入页框的页。
  • LRU:淘汰最近最久未访问的页。
  • OPT:淘汰未来最久才访问的页。
  • Clock:R=1 清 0 跳过,R=0 淘汰。
  • 增强 Clock:优先未引用且干净页。

十六、英文缩写速查

缩写 全称 中文
AMAT Average Memory Access Time 平均内存访问时间
API Application Programming Interface 应用程序编程接口
ART Average Response Time 平均响应时间
ASID Address Space Identifier 地址空间标识符
ATT Average Turnaround Time 平均周转时间
BSS Block Started by Symbol 未初始化数据段
CFS Completely Fair Scheduler 完全公平调度器
CLI Clear Interrupt Flag 清除中断标志
CPU Central Processing Unit 中央处理器
CTSS Compatible Time-Sharing System 兼容分时系统
ELF Executable and Linkable Format 可执行与可链接格式
FCFS First-Come, First-Served 先来先服务
FIFO First-In, First-Out 先进先出
FP Frame Pointer 栈帧指针
IDT Interrupt Descriptor Table 中断描述符表
IDTR Interrupt Descriptor Table Register 中断描述符表寄存器
IPC Inter-Process Communication 进程间通信
I/O Input/Output 输入/输出
IVT Interrupt Vector Table 中断向量表
LDE Limited Direct Execution 受限直接执行
LFU Least Frequently Used 最不经常使用
LRU Least Recently Used 最近最久未使用
LWP Light Weight Process 轻量级进程
MLFQ Multilevel Feedback Queue 多级反馈队列
MMU Memory Management Unit 内存管理单元
OOM Out Of Memory 内存耗尽
OPT Optimal Replacement 最优替换
OS Operating System 操作系统
PA Physical Address 物理地址
PCB Process Control Block 进程控制块
PC Program Counter 程序计数器
PDBR Page Directory Base Register 页目录基址寄存器
PDE Page Directory Entry 页目录项
PDIndex Page Directory Index 页目录索引
PE Portable Executable 可移植可执行文件格式
PFN Physical Frame Number 物理页框号
PID Process Identifier 进程 ID
PIT Programmable Interval Timer 可编程间隔定时器
PPID Parent Process Identifier 父进程 ID
PSJF Preemptive Shortest Job First 抢占式最短作业优先
PTE Page Table Entry 页表项
PTBR Page Table Base Register 页表基址寄存器
PTIndex Page Table Index 页表索引
RAM Random Access Memory 随机访问存储器
RB Tree Red-Black Tree 红黑树
RISC Reduced Instruction Set Computer 精简指令集计算机
RR Round Robin 轮转调度
SIG Signal 信号
SJF Shortest Job First 最短作业优先
SN Segment Number 段号
SP Stack Pointer 栈指针
STCF Shortest Time-to-Completion First 最短完成时间优先
STDERR Standard Error 标准错误
STDIN Standard Input 标准输入
STDOUT Standard Output 标准输出
STI Set Interrupt Flag 设置中断标志
TLB Translation Lookaside Buffer 转换后备缓冲
UID User Identifier 用户 ID
VA Virtual Address 虚拟地址
VPN Virtual Page Number 虚拟页号

十七、最后复习抓手

最需要会讲清楚的主干:

  1. 进程为什么是 CPU 虚拟化的基本抽象:PCB 保存现场,调度器切换进程。
  2. 受限直接执行为什么又快又安全:用户态直接执行,系统调用/中断/异常进入内核。
  3. 调度算法的取舍:FCFS 简单但护航,SJF/STCF 优化周转但可能饥饿,RR 响应好但有切换开销,MLFQ 用历史行为动态预测,CFS 用 vruntime 近似公平。
  4. 地址空间为什么是内存虚拟化的基本抽象:进程使用虚拟地址,MMU 负责转换,OS 负责管理映射和异常。
  5. 分段和分页的对比:分段贴近逻辑但有外部碎片,分页固定大小便于管理但页表可能大。
  6. TLB 为什么必要:没有 TLB,分页访问至少多一次内存访问。
  7. 多级页表解决什么:节省未使用虚拟地址范围对应的页表空间,但增加查表层级。
  8. 交换机制如何让虚拟内存超过物理内存:Present Bit、Page Fault、换入换出、页面置换。
  9. 页面置换算法的本质:用历史或未来信息降低缺页率,实际系统常用近似 LRU 的 Clock。

操作系统并发复习

资料来源:并发部分 7 份课件,包括并发介绍、线程 API、锁、基于锁的并发数据结构、条件变量、信号量、常见并发问题。

十八、并发与线程基础

1. 线程是什么

线程(Thread)是进程内的一个执行流,也是 CPU 调度的基本单位。一个进程可以包含多个线程,因此有多个并发执行路径。

同一进程内线程共享:

  • 代码段、全局变量、堆。
  • 打开的文件描述符。
  • 进程地址空间中的大部分资源。

每个线程私有:

  • PC:Program Counter,程序计数器。
  • 寄存器上下文。
  • 栈。
  • TCB:Thread Control Block,线程控制块。

TCB 用于保存线程状态,类似 PCB 之于进程。但同一进程内线程切换通常不需要切换地址空间,因此比进程切换轻量。

2. 为什么使用线程

使用线程的主要原因:

  • 并行性(Parallelism):把计算任务分成多个部分,在多核 CPU 上同时运行。
  • 避免阻塞:一个线程等待 I/O 时,其他线程仍可执行。
  • 响应性:主线程响应用户输入,后台线程执行保存、拼写检查、网络请求等。
  • 共享方便:同进程线程天然共享地址空间,比进程间通信更轻量。

代价:

  • 共享数据会带来竞态条件。
  • 执行顺序不确定,调试困难。
  • 必须使用锁、条件变量、信号量等同步原语。

3. 线程执行顺序为什么不确定

线程执行顺序取决于:

  • 操作系统调度算法。
  • 线程优先级和时间片。
  • 系统负载。
  • I/O、锁、条件变量等阻塞事件。
  • 时钟中断导致的抢占。

因此多线程程序不能依赖“某个线程一定先执行”,必须显式同步。

4. 原子性、竞态条件和临界区

原子性(Atomicity):一个操作从其他线程看来要么尚未发生,要么已经完整发生,不暴露中间状态。

counter++ 看起来是一句 C 代码,但机器层面通常至少包含:

  1. Load:从内存读 counter 到寄存器。
  2. Add:寄存器加 1。
  3. Store:写回内存。

这不是原子操作。两个线程交错执行时,可能都读到旧值,然后各自写回同一个新值,产生丢失更新。

竞态条件(Race Condition):程序结果依赖线程执行交错顺序,并且某些交错会导致错误。

数据竞争(Data Race):多个线程并发访问同一共享数据,至少一个是写操作,并且没有同步保护。

临界区(Critical Section):访问共享数据并可能破坏一致性的代码段。临界区需要同步原语保护,使同一时刻最多一个线程进入。

5. 同步原语概览

  • 锁(Lock/Mutex):保护临界区,实现互斥。
  • 条件变量(Condition Variable):等待某个条件成立。
  • 信号量(Semaphore):维护整数计数,可表示资源数量、事件或二进制锁。
  • 原子操作(Atomic Operation):由硬件保证不可分割,例如 CAS、Test-and-Set。

十九、Pthreads 线程 API

Pthreads 是 POSIX Threads,全称 POSIX 线程库。POSIX 是 Portable Operating System Interface,全称可移植操作系统接口。

使用 Pthreads 程序通常需要:

gcc file.c -Wall -pthread

并包含:

#include <pthread.h>

1. pthread_create()

pthread_create() 用于创建线程:

int pthread_create(pthread_t *thread,
                   const pthread_attr_t *attr,
                   void *(*start_routine)(void *),
                   void *arg);

参数:

  • thread:指向 pthread_t 的指针,用于保存新线程 ID。
  • attr:线程属性,例如栈大小、调度策略;默认属性可传 NULL
  • start_routine:线程入口函数。
  • arg:传给线程入口函数的参数,类型为 void *

常见做法是把多个参数封装到结构体中,把结构体指针作为 arg 传入。

2. pthread_join()

pthread_join() 用于等待指定线程结束并回收资源:

int pthread_join(pthread_t thread, void **value_ptr);

参数:

  • thread:要等待的线程。
  • value_ptr:接收线程函数返回值的地址;不关心返回值可传 NULL

易错点:

  • 线程函数不能返回栈上局部变量地址。函数返回后栈帧销毁,地址会变成悬空指针。
  • 如果需要返回复杂结果,应在堆上分配,主线程 pthread_join() 获取后负责 free()
  • 创建线程后通常要 pthread_join() 或分离线程,否则资源可能不能及时回收。

3. Pthreads 互斥锁 API

Mutex 是 Mutual Exclusion,全称互斥锁。

常见接口:

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
pthread_mutex_unlock(&lock);

动态初始化:

pthread_mutex_init(&lock, NULL);
pthread_mutex_destroy(&lock);

其他接口:

  • pthread_mutex_trylock():锁已被占用时立即失败返回。
  • pthread_mutex_timedlock():等待到超时时间,超时仍未获得锁则失败返回。

使用要求:

  • 所有锁必须初始化。
  • 加锁和解锁应检查错误码。
  • 访问同一共享变量的所有代码路径必须使用同一把锁。
  • 通常谁加锁谁解锁,避免所有权混乱。

4. Pthreads 条件变量 API

Condition Variable 是条件变量。

常见接口:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_wait(&cond, &mutex);
pthread_cond_signal(&cond);
pthread_cond_broadcast(&cond);

pthread_cond_wait() 的语义:

  1. 调用前必须已经持有 mutex。
  2. 调用时原子地释放 mutex,并让线程进入等待队列睡眠。
  3. 被唤醒后,重新获得 mutex,然后返回。

必须使用 while 重新检查条件:

pthread_mutex_lock(&mutex);
while (!condition) {
    pthread_cond_wait(&cond, &mutex);
}
pthread_mutex_unlock(&mutex);

原因:

  • 可能发生虚假唤醒。
  • 被唤醒后,条件可能已经被其他线程改变。
  • signal 只表示条件可能变化,不保证条件成立。

二十、锁

1. 锁的基本思想

锁用于实现互斥,使同一时刻最多一个线程进入受保护的临界区。

锁变量通常有两种状态:

  • unlocked/free:未锁定,没有线程持有。
  • locked/held:已锁定,有线程持有。

lock() 获取锁;如果锁已被持有,则等待。unlock() 释放锁,并可能唤醒等待线程。

2. 评价锁的标准

互斥性(Mutual Exclusion):

  • 同一时刻最多一个线程进入临界区。

公平性(Fairness):

  • 等待线程是否能在有限时间内获得锁。
  • 是否避免饥饿。

性能(Performance):

  • 无竞争时,加锁/解锁开销是否低。
  • 有竞争时,是自旋还是阻塞。
  • 是否浪费 CPU 或导致大量上下文切换。

可量化为:

$$ \begin{gathered} L_i = a_i - r_i \ S_i = u_i - a_i \ \rho = \frac{C}{N} \ \omega = \frac{P}{T} \ X = \frac{K}{T} \ \varphi = \frac{\min_i G_i}{\max_i G_i} \ W_i \to \infty \end{gathered} $$

符号说明:L_i 为线程 i 的加锁等待时间;r_i 为请求锁时间;a_i 为获得锁时间;S_i 为临界区时间;u_i 为释放锁时间;\rho 为锁竞争率;C 为发生竞争的加锁次数;N 为总加锁次数;$\omega$ 为自旋浪费比例;P 为自旋消耗的 CPU 时间;T 为总 CPU 时间;X 为锁吞吐量;K 为完成的临界区次数;$\varphi$ 为公平性比值;G_i 为线程 i 成功获得锁的次数;W_i -> ∞ 表示线程 i 可能饥饿。

3. 禁用中断实现互斥

早期单处理器系统可以在临界区禁用中断:

  • 禁用中断后,当前线程不会因时钟中断被抢占。
  • 临界区执行完后恢复中断状态。

问题:

  • 只适合内核态,用户态不能直接禁用中断。
  • 只适合单处理器,多处理器上其他 CPU 仍可并发访问。
  • 临界区必须很短,否则影响系统响应。
  • 应保存并恢复原中断状态,而不是简单无条件启用。

4. 为什么普通变量不能实现锁

如果用普通变量 flag 实现锁:

while (flag == 1) ;
flag = 1;

检查 flag 和设置 flag 不是原子的。两个线程可能同时看到 flag == 0,然后都设置为 1 并进入临界区,互斥失败。

因此锁需要硬件原子指令。

5. Peterson 算法

Peterson 算法是双线程互斥算法。它用:

  • flag[i] 表示线程 i 想进入临界区。
  • turn 表示双方都想进入时让谁优先。

它说明纯软件可在严格假设下实现双线程互斥。但实际系统中由于多核内存模型、编译器重排和扩展性问题,真实锁通常依赖硬件原子指令。

6. Test-and-Set 自旋锁

Test-and-Set 是测试并设置,也叫 Atomic Exchange,原子交换。

语义:

$$ \begin{gathered} old = \cdot ptr \ \cdot ptr = new \ \text{return old} \end{gathered} $$

整个操作原子执行。

自旋锁:

while (TestAndSet(&flag, 1) == 1)
    ; /* spin */

优点:

  • 简单。
  • 能保证互斥。

缺点:

  • 忙等浪费 CPU。
  • 普通自旋锁不公平,可能饥饿。
  • 单核上如果持锁线程被抢占,等待线程会浪费整个时间片。

多核上,如果临界区非常短且持锁线程正在其他 CPU 上运行,自旋可能比阻塞更快。

7. CAS、LL/SC 与 FAA

CAS 是 Compare-and-Swap,全称比较并交换。

语义:

if(·ptr == expected)
	{ ·ptr = new; } 
	return old value

如果返回值等于 expected,说明更新成功;否则失败,需要重试。

LL 是 Load-Linked,全称链接加载。SC 是 Store-Conditional,全称条件存储。LoadLinked(ptr) 读取值并记录地址;StoreConditional(ptr, value) 只有在此后没有其他处理器写过该地址时才成功。

FAA 是 Fetch-and-Add,全称获取并增加。它原子地返回旧值,并把内存中的值增加。

这些硬件原子指令可用于实现锁、原子计数器和无锁数据结构。

8. Ticket 锁

Ticket 锁用 FAA 实现公平自旋锁。

变量:

  • ticket:下一个待分配票号。
  • turn:当前正在服务的票号。

流程:

  1. 线程通过 FAA 领取自己的票号 myturn
  2. 自旋等待 turn == myturn
  3. 释放锁时 turn++,服务下一个票号。

优点:按取票顺序进入临界区,公平。

缺点:仍然忙等,会消耗 CPU。

9. yield、park/unpark 与 futex

如果自旋过多,需要操作系统支持让线程睡眠。

yield()

  • 当前线程主动放弃 CPU。
  • 从 Running 变成 Ready。
  • 问题是线程多时会产生大量上下文切换,也不保证公平。

park() / unpark(threadID)

  • park() 让当前线程阻塞,不占用 CPU。
  • unpark() 唤醒指定线程,使其进入就绪队列。
  • 需要等待队列记录等待锁的线程。

丢失唤醒问题:

  • 如果 unpark() 发生在目标线程真正 park() 之前,唤醒可能丢失。
  • 之后线程再 park(),可能永久睡眠。
  • Solaris 使用 setpark() 声明“即将 park”,避免丢失唤醒。

futex 是 Fast Userspace Mutex,全称快速用户态互斥。Linux futex 的思想:

  • 无竞争时在用户态用原子指令完成,不进内核。
  • 有竞争时才通过内核阻塞或唤醒。

常见操作:

  • futex_wait(address, expected):如果 *address == expected,原子地加入等待队列并阻塞。
  • futex_wake(address, n):唤醒最多 n 个等待在该地址上的线程。

10. 两阶段等待锁

两阶段等待:

  1. 先短暂自旋,期待锁很快释放。
  2. 自旋失败后阻塞睡眠,避免长期浪费 CPU。

适合多核、临界区短但竞争偶尔发生的场景。

二十一、基于锁的并发数据结构

1. 基本目标

并发数据结构的目标是让多个线程安全访问同一个数据结构,同时尽量保持性能。

需要考虑:

  • 正确性:共享状态是否被同步保护,数据结构不变量是否保持。
  • 死锁:是否存在错误锁顺序。
  • 性能:锁粒度、锁竞争、可扩展性。

锁粒度:

  • 粗粒度锁:一把锁保护整个结构。简单,但并发性差。
  • 细粒度锁:多把锁保护不同部分。并发性高,但实现复杂、死锁风险更高。

2. 并发计数器

普通计数器的 increment() 是读-改-写,多个线程并发会丢失更新。

单锁计数器:

  • 每次 increment/decrement/get 都先加锁。
  • 正确但可扩展性差。
  • 线程越多,锁竞争越严重。

注意:get() 也要加锁,因为它可能和写操作并发。

3. 近似计数器

近似计数器用多个局部计数器减少全局锁竞争。

结构:

  • 一个全局计数器 G。
  • 多个局部计数器 L1, L2, ...,通常每个 CPU 一个。
  • 每个局部计数器一把锁,全局计数器一把锁。
  • 阈值 S 控制局部值何时汇总到全局。

更新流程:

  1. 线程更新本 CPU 对应局部计数器。
  2. 如果局部计数器达到 S,获取全局锁。
  3. 把局部值加到全局计数器。
  4. 局部计数器清零。

真实总数:

$$ \begin{gathered} V = G + \sum_i L_i \ V_r = G \ \varepsilon = V - V_r = \sum_i L_i \ \varepsilon_max < nS \ \varepsilon_r = \frac{\varepsilon}{V} \end{gathered} $$

符号说明:V 为真实总数;G 为全局计数器;L_i 为第 i 个局部计数器;V_r 为读到的近似值;\varepsilon 为绝对误差;\varepsilon_max 为最大绝对误差;n 为局部计数器数量;S 为阈值;\varepsilon_r 为相对误差。

S 的权衡:

  • S 小:更准确,但全局锁竞争多,性能差。
  • S 大:性能好,但全局值滞后,不够精确。

4. 并发链表

粗粒度链表:

  • 一把锁保护整条链表。
  • 插入、查找时都持有锁。
  • 正确简单,但查找和插入无法并行。

缩小临界区:

  • malloc() 不访问链表共享状态,应放在锁外。
  • 只在真正修改 head 和节点链接时加锁。
  • 避免多个返回路径忘记 unlock()

锁耦合:

Lock Coupling 也叫 Hand-over-Hand Locking,全称手递手加锁。

基本思想:

  1. 每个节点有一把锁。
  2. 遍历时先获取下一个节点锁。
  3. 再释放当前节点锁。
  4. 始终保证当前访问节点被锁保护。

优点:不同线程可在链表不同位置并发操作。

缺点:每经过一个节点都要加锁/解锁,开销大,实现复杂,未必比粗粒度锁快。

5. 并发队列

Michael 和 Scott 风格队列常用两把锁:

  • headLock:保护队头。
  • tailLock:保护队尾。

使用 dummy node,即虚拟节点,简化空队列处理。

入队只操作尾部,因此只获取 tailLock。出队只操作头部,因此只获取 headLock。多数情况下入队和出队可以并发。

6. 并发哈希表

并发哈希表常见做法是每个桶一把锁:

  • 每个桶是一条链表。
  • 不同 key 落入不同桶时,可以并发插入或查找。
  • 如果大量 key 落入同一桶,仍会产生锁竞争。

这种设计通过分散锁竞争提高可扩展性。

二十二、条件变量

1. 条件变量解决什么

条件变量用于解决“线程等待某个条件成立”的问题。

例子:

  • 父线程等待子线程完成。
  • 消费者等待缓冲区非空。
  • 生产者等待缓冲区非满。

条件变量本身不保存条件。真正的条件保存在共享状态变量中,例如 done == 1count > 0count < MAX

条件变量负责睡眠和唤醒;互斥锁负责保护条件对应的共享状态。

2. 自旋等待为什么不好

父线程可以不断检查 done 是否为 1:

while (done == 0)
    ;

问题:

  • 一直占用 CPU。
  • 等待时间越长浪费越严重。
  • volatile 不能替代线程同步,不能保证互斥、顺序和可见性完整正确。

3. 使用条件变量实现 join

共享状态:

  • done:真正条件。
  • cond:等待队列。
  • mutex:保护 done

父线程:

pthread_mutex_lock(&mutex);
while (done == 0) {
    pthread_cond_wait(&cond, &mutex);
}
pthread_mutex_unlock(&mutex);

子线程结束:

pthread_mutex_lock(&mutex);
done = 1;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

4. 为什么必须有状态变量

错误做法:子线程只 signal(),父线程只 wait(),没有 done

问题:

  • 如果子线程先执行 signal(),此时父线程还没有 wait(),信号不会保存。
  • 父线程之后 wait(),可能永久睡眠。

结论:条件变量不是消息队列。必须用共享状态记录条件是否已经成立。

5. 检查条件和 wait 必须由同一把锁保护

错误窗口:

  1. 父线程检查 done == 0
  2. 父线程准备 wait(),但还没真正睡眠。
  3. 子线程设置 done = 1signal()
  4. 父线程再进入 wait()
  5. signal 丢失,父线程永久睡眠。

正确方式是持有同一把 mutex,并用 pthread_cond_wait() 原子释放锁并睡眠。

6. Mesa 语义与 while

Mesa 语义:signal() 只是把等待线程变为就绪状态,不会立即把 CPU 交给它。被唤醒线程真正运行前,条件可能已经被其他线程改变。

Hoare 语义:signal() 后立即把执行权交给被唤醒线程,使其在条件刚成立时运行。现代系统通常采用 Mesa 语义。

因此条件变量等待必须写成 while,不要写成 if

7. 生产者-消费者问题

Producer-Consumer Problem 是生产者-消费者问题,也叫有界缓冲区问题。

共享资源:容量有限的缓冲区。

同步需求:

  • 互斥:生产者和消费者不能同时破坏缓冲区结构、计数和索引。
  • 条件同步:缓冲区满时生产者等待;缓冲区空时消费者等待。

单槽缓冲区中:

  • count == 0:空,可以 put,不能 get。
  • count == 1:满,可以 get,不能 put。

8. 为什么需要两个条件变量

只用一个条件变量时,signal() 可能唤醒错误类型线程。

例:消费者取走数据后,应该唤醒生产者;但单一条件变量可能唤醒另一个消费者。被唤醒消费者发现仍然为空,再次睡眠,系统可能没有线程推进。

正确方案:

  • empty:表示缓冲区非满,生产者等待它。
  • fill:表示缓冲区非空,消费者等待它。
  • 等待时使用 while

生产者:

pthread_mutex_lock(&mutex);
while (count == MAX)
    pthread_cond_wait(&empty, &mutex);
put(item);
pthread_cond_signal(&fill);
pthread_mutex_unlock(&mutex);

消费者:

pthread_mutex_lock(&mutex);
while (count == 0)
    pthread_cond_wait(&fill, &mutex);
item = get();
pthread_cond_signal(&empty);
pthread_mutex_unlock(&mutex);

9. 覆盖条件与 broadcast

覆盖条件(Covering Condition):多个线程等待在同一个条件变量上,但每个线程真正需要的条件不同。

例:内存分配中,一个线程等 100 字节,一个线程等 10 字节。释放 50 字节后,应唤醒等待 10 字节的线程,但 signal() 不一定选中它。

解决方法:

  • 使用 pthread_cond_broadcast() 唤醒所有等待线程。
  • 每个线程醒来后重新检查自己的条件。
  • 条件不满足的线程再次睡眠。

代价是可能唤醒过多线程。

二十三、信号量

1. 信号量定义

Semaphore 是信号量。它是一个同步对象,维护一个整数计数值。

POSIX 信号量常见操作:

  • sem_init():初始化。
  • sem_wait():申请资源,P 操作。
  • sem_post():释放资源或发送事件,V 操作。

P 操作来自荷兰语 proberen,表示尝试。V 操作来自荷兰语 verhogen,表示增加。

2. sem_wait()

语义:

  • 如果信号量值大于 0,立即减 1 并返回。
  • 如果信号量值等于 0,调用线程阻塞,直到其他线程执行 sem_post()

它常用于申请资源。

3. sem_post()

语义:

  • 原子地增加信号量值。
  • 如果有线程阻塞在该信号量上,唤醒其中一个。
  • 被唤醒线程只是变为就绪,不一定立刻运行。

它常用于释放资源或通知事件发生。

4. 二进制信号量实现互斥

初始值为 1 的信号量可以作为二进制锁:

  • 1:资源可用,线程可进入临界区。
  • 0:资源被占用,其他线程等待。

用法:

sem_wait(&mutex);
/* critical section */
sem_post(&mutex);

5. 信号量实现事件等待

父线程等待子线程结束时,信号量初始值应为 0:

  • 初始 0 表示事件未发生。
  • 父线程 sem_wait() 等待。
  • 子线程完成后 sem_post()

如果初始值设为 1,父线程不会等待,会直接继续执行,这是错误的。

信号量与条件变量的区别之一:信号量计数可以保存事件。如果子线程先 sem_post(),父线程之后 sem_wait() 可以直接通过。

6. 信号量解决生产者-消费者

使用三个信号量:

  • empty:空槽数量,初始为 MAX。
  • full:已填充槽数量,初始为 0。
  • mutex:二进制信号量,保护缓冲区、fillusecount

生产者:

sem_wait(&empty);
sem_wait(&mutex);
put(item);
sem_post(&mutex);
sem_post(&full);

消费者:

sem_wait(&full);
sem_wait(&mutex);
item = get();
sem_post(&mutex);
sem_post(&empty);

关键规则:先等资源条件,再进互斥区;不要在持有 mutex 时等待 empty/full

7. 读写锁

Reader-Writer Lock 是读写锁。

需求:

  • 读操作不修改数据,多个读者可以并发。
  • 写操作修改数据,写者必须独占。
  • 写者运行时不能有读者或其他写者。

读者优先实现思想:

  • 第一个读者获取 writelock,阻止写者。
  • 后续读者只增加读者计数,可以并发读。
  • 最后一个读者释放 writelock

问题:如果读者持续到来,写者可能长期等待,产生写者饥饿。

8. 哲学家就餐问题

Dining Philosophers Problem 是哲学家就餐问题。

场景:

  • 5 个哲学家围坐。
  • 每两人之间一把叉子,共 5 把。
  • 哲学家吃饭需要同时拿到左右两把叉子。

目标:

  • 无死锁。
  • 无饥饿。
  • 尽量高并发。

错误方案:所有哲学家都先拿左叉,再拿右叉。若所有人同时拿到左叉并等待右叉,会形成循环等待,死锁。

解决思路:

  • 打破循环等待:让一个哲学家先拿右叉再拿左叉,或规定全局资源顺序。
  • 限制并发数:最多允许 4 个哲学家同时尝试拿叉子。
  • 用条件变量记录哲学家状态。

9. 线程节流

线程节流用于限制同时执行某段高开销代码的线程数量。

做法:使用计数信号量,初始值为允许并发数 N。

sem_wait(&limit);
/* expensive operation */
sem_post(&limit);

10. 用锁和条件变量实现信号量

简化信号量可以用:

  • 一个整数 value
  • 一个互斥锁保护 value
  • 一个条件变量让等待线程睡眠。

wait

pthread_mutex_lock(&lock);
while (value == 0)
    pthread_cond_wait(&cond, &lock);
value--;
pthread_mutex_unlock(&lock);

post

pthread_mutex_lock(&lock);
value++;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&lock);

while 仍然必要,用于防止虚假唤醒或条件被其他线程抢先改变。

二十四、常见并发错误

1. 非死锁错误

非死锁并发错误不会让程序永久停住,但可能导致程序崩溃、数据不一致或结果错误。

两类主要非死锁错误:

  • 原子性违反。
  • 顺序违反。

真实系统中,非死锁错误数量往往多于死锁错误。

2. 原子性违反

Atomicity Violation 是原子性违反。

定义:本应连续、不可被打断执行的操作序列,被其他线程插入,导致错误。

典型“检查后使用”竞态:

  1. 线程 1 检查 ptr != NULL
  2. 线程 1 尚未使用 ptr。
  3. 线程 2 把 ptr 设为 NULL
  4. 线程 1 继续使用 ptr。
  5. 可能空指针访问或崩溃。

修复方法:

  • 用同一把锁保护所有访问。
  • 检查和使用必须放在同一个临界区。
  • 修改该共享变量的线程也必须持有同一把锁。

3. 顺序违反

Ordering Violation 是顺序违反。

定义:程序逻辑要求 A 在 B 之前发生,但并发执行没有强制这个顺序。

例:

  • 线程 A 初始化对象。
  • 线程 B 使用对象。
  • 如果没有同步,B 可能在 A 初始化完成前使用对象。

修复方法:

  • 使用条件变量、信号量或 join 强制顺序。
  • 用状态变量记录“初始化完成”。
  • 用互斥锁保护状态变量。

4. 死锁

Deadlock 是死锁。一组线程互相等待对方持有的资源,导致所有线程都无法继续。

典型例子:

  • 线程 1 持有 L1,等待 L2。
  • 线程 2 持有 L2,等待 L1。

大型系统容易死锁的原因:

  • 模块依赖复杂。
  • 不同模块有自己的锁。
  • 封装隐藏了函数内部会获取哪些锁。
  • 调用者可能无意中形成嵌套锁和循环等待。

5. 死锁四个必要条件

死锁发生必须同时满足:

  1. 互斥(Mutual Exclusion):资源一次只能被一个线程持有。
  2. 持有并等待(Hold and Wait):线程持有至少一个资源,同时等待其他资源。
  3. 不可抢占(No Preemption):已获得资源不能被系统强制抢走,只能主动释放。
  4. 循环等待(Circular Wait):存在线程等待环。

破坏任意一个条件,就能防止死锁。

6. 死锁预防

破坏循环等待:

  • 为所有锁规定全局顺序。
  • 所有线程按相同顺序获取锁。
  • 大系统中可使用偏序,而非完整全序。

破坏持有并等待:

  • 一次性获取所需全部锁。
  • 可用一把全局 prevention 锁保护“获取多把锁”的过程。
  • 缺点是并发性下降,且必须提前知道需要哪些锁。

破坏不可抢占:

  • 使用 trylock()
  • 如果拿不到后续锁,就释放已经持有的锁,稍后重试。

减少互斥:

  • 使用 CAS、FAA、LL/SC 等原子操作构造无锁数据结构。
  • 无显式锁则不会发生锁导致的死锁。

7. 活锁

Livelock 是活锁。线程没有阻塞,也一直在执行,但不断重复失败尝试,系统没有实际进展。

例:两个线程都用 trylock(),拿不到第二把锁就释放第一把并立刻重试。如果它们总是同步重试,可能一直冲突。

缓解方法:

  • 随机延迟。
  • 退避策略。
  • 打破同步重试模式。

死锁和活锁区别:

  • 死锁:线程阻塞,无法继续。
  • 活锁:线程运行,但没有有效进展。

8. 无锁原子加法

CAS 实现原子加法:

do {
    old = value;
    new = old + amount;
} while (CAS(&value, old, new) != old);

优点:

  • 不需要显式锁。
  • 不会发生锁死锁。

缺点:

  • 高竞争下可能反复失败,性能下降。
  • 可能出现类似活锁的持续重试。

9. 死锁避免与银行家算法

Deadlock Avoidance 是死锁避免。它不一定破坏必要条件,而是在运行时根据系统状态做决策,避免进入可能死锁的不安全状态。

Banker's Algorithm 是银行家算法。它通过谨慎分配资源,确保系统始终处于安全状态。

安全状态(Safe State):存在至少一个线程执行序列,使所有线程最终都能获得最大需求资源并顺利结束。

需要维护:

  • E:Existing Resource,系统中每类资源总数。
  • A:Available Resource,当前可用资源数量。
  • M:Maximum Claim,每个线程对每类资源的最大需求。
  • C:Current Allocation,每个线程当前已分配资源。
  • N:还可能需要的资源,N = M - C
  • R:Request,当前请求。

矩阵表达:

$$ \begin{gathered} N_{ij} = M_{ij} - C_{ij} \ R_{ij} \le A_j \ R_{ij} \le N_{ij} \ \exists(p_1,...,p_n), \forall k: N_{p_k} \le W_k \ W_{k+1} = W_k + C_{p_k} \end{gathered} $$

符号说明:E_j 为第 j 类资源总量;A_j 为第 j 类当前可用资源量;M_{ij} 为线程 i 对资源 j 的最大需求;C_{ij} 为线程 i 已持有的资源 j 数量;N_{ij} 为线程 i 对资源 j 的剩余需求;R_{ij} 为线程 i 当前请求资源 j 的数量;p_k 为安全序列中的第 k 个线程;W_k 为检查到第 k 步时的可用资源向量。

判断当前状态是否安全:

  1. 找一个尚未完成且 Need <= A 的线程。
  2. 假设它完成,释放其资源:A' = A + C_i
  3. 标记该线程完成。
  4. 重复直到所有线程完成,或找不到可完成线程。
  5. 若所有线程可完成,则状态安全,并得到安全序列。

判断请求能否批准:

  1. 检查 Request <= Need
  2. 检查 Request <= A
  3. 假设批准:更新 A' = A - R_iC_i' = C_i + R_iN_i' = N_i - R_i
  4. 运行安全性检查。
  5. 若假设后仍安全,则批准;否则不批准。

银行家算法局限:

  • 线程和资源数量需要相对固定。
  • 每个线程必须提前声明最大需求。
  • 请求模式需要可预测。
  • 实际程序动态创建线程、动态申请资源,很难满足假设。

10. 死锁检测与恢复

Deadlock Detection and Recovery 是死锁检测与恢复。

思路:

  • 允许死锁偶尔发生。
  • 定期构建资源分配图或等待图。
  • 如果图中存在环,说明可能死锁。
  • 选择一个或多个线程/事务中止,释放资源。

数据库系统常使用这种方式:周期性检测事务等待图中的环,发现死锁后中止某个事务。

二十五、并发常考模板

1. 判断是否需要加锁

如果满足以下条件,就需要同步:

  1. 多个线程访问同一共享数据。
  2. 至少一个线程写。
  3. 没有其他机制保证互斥、原子性或顺序。

答题关键词:共享变量、读-改-写、临界区、数据竞争、丢失更新。

2. 正确使用条件变量

模板:

pthread_mutex_lock(&mutex);
while (!condition) {
    pthread_cond_wait(&cond, &mutex);
}
/* use condition */
pthread_mutex_unlock(&mutex);

说明必须写:

  • 条件变量本身不保存条件。
  • 条件由共享状态变量表示。
  • wait 前必须持有锁。
  • wait 会原子释放锁并睡眠。
  • 醒来后必须用 while 重新检查条件。

3. 生产者-消费者条件变量模板

生产者:

pthread_mutex_lock(&mutex);
while (count == MAX)
    pthread_cond_wait(&empty, &mutex);
put(item);
pthread_cond_signal(&fill);
pthread_mutex_unlock(&mutex);

消费者:

pthread_mutex_lock(&mutex);
while (count == 0)
    pthread_cond_wait(&fill, &mutex);
item = get();
pthread_cond_signal(&empty);
pthread_mutex_unlock(&mutex);

4. 生产者-消费者信号量模板

生产者:

sem_wait(&empty);
sem_wait(&mutex);
put(item);
sem_post(&mutex);
sem_post(&full);

消费者:

sem_wait(&full);
sem_wait(&mutex);
item = get();
sem_post(&mutex);
sem_post(&empty);

关键解释:先等资源条件,再进互斥区;不要持有 mutex 去等 empty/full。

5. 判断死锁

检查四个必要条件:

  1. 是否互斥。
  2. 是否持有并等待。
  3. 是否不可抢占。
  4. 是否循环等待。

若题目问如何预防,回答破坏其中一个条件:

  • 规定全局锁顺序,破坏循环等待。
  • 一次性申请所有锁,破坏持有并等待。
  • trylock() 失败后释放已持有锁,破坏不可抢占。
  • 用无锁原子操作减少互斥。

6. 银行家算法模板

计算:

$$ N = M - C $$

安全性检查:

$$ \begin{gathered} W_0 = A \ \exists i: done_i = 0 \land N_i \le W_k \ W_{k+1} = W_k + C_i \ done_i = 1 \ \forall i: done_i = 1 \Rightarrow \text{safe} \ \exists i: done_i = 0 \Rightarrow \text{unsafe} \end{gathered} $$

符号说明:N 为剩余需求矩阵;M 为最大需求矩阵;C 为已分配矩阵;W_k 为第 k 步可用资源向量;A 为初始可用资源向量;done_i 表示线程 i 是否可完成。

资源请求判断:

$$ \begin{gathered} \text{if } Request > Need: \text{error} \ \text{if } Request > Available: \text{wait} \ \text{假设分配后运行安全性检查} \ \text{安全则批准,不安全则拒绝} \end{gathered} $$

二十六、并发英文缩写速查

缩写 全称 中文
CAS Compare-and-Swap 比较并交换
FAA Fetch-and-Add 获取并增加
LL Load-Linked 链接加载
POSIX Portable Operating System Interface 可移植操作系统接口
Pthreads POSIX Threads POSIX 线程库
SC Store-Conditional 条件存储
TCB Thread Control Block 线程控制块

二十七、并发最后复习抓手

  1. 线程和进程的区别:线程共享地址空间,但有私有 PC、寄存器、栈和 TCB。
  2. 并发错误根源:共享数据 + 至少一个写 + 缺少同步。
  3. counter++ 不是原子操作,典型导致丢失更新。
  4. 锁保护临界区,正确性要靠所有访问路径都使用同一把锁。
  5. 自旋锁依赖硬件原子指令;短临界区多核可用,长等待会浪费 CPU。
  6. futex 的核心是无竞争用户态完成,有竞争才进内核睡眠。
  7. 条件变量必须和 mutex、状态变量、while 一起使用。
  8. 生产者-消费者问题要同时解决互斥和条件同步。
  9. 信号量既能做二进制锁,也能做计数资源和事件通知。
  10. 死锁四条件必须同时满足;破坏任一条件即可预防。
  11. 银行家算法的核心是“假设分配后仍有安全序列才批准”。

操作系统持久化复习

资料来源:持久化部分 4 份课件,包括 I/O 设备、磁盘驱动器、文件和目录、文件系统实现。

二十八、I/O 设备

1. I/O 设备与操作系统职责

I/O 是 Input/Output,全称输入/输出。I/O 设备让计算机与外部环境交换数据。

常见设备:

  • 输入设备:键盘、鼠标、摄像头。
  • 输出设备:显示器、打印机、扬声器。
  • 双向通信设备:网卡、蓝牙控制器。
  • 持久化存储设备:HDD、SSD、U 盘。

操作系统管理 I/O 设备时要解决:

  • 如何向设备发命令。
  • 如何传输数据。
  • 如何知道设备完成操作。
  • 如何隐藏不同设备的硬件差异。
  • 如何让应用程序使用统一接口访问设备。

2. I/O 系统为什么分层

现代计算机通常采用层次化 I/O 互联结构。

原因:

  • 设备带宽不同。
  • 访问延迟不同。
  • 设备数量不同。
  • 成本不同。
  • 可扩展性要求不同。
  • 有些设备需要热插拔。

一般规律:

  • 高带宽、低延迟设备更靠近 CPU,例如 GPU、部分 NVMe SSD。
  • 低速、数量多或可插拔设备通过芯片组和外设总线连接,例如 USB、SATA 设备。

DMI 是 Direct Media Interface,全称直接媒体接口,是 Intel 平台中 CPU 和芯片组之间的高速连接通道。

PCIe 是 Peripheral Component Interconnect Express,全称高速串行计算机扩展总线标准。

USB 是 Universal Serial Bus,全称通用串行总线。

SATA 是 Serial Advanced Technology Attachment,全称串行高级技术附件。

NVMe 是 Non-Volatile Memory Express,全称非易失性内存主机控制器接口规范。

3. 标准设备接口

操作系统通常通过设备控制器暴露的寄存器控制设备:

  • 状态寄存器(Status Register):表示设备是否忙、是否完成、是否出错。
  • 命令寄存器(Command Register):告诉设备执行什么操作。
  • 数据寄存器(Data Register):传输少量数据或参数。

设备控制器负责把这些寄存器操作转换成真实硬件动作。

4. 轮询式 PIO

PIO 是 Programmed I/O,全称程序控制 I/O。轮询式 PIO 中,CPU 通过设备寄存器搬运数据,并不断检查状态寄存器。

典型流程:

  1. CPU 读取状态寄存器,等待设备空闲。
  2. CPU 把数据写入数据寄存器。
  3. CPU 把命令写入命令寄存器。
  4. 设备开始工作。
  5. CPU 不断轮询状态寄存器,直到操作完成。

优点:

  • 实现简单。
  • 对非常快或非常简单的设备可能足够。

缺点:

  • 忙等待,浪费 CPU。
  • 慢速设备会拖累系统并发能力。

5. 中断驱动 I/O

中断驱动 I/O 让设备在完成操作或发生异常时主动通知 CPU。

流程:

  1. 进程发起 I/O 请求。
  2. 设备开始执行。
  3. 如果进程必须等待结果,OS 阻塞该进程。
  4. 调度器运行其他可执行线程或进程。
  5. 设备完成后触发硬件中断。
  6. CPU 进入内核中断处理程序。
  7. 内核记录完成状态并唤醒等待进程。

优点:

  • 允许计算与 I/O 重叠。
  • CPU 不必一直忙等。

缺点:

  • 中断本身有处理开销。
  • 高频小 I/O 如果每次都中断,可能产生大量中断开销。

6. DMA

DMA 是 Direct Memory Access,全称直接内存访问。

DMA 的目标是不让 CPU 逐字节或逐块搬运大量数据。CPU 只负责设置传输参数,设备控制器或 DMA 控制器负责在设备和内存之间搬运数据。

基本流程:

  1. OS 通过设备驱动配置 DMA 描述符,包括内存地址、数据长度、传输方向。
  2. CPU 启动 I/O。
  3. 当前进程阻塞,CPU 可运行其他进程。
  4. DMA 控制器与设备控制器完成数据传输。
  5. 传输完成后,设备或 DMA 控制器通过中断通知 CPU。

DMA 的优势:

  • 减少 CPU 数据搬运负担。
  • 提高大块 I/O 吞吐。
  • 让 CPU 与 I/O 更好重叠。

7. 端口映射 I/O 与 MMIO

端口映射 I/O:

  • 使用独立的 I/O 地址空间。
  • CPU 用专门 I/O 指令访问设备寄存器。
  • x86 典型指令是 inout

MMIO 是 Memory-Mapped I/O,全称内存映射 I/O。

MMIO 的做法:

  • 把设备寄存器映射到处理器物理地址空间中的一段地址。
  • CPU 用普通 load/store 指令访问这些地址。
  • 硬件地址译码逻辑把访问路由到设备而不是 DRAM。
  • OS 通常把设备 MMIO 物理地址映射到内核虚拟地址空间,供驱动使用。

DRAM 是 Dynamic Random Access Memory,全称动态随机访问存储器。

8. 设备驱动

设备驱动程序封装硬件差异,向上提供统一接口。

典型分层:

$$ \text{应用程序}\rightarrow\text{POSIX API}\rightarrow\text{文件系统}\rightarrow\text{通用块设备层}\rightarrow\text{设备驱动}\rightarrow\text{设备控制器} $$

驱动通常运行在内核态,直接操作硬件,涉及中断、DMA、并发和设备差异,因此是内核错误的重要来源之一。

二十九、磁盘驱动器

1. LBA 与扇区

机械硬盘向 OS 提供线性的逻辑块地址空间。

LBA 是 Logical Block Address,全称逻辑块地址。操作系统通过 LBA 对磁盘执行读写,不需要知道扇区在盘片上的物理位置。

Sector 是扇区。传统磁盘常使用 512 B 扇区,现代设备也可能使用 4 KB 扇区。

2. HDD 结构

HDD 是 Hard Disk Drive,全称机械硬盘。 Pasted image 20260619215040.png 主要组件:

  • Platter:盘片。
  • Surface:记录表面。
  • Spindle:主轴。
  • Track:磁道,盘片表面上的同心圆。
  • Cylinder:柱面,多个记录表面上半径相同的磁道集合。
  • Sector:扇区,磁道上的编号数据单元。
  • Head:磁头。
  • Arm / Actuator Assembly:执行器臂/执行器组件,控制磁头径向移动。

盘片断电后仍可保存数据,因此 HDD 是持久化存储设备。

3. RPM 与旋转延迟

RPM 是 Rotations Per Minute,全称每分钟转数。

旋转一圈时间:

$$ T_{rot}=\frac{60}{RPM} $$

平均旋转延迟约为半圈时间:

$$ R_{avg}=\frac{T_{rot}}{2} $$

例:10000 RPM 磁盘:

$$ \begin{gathered} T_{rot} = 60 / 10000 = 0.006\text{s} = 6\text{ms} \ R_{avg} = 3\text{ms} \end{gathered} $$

4. HDD 访问时间

一次机械硬盘 I/O 主要由三部分组成:

$$ T_{IO} = S + R + X $$

符号说明:T_{rot} 为旋转一圈时间;RPM 为每分钟转数;R_{avg} 为平均旋转延迟;T_{IO} 为一次 I/O 时间;S 为寻道时间;R 为旋转延迟;X 为传输时间。

  • Seek Time:寻道时间,磁头移动到目标磁道的时间。
  • Rotational Delay:旋转延迟,等待目标扇区转到磁头下方的时间。
  • Transfer Time:传输时间,真正读写数据的时间。

顺序访问:

  • 初始寻道和旋转开销可被大量连续数据摊薄。
  • 性能主要受传输速率限制。

随机小块访问:

  • 每次都可能有寻道和旋转延迟。
  • 性能主要受机械定位时间限制。
  • 吞吐量远低于顺序访问。

5. 随机吞吐量估算

公式:

$$ \begin{gathered} T = S + R + X \ B = \frac{Q}{T} \end{gathered} $$

符号说明:T 为一次 I/O 总时间;S 为寻道时间;R 为旋转延迟;X 为传输时间;B 为吞吐量;Q 为请求大小。

例:随机读取 16 KB,若总耗时约 6.1 ms:

$$ B = 16\text{KB} / 6.1\text{ms} \approx 2.6\text{MB}/s $$

课件结论:即使高转速 HDD,小块随机访问吞吐也会大幅下降。文件系统应尽量提高局部性,减少随机寻道。

6. 磁盘调度算法

FCFS 是 First-Come, First-Served,全称先到先服务。

  • 按请求到达顺序处理。
  • 简单公平。
  • 磁头移动可能很大,性能差。

SSTF 是 Shortest-Seek-Time-First,全称最短寻道时间优先。

  • 选择距离当前磁头最近的请求。
  • 能减少寻道距离。
  • 可能导致远处请求饥饿。

SCAN 是电梯算法。

  • 磁头像电梯一样沿一个方向移动,处理沿途请求。
  • 到边界后反向。
  • 比 FCFS/SSTF 更均衡。

CSCAN:

  • 它只在一个方向上处理请求。比如规定只向右处理: 向右移动,沿途处理请求;到达最大磁道后,直接回到最小磁道;然后继续向右处理。

LOOK 是 SCAN 的改进。

  • 不一定走到磁盘边界。
  • 当前方向上没有更多请求时就反向。

SPTF 是 Shortest Positioning Time First,全称最短定位时间优先。

  • 同时考虑寻道时间和旋转延迟。
  • 选择预计定位时间最短的请求。

调度指标和选择函数:

$$ \begin{gathered} d_i = |x_i - x_0| \ D = \sum d_i \ k_{SSTF} = \arg\min_i d_i \ p_i = s_i + r_i \ k_{SPTF} = \arg\min_i p_i \ L_{avg} = (1/n) \sum (c_i - a_i) \ B = \frac{Q}{T} \end{gathered} $$

符号说明:d_i 为请求 i 与当前磁头位置的柱面距离;x_i 为请求 i 的磁道或柱面号;x_0 为当前磁头位置;D 为总磁头移动距离;k_{SSTF} 为 SSTF 选择的请求;p_i 为请求 i 的定位时间;s_i 为寻道时间;r_i 为旋转延迟;k_{SPTF} 为 SPTF 选择的请求;L_{avg} 为平均请求延迟;c_i 为完成时间;a_i 为到达时间;B 为磁盘吞吐量;Q 为传输字节数;T 为总耗时。

7. 现代 HDD 的分层调度和缓存

现代系统中,调度可能发生在多层:

  • OS 块层合并和排序请求。
  • 设备驱动提交命令。
  • 硬盘控制器或固件对已排队命令重新排序。

NCQ 是 Native Command Queuing,全称原生命令队列。SATA NCQ 允许设备同时接收多个请求并由设备内部重排。

设备缓存:

  • 读取缓存:缓存已读数据。
  • 预读:固件可能读取相邻扇区。
  • 写入缓存:数据进入设备缓存后,设备可能先向系统报告完成,之后再写入持久介质。

写缓存提高性能,但如果断电前未真正落盘,可能影响持久性。

三十、文件和目录

1. 文件抽象

文件是操作系统为持久存储信息提供的核心抽象。

可以把文件看成持久保存的字节数组,支持:

  • read:读。
  • write:写。
  • seek/lseek:定位。
  • truncate:截断。

文件数据是字节序列;文本文件只是字节序列的一种解释方式。

文件元数据包括:

  • 文件大小。
  • 所有者和组。
  • 访问权限。
  • 时间戳:atime、mtime、ctime。
  • 数据块位置或索引结构。

atime 是 Access Time,全称最近访问时间。

mtime 是 Modification Time,全称最近内容修改时间。

ctime 是 Change Time,全称 inode 状态改变时间。

birth time 表示创建时间,部分文件系统支持。

2. 文件系统职责

文件系统负责组织、存储、检索文件数据和元数据。

主要职责:

  • 提供文件和目录抽象。
  • 提供统一系统调用接口,例如 openreadwriteclose
  • 将文件名映射到底层存储对象,例如 inode。
  • 管理磁盘空间,分配和释放数据块。
  • 维护元数据:大小、权限、时间、数据块索引。
  • 保证一致性和可靠性。
  • 向底层块设备发读写请求。

三个视角:

  • 用户视角:文件和目录。
  • OS 视角:inode、目录项、打开文件表。
  • 设备视角:块读写接口。

3. 文件的三种标识

inode 编号:

  • 文件系统内部对象编号。
  • 用于定位文件元数据。

路径名(Pathname):

  • 面向用户的字符串名称。
  • 通过目录逐级解析为 inode。

文件描述符 FD:

  • FD 是 File Descriptor,全称文件描述符。
  • 进程打开文件后获得的整数句柄。
  • 后续 readwritelseekclose 使用 FD。
  • FD 是进程私有的,不是磁盘上的永久名称。

4. inode

inode 是 Unix 类文件系统中表示文件对象的核心数据结构。

一个 inode 对应一个文件对象,记录元数据和数据块索引。inode 通常不记录文件名。

inode 中常见信息:

  • 文件类型:普通文件、目录、符号链接等。
  • 文件大小。
  • UID:User Identifier,用户 ID。
  • GID:Group Identifier,组 ID。
  • 访问权限。
  • 时间戳。
  • link count:链接计数。
  • 数据块索引:直接块、间接块、extent 或其他结构。

inode 编号在同一文件系统内唯一。不同文件系统可有相同 inode 编号。文件删除后,inode 编号可能被回收再分配。

定位 inode 的基本计算:

$$ \begin{gathered} o_i = i \cdot S_i \ A_i = A_0 + o_i \end{gathered} $$

符号说明:$o_i$ 为 inode i 相对 inode 表起点的偏移;i 为 inode 编号;$S_i$ 为 inode 大小;$A_i$ 为 inode i 的地址;$A_0$ 为 inode 表起始地址。

注意实际系统可能从 1 编号,课件示例从 0 编号,做题要看题目说明。

5. 路径与目录

目录是一种特殊文件,其内容是一组目录项。

目录项通常保存:

$$ \langle\text{file name},\text{inode number}\rangle $$

路径解析就是从根目录或当前目录开始,逐级查找目录项,最终找到目标 inode。

绝对路径从根目录 / 开始,例如 /usr/bin/gcc

相对路径从当前工作目录开始,例如 ./src/main.c

目录通常包含:

  • .:当前目录。
  • ..:父目录。

根目录是文件系统命名空间起点。在 ext 类文件系统中根目录常为 inode 2,但不是所有文件系统都通用。

6. 文件描述符、打开文件表与 inode

文件打开后通常涉及三层结构:

  1. 进程 FD 表。
  2. 系统级 open file description 表。
  3. inode 表。

进程 FD 表:

  • 每个进程私有。
  • FD 是表项索引。
  • 表项指向系统级 open file description。

系统级 open file description:

  • 每次 open() 通常创建一个新的 open file description。
  • 记录当前文件偏移量 offset。
  • 记录 status flags,例如 O_RDONLYO_APPEND
  • 指向 inode/vnode。

inode 表:

  • 表示磁盘文件对象。
  • 保存元数据和数据块索引。

重点:offset 位于系统级 open file description 中。

7. 分别 open、fork、dup 的区别

分别 open() 同一文件:

  • 通常产生不同的 open file description。
  • 指向同一个 inode。
  • offset 各自独立。

fork() 后:

  • 子进程继承父进程 FD 表。
  • 父子进程对应 FD 指向同一个 open file description。
  • offset 共享。

dup() / dup2()

  • 在同一进程内复制文件描述符。
  • 新 FD 和旧 FD 指向同一个 open file description。
  • offset 共享。

这是文件描述符题的核心。

8. 常见文件系统接口

  • open():打开或创建指定路径文件,返回 FD。
  • read():从 FD 读取数据,并推进 offset。
  • write():向 FD 写入数据,并推进 offset。
  • close():关闭 FD,减少 open file description 引用计数。
  • lseek():修改 open file description 中的 offset。
  • fsync():请求将文件数据和必要元数据刷新到持久存储。

9. 目录操作

操作系统通常不允许用户直接写目录文件。目录修改必须通过系统调用完成:

  • mkdir():创建目录。
  • rmdir():删除空目录。
  • opendir():打开目录流。
  • readdir():读取下一个目录项。
  • link():创建硬链接,新增目录项指向已有 inode。
  • unlink():删除目录项,减少 inode 链接计数。
  • rename():重命名或移动目录项。

10. 硬链接

Hard Link 是硬链接。

硬链接是在目录中新增一个文件名,使它指向已有文件的 inode。

创建:

ln a.txt b.txt

特点:

  • 多个文件名指向同一个 inode。
  • inode 的 link count 增加。
  • unlink() 删除的是目录项,不是立即删除文件数据。
  • 当 link count 变为 0 且没有打开文件引用时,文件数据才真正释放。

限制:

  • 通常不能跨文件系统,因为 inode 编号只在单个文件系统内有意义。
  • 普通用户通常不能硬链接目录,避免目录树形成环和 .. 语义混乱。

11. 符号链接

Symbolic Link 是符号链接,也叫软链接。

创建:

ln -s a.txt b.txt

特点:

  • 符号链接是特殊文件。
  • 文件内容保存目标路径名。
  • 不直接指向目标 inode。
  • 可以跨文件系统。
  • 可以指向目录。
  • 可以指向不存在的路径。
  • 删除符号链接本身不影响目标文件。
  • 删除目标文件会使符号链接变成 dangling link,即悬空链接。

访问符号链接时,系统读取其中保存的目标路径,再继续路径解析。符号链接可能形成循环,系统通常设置解析次数上限,超过后返回 ELOOP

12. 文件保护与挂载

Unix 文件系统使用 UID、GID 和权限位控制访问。

权限对象:

  • user:文件所有者。
  • group:所属组。
  • others:其他用户。

权限类型:

  • 普通文件:read、write、execute。
  • 目录:list、create/delete/rename、traverse/search。

Mount 是挂载。mount() 把一个文件系统挂载到目录树中的挂载点,形成统一目录树,并支持不同文件系统共存。

VFS 是 Virtual File System,全称虚拟文件系统。VFS 向上提供统一接口,向下适配不同文件系统。

如果某进程正在使用挂载点中的文件,卸载可能失败,提示 device busy。

三十一、文件系统实现

1. 文件系统两个核心问题

文件系统实现要回答:

  1. 数据结构:磁盘上如何组织数据和元数据。
  2. 访问方法:如何把 open()read()write() 转换为目录项、inode、位图和数据块访问。

简单 Unix 文件系统通常基于块数组结构;现代文件系统会使用哈希索引、B/B+ 树、日志等结构。

2. 文件系统块与磁盘布局

文件系统把存储空间划分为固定大小的块(Block),例如 4 KB,并从 0 到 N-1 编号。

典型布局包括:

  • Superblock:超级块。
  • inode bitmap:inode 位图。
  • data bitmap:数据块位图。
  • inode table:inode 表。
  • data blocks:数据块区域。

超级块保存全局元数据:

  • 文件系统类型。
  • 块大小。
  • inode 数量。
  • 数据块数量。
  • inode 表起始位置。
  • 各区域布局信息。

挂载文件系统时,OS 会读取超级块。

3. 位图和空闲空间管理

Bitmap 是位图。文件系统通常使用两个位图:

  • inode bitmap:记录 inode 是否已分配。
  • data bitmap:记录数据块是否已分配。

一个 bit 表示一个对象状态:

  • 1:已分配。
  • 0:空闲。

位图优点:

  • 容易找到连续空闲空间。
  • 空间开销较低。

位图大小计算:

$$ \begin{gathered} n = \frac{D}{B} \ S_B = \frac{n}{8} \end{gathered} $$

符号说明:n 为块数量,也就是位图位数;D 为磁盘大小;B 为块大小;$S_B$ 为位图字节数。

例:1 TB 磁盘,4 KB 块:

$$ \text{blocks} = 2^{40} / 2^{12} = 2^{28} $$ $$ \text{bitmap_size} = 2^{28} bit\text{s} = 2^{25} byte\text{s} = 32 \text{MB} $$

空闲列表也可管理空闲块,但查找连续空间困难,分配多个块可能要遍历链表,并可能产生额外 I/O。

4. 块大小权衡

块太大:

  • 顺序读写吞吐较好。
  • 元数据开销小。
  • 小文件内部碎片严重,浪费空间。

块太小:

  • 空间利用率高。
  • 大文件需要更多块和更多索引项。
  • 随机读写多个块时 I/O 开销高。

文件系统块大小是空间利用率和 I/O 性能之间的权衡。

5. 文件组织方式

文件系统要维护从文件逻辑偏移到物理数据块的映射。

常见方式:

  • 连续分配。
  • 链表分配。
  • FAT。
  • 索引式分配。
  • 多级索引。

6. 连续分配

Contiguous Allocation 是连续分配。每个文件占用一组连续数据块。

元数据只需记录:

$$ (b_0,n) $$

符号说明:b_0 为文件起始块号;n 为连续块数量。

优点:

  • 顺序读写性能好。
  • 快速定位任意数据块。

缺点:

  • 创建时可能需要知道文件大小。
  • 文件扩展困难。
  • 外部碎片严重。

适合静态文件存储等场景。

7. 链表分配与 FAT

Linked Allocation 是链表分配。文件数据块组成链表,文件元数据记录第一个数据块。

优点:

  • 没有外部碎片。
  • 文件增长灵活。

缺点:

  • 随机访问差,定位第 n 块需要遍历。
  • 每个数据块要存指针,有空间开销。
  • 指针损坏可能导致文件后续数据丢失。

FAT 是 File Allocation Table,全称文件分配表。

FAT 把链表的“下一块指针”集中放到一张表中。每个数据块对应 FAT 中一个表项,表项可表示:

  • 下一个数据块编号。
  • 文件结束标记。
  • 空闲状态。

优点:

  • FAT 可缓存在内存中,比在磁盘块中追指针更快。
  • 实现简单,适合早期 Windows、DOS、USB、嵌入式设备。

缺点:

  • 大磁盘上 FAT 本身可能很大。

例:1 TB 磁盘、4 KB 块、每表项 4 B:

$$ \begin{gathered} \text{entries} = 2^{40} / 2^{12} = 2^{28} \ \text{FAT_size} = 2^{28} \cdot 4B = 2^{30}B = 1\text{GB} \end{gathered} $$

8. 索引式分配

Indexed Allocation 是索引式分配。每个文件有一个索引块,索引块中保存该文件各逻辑块对应的物理块号。

优点:

  • 随机访问好。
  • 只需缓存当前打开文件的索引块,不需要缓存整个磁盘的表。

问题:

  • 小文件可能浪费一个索引块。
  • 大文件可能一个索引块不够,需要多个索引块或多级索引。

9. 多级索引

多级索引兼顾小文件和大文件。

inode 中维护固定数量指针:

  • 直接指针:直接指向数据块。
  • 一级间接指针:指向一个间接块,间接块中保存数据块指针。
  • 二级间接指针:指向二级间接块。
  • 三级间接指针:指向三级间接块。

小文件使用直接指针,访问路径短。大文件通过间接指针扩展容量。

容量计算例:块大小 4 KB,块指针 4 B。

一个间接块可保存:

$$ 4\text{KB} / 4B = 1024 \text{pointers} $$

若 inode 有 12 个直接指针:

  • 直接指针容量:12 * 4KB = 48KB
  • 一级间接容量:1024 * 4KB = 4MB
  • 二级间接容量:1024 * 1024 * 4KB = 4GB
  • 三级间接容量:1024^3 * 4KB = 4TB

10. 目录组织

目录本质上是特殊文件,内容由目录项组成。

Unix 类目录项通常保存:

$$ \langle\text{file name},\text{inode number}\rangle $$

FAT 目录项通常保存更多信息:

  • 文件名。
  • 属性。
  • 时间。
  • 起始 cluster。
  • 文件大小。

现代目录项常采用变长结构,以支持不定长文件名。

目录查找:

  • 小目录可线性扫描目录项。
  • 大目录可用哈希索引、B+ 树等结构加速。

B+ Tree 是 B+ 树。现代文件系统常用文件名 hash 作为索引,在 B+ 树中定位目录项。

11. 文件操作的磁盘访问

mount()

  • 读取超级块。
  • 初始化内存中的文件系统结构。

open("/foo/bar")

  • 通常只做路径解析并打开文件。
  • 访问目录 inode、目录数据块和目标 inode。
  • 不读取 /foo/bar 的文件内容数据块。

read(fd, buf, count)

  • 根据 FD 找 open file description。
  • 读取 offset。
  • 找 inode。
  • 根据文件块索引找到数据块。
  • 读取数据并推进 offset。

write() 覆盖已有块:

  • 找到目标数据块。
  • 写数据。
  • 更新 mtime/ctime 等元数据。

write() 追加并需要新块:

  • 分配新数据块。
  • 更新数据位图。
  • 更新 inode 数据块索引和文件大小。
  • 写入新数据块。

12. 文件系统性能:缓存与缓冲

文件系统性能关键在于减少慢速存储 I/O。一次 read()write() 可能只访问内存缓存,不一定触发磁盘访问。

Caching 是缓存:

  • FAT 可缓存 FAT 表的部分或全部。
  • Unix/Linux 会缓存活跃 inode、目录项和文件数据页。
  • 可利用时间局部性和空间局部性。
  • 可通过预取提高顺序访问性能。

缓存指标:

$$ \begin{gathered} h = \frac{H}{N} \ m = \frac{M}{N} \ h + m = 1 \ L = hL_m + mL_d \ P = U / V \end{gathered} $$

符号说明:h 为缓存命中率;m 为缓存未命中率;H 为命中次数;M 为未命中次数;N 为请求总数;L 为平均 I/O 延迟;L_m 为内存缓存访问延迟;L_d 为磁盘访问延迟;P 为预取有效率;U 为真正被使用的预取块数;V 为预取块总数。

Buffering 是缓冲,尤其是写缓冲。

Write-through Policy 是直写策略:

  • 修改后立即写回磁盘。
  • 一致性较好。
  • 可能导致大量随机写。

Write-back Policy 是写回策略:

  • 修改后先标记为 dirty,延迟写回。
  • 可合并小写,减少随机写。
  • 性能更好。
  • 崩溃时可能丢失尚未写回的数据。

写缓冲指标:

$$ \begin{gathered} \delta = \frac{D}{C} \ \beta = \frac{W}{T} \ \alpha = \frac{W_p}{W_l} \ L \le D_u \end{gathered} $$

符号说明:\delta 为脏块比例;D 为脏块数;C 为缓存块数;\beta 为写回速率;W 为写回块数;T 为时间;\alpha 为写放大;W_p 为物理写次数;W_l 为逻辑写次数;L 为崩溃时可能丢失的脏数据量;D_u 为尚未刷盘的脏数据量。

13. FFS

FFS 是 Fast File System,全称快速文件系统。Berkeley FFS 将磁盘划分为多个 cylinder groups,即柱面组。

每个 group 有自己的:

  • 超级块副本。
  • inode bitmap。
  • data block bitmap。
  • inode table。
  • data blocks。

FFS 核心思想:利用局部性,把可能一起访问的数据放近。

分配策略:

  • 创建文件时,同一文件的 inode 和 data 尽量放在同一个 group。
  • 同一目录下的文件尽量放在同一个 group。
  • 创建目录时,在目录较少、空闲 inode 较多的 group 中分配,均衡目录分布。

目的:

  • 减少机械硬盘寻道。
  • 提高顺序访问和相关文件访问性能。

14. 持久化中的一致性风险

写缓冲、设备缓存和多步元数据更新都会带来一致性风险。

例如追加写可能涉及:

  • 分配数据块。
  • 更新数据位图。
  • 写数据块。
  • 更新 inode 指针。
  • 更新文件大小。

如果系统在中间崩溃,可能出现:

  • 位图显示块已分配,但 inode 未引用。
  • inode 指向未初始化数据块。
  • 文件大小与实际块索引不一致。

课件后续若讲日志或崩溃一致性,应重点关注“多块更新如何原子化”。

三十二、持久化常考模板

1. 磁盘访问时间

$$ \begin{gathered} T_{IO} = S + R + X \ B = \frac{Q}{T_{IO}} \end{gathered} $$

平均旋转延迟:

$$ \begin{gathered} T_{rot}=\frac{60}{RPM} \ R_{avg}=\frac{T_{rot}}{2} \end{gathered} $$

符号说明:T_{IO} 为一次 I/O 时间;S 为寻道时间;R 为旋转延迟;X 为传输时间;B 为吞吐量;Q 为请求大小;T_{rot} 为旋转一圈时间;RPM 为每分钟转数;R_{avg} 为平均旋转延迟。

答题要点:

  • 顺序访问主要看传输速率。
  • 随机访问主要看寻道和旋转延迟。
  • 小随机 I/O 吞吐远低于顺序 I/O。

2. 文件描述符共享关系

判断 offset 是否共享:

  • 两次独立 open():不同 open file description,offset 不共享。
  • fork() 继承 FD:共享同一个 open file description,offset 共享。
  • dup() / dup2():共享同一个 open file description,offset 共享。

3. 路径解析访问次数

路径 /a/b/c 解析通常要逐级:

  1. 读取目录 inode。
  2. 读取目录数据块,查找名字。
  3. 得到下一级 inode number。

若缓存命中,可减少磁盘 I/O。题目一般会说明是否忽略缓存。

4. inode 定位计算

$$ \begin{gathered} o_i = i \cdot S_i \ A_i = A_0 + o_i \ b_i = \left\lfloor A_i / B \right\rfloor \ \delta_i = A_i \bmod B \end{gathered} $$

如果题目按扇区寻址:

$$ \begin{gathered} s_i = \left\lfloor A_i / S \right\rfloor \ \delta_i = A_i \bmod S \end{gathered} $$

符号说明:o_i 为 inode i 的偏移;i 为 inode 编号;S_i 为 inode 大小;A_i 为 inode 地址;A_0 为 inode 表起始地址;b_i 为块号;B 为块大小;\delta_i 为块内或扇区内偏移;s_i 为扇区号;S 为扇区大小。

注意 inode 编号从 0 还是从 1 开始。

5. 多级索引容量

给定:

  • B:块大小。
  • P:指针大小。
  • d:直接指针数量。

计算:

$$ \begin{gathered} n = \frac{B}{P} \ C_0 = dB \ C_1 = nB \ C_2 = n^2B \ C_3 = n^3B \end{gathered} $$

符号说明:n 为一个间接块可容纳的指针数;B 为块大小;P 为指针大小;C_0 为直接指针容量;d 为直接指针数量;C_1 为一级间接容量;C_2 为二级间接容量;C_3 为三级间接容量。

6. 位图大小

$$ \begin{gathered} n = \frac{D}{B} \ S_b = n \ S_B = \frac{n}{8} \end{gathered} $$

符号说明:n 为块数量;D 为磁盘大小;B 为块大小;S_b 为位图位数;S_B 为位图字节数。

inode 位图同理,只是对象数量换成 inode 数量。

7. 硬链接与符号链接

硬链接:

  • 新目录项指向同一个 inode。
  • link count 增加。
  • 不能跨文件系统。
  • 通常不能链接目录。

符号链接:

  • 特殊文件,内容是目标路径。
  • 可以跨文件系统。
  • 可以指向目录。
  • 可以指向不存在路径。
  • 目标删除后会悬空。

三十三、持久化英文缩写速查

缩写 全称 中文
DMI Direct Media Interface 直接媒体接口
DMA Direct Memory Access 直接内存访问
DRAM Dynamic Random Access Memory 动态随机访问存储器
FAT File Allocation Table 文件分配表
FD File Descriptor 文件描述符
FFS Fast File System 快速文件系统
GID Group Identifier 组 ID
HDD Hard Disk Drive 机械硬盘
LBA Logical Block Address 逻辑块地址
MMIO Memory-Mapped I/O 内存映射 I/O
NCQ Native Command Queuing 原生命令队列
NVMe Non-Volatile Memory Express 非易失性内存主机控制器接口规范
PCIe Peripheral Component Interconnect Express 高速串行计算机扩展总线标准
PIO Programmed I/O 程序控制 I/O
RPM Rotations Per Minute 每分钟转数
SATA Serial Advanced Technology Attachment 串行高级技术附件
SSD Solid State Drive 固态硬盘
SPTF Shortest Positioning Time First 最短定位时间优先
SSTF Shortest-Seek-Time-First 最短寻道时间优先
UID User Identifier 用户 ID
USB Universal Serial Bus 通用串行总线
VFS Virtual File System 虚拟文件系统

三十四、持久化最后复习抓手

  1. I/O 设备通过状态、命令、数据寄存器与 OS 交互。
  2. 轮询简单但忙等浪费 CPU;中断允许计算与 I/O 重叠。
  3. PIO 由 CPU 搬运数据;DMA 由控制器直接在设备和内存之间搬运数据。
  4. HDD 随机访问慢,核心代价是寻道和旋转延迟。
  5. 磁盘调度要减少磁头移动和定位时间,但要注意饥饿。
  6. 文件是持久字节数组,inode 保存元数据和数据块索引,目录保存名字到 inode 的映射。
  7. FD 是进程私有句柄,offset 在 open file description 中。
  8. fork()dup() 会共享 open file description,因此共享 offset。
  9. 硬链接共享 inode;符号链接保存路径名。
  10. 文件系统布局通常包括超级块、位图、inode 表和数据块。
  11. 多级索引用直接指针照顾小文件,用间接指针扩展大文件。
  12. 缓存和写缓冲能提升性能,但会引入崩溃一致性风险。

三十五、全篇指标数学表达式汇总

1. CPU 调度指标

$$ \begin{gathered} X = \frac{N}{T} \ U = \frac{B}{T} \ T_i = C_i - A_i \ ATT = \frac{1}{n}\sum_i T_i \ R_i = F_i - A_i \ ART = \frac{1}{n}\sum_i R_i \ W_i = T_i - S_i \ NTT_i = \frac{T_i}{S_i} \ \varphi = \frac{\min_i Q_i}{\max_i Q_i} \ O_r = \frac{O}{q+O} \ E = \frac{q}{q+O} \end{gathered} $$

符号说明:X 吞吐量;N 完成任务数;T 总时间;U CPU 利用率;B CPU 忙碌时间;T_i 作业 i 的周转时间;C_i 完成时间;A_i 到达时间;ATT 平均周转时间;R_i 响应时间;F_i 首次运行时间;ART 平均响应时间;W_i 等待时间;S_i 实际运行时间;NTT_i 带权周转时间;\varphi 公平性比值;Q_i 进程 i 获得的资源份额;O_r RR 调度开销比例;O 每次调度开销;q 时间片长度;E 有效 CPU 时间比例。

2. 比例份额和 CFS 指标

$$ \begin{gathered} s_i = \frac{t_i}{\sum_j t_j} \ C_i \approx C_{total} \cdot s_i \ l_i = \frac{L}{t_i} \ p_i' = p_i + l_i \ k = \arg\min_i p_i \ \tau_i = \lambda\cdot\frac{w_i}{\sum_j w_j} \ v_i' = v_i+\Delta_i\cdot\frac{1024}{w_i} \ k = \arg\min_i v_i \end{gathered} $$

符号说明:s_i CPU 份额;t_i 彩票数;C_i 进程 i 获得的 CPU 时间;C_{total} 总 CPU 时间;l_i 步长;L 大常数;p_i pass 值;p_i' 更新后的 pass 值;k 被选中的进程;\tau_i CFS 时间片;\lambda 调度周期;w_i 权重;v_i 虚拟运行时间;v_i' 更新后的虚拟运行时间;\Delta_i 实际运行时间。

3. 内存和地址转换指标

$$ \begin{gathered} b = \log_2(P) \ v = a - b \ N_{PTE} = 2^v \ S_{PT} = N_{PTE} \cdot S_{PTE} \ VPN = VA \gg b \ d = VA \bmod P \ PA = PFN \cdot P + d \ h = \frac{H}{N} \ m = \frac{M}{N} \ EAT = hC_h + mC_m \end{gathered} $$

符号说明:b 页内偏移位数;P 页大小;v 虚拟页号位数;a 虚拟地址位数;N_{PTE} 页表项数量;S_{PT} 页表大小;S_{PTE} 单个页表项大小;VA 虚拟地址;VPN 虚拟页号;d 页内偏移;PA 物理地址;PFN 物理页帧号;h TLB 命中率;m TLB 未命中率;H 命中次数;M 未命中次数;N 地址转换总次数;EAT 有效地址转换代价;C_h 命中代价;C_m 未命中代价。

4. 页面置换指标

$$ \begin{gathered} h = \frac{K}{N} \ m = \frac{F}{N} \ h + m = 1 \ AMAT = H + mP \ AMAT = M + fD \ v_{OPT} = \arg\max_p next(p) \ v_{LRU} = \arg\min_p last(p) \ v_{LFU} = \arg\min_p count(p) \ B(k+1) > B(k) \end{gathered} $$

符号说明:h 命中率;m 未命中率;K 命中次数;F 缺页次数;N 内存访问次数;AMAT 平均内存访问时间;H 命中访问时间;P 未命中惩罚;M 内存访问时间;f 缺页率;D 缺页服务时间;v_{OPT} OPT 淘汰页;next(p) 页 p 下一次被访问时间;v_{LRU} LRU 淘汰页;last(p) 页 p 上次访问时间;v_{LFU} LFU 淘汰页;count(p) 页 p 访问次数;B(k) k 个页框时的缺页次数。

5. 空间管理指标

$$ \begin{gathered} I = A - R \ I_r = \frac{I}{A} \ E = \sum_i f_i \ M = \max(f_i) \ E \ge R \land M < R \ S_b = n \ S_B = \frac{n}{8} \end{gathered} $$

符号说明:I 内部碎片大小;I_r 内部碎片率;A 实际分配块大小;R 请求大小;E 空闲空间总量;f_i 第 i 个空闲块大小;M 最大空闲块大小;E ≥ R ∧ M < R 表示存在外部碎片;S_b 位图位数;S_B 位图字节数;n 被管理对象数量。

6. 并发指标

$$ \begin{gathered} L_i = a_i - r_i \ S_i = u_i - a_i \ \rho = \frac{C}{N} \ \omega = \frac{P}{T} \ X = \frac{K}{T} \ \varphi = \frac{\min_i G_i}{\max_i G_i} \ W_i \to \infty \ V = G + \sum_i L_i \ V_r = G \ \varepsilon = V - V_r = \sum_i L_i \ \varepsilon_r = \frac{\varepsilon}{V} \ N_{ij} = M_{ij} - C_{ij} \ \exists(p_1,...,p_n), \forall k: N_{p_k} \le W_k \ W_{k+1} = W_k + C_{p_k} \end{gathered} $$

符号说明:L_i 加锁等待时间;r_i 请求锁时间;a_i 获得锁时间;S_i 临界区时间;u_i 释放锁时间;\rho 锁竞争率;C 发生竞争的加锁次数;N 总加锁次数;\omega 自旋浪费比例;P 自旋消耗 CPU 时间;T 总 CPU 时间;X 锁吞吐量;K 完成临界区次数;\varphi 公平性比值;G_i 线程 i 成功获得锁次数;W_i -> ∞ 表示饥饿;V 近似计数器真实值;G 全局计数器;L_i 第 i 个局部计数器;V_r 读到的近似值;\varepsilon 绝对误差;\varepsilon_r 相对误差;N_{ij} 剩余资源需求;M_{ij} 最大需求;C_{ij} 已分配资源;p_k 安全序列中第 k 个线程;W_k 第 k 步可用资源向量。

7. 磁盘和文件系统指标

$$ \begin{gathered} T_{rot}=\frac{60}{RPM} \ R_{avg}=\frac{T_{rot}}{2} \ T_{IO} = S + R + X \ B = \frac{Q}{T_{IO}} \ d_i = |x_i - x_0| \ D = \sum_i d_i \ p_i = s_i + r_i \ k = \arg\min_i p_i \ o_i = i \cdot S_i \ A_i = A_0 + o_i \ b_i = \left\lfloor A_i / B_s \right\rfloor \ \delta_i = A_i \bmod B_s \ n_p = \frac{B_s}{S_p} \ C_1 = n_pB_s \ C_2 = n_p^2B_s \ C_3 = n_p^3B_s \ h = \frac{H}{N} \ L = hL_m + (1-h)L_d \ \delta = \frac{D_b}{C_b} \ \alpha = \frac{W_p}{W_l} \end{gathered} $$

符号说明:T_{rot} 磁盘旋转一圈时间;RPM 每分钟转数;R_{avg} 平均旋转延迟;T_{IO} 一次 I/O 时间;S 寻道时间;R 旋转延迟;X 传输时间;B 吞吐量;Q 请求大小或传输字节数;d_i 请求 i 的寻道距离;x_i 请求磁道号;x_0 当前磁头位置;D 总磁头移动距离;p_i 请求 i 的定位时间;s_i 寻道时间;r_i 旋转延迟;k SPTF 选择的请求;o_i inode i 的偏移;S_i inode 大小;A_i inode i 的地址;A_0 inode 表起始地址;b_i 所在块号;B_s 块大小;\delta_i 块内偏移;n_p 间接块可存放的指针数;S_p 指针大小;C_1 一级间接容量;C_2 二级间接容量;C_3 三级间接容量;h 缓存命中率;H 命中次数;N 请求总数;L 平均 I/O 延迟;L_m 内存访问延迟;L_d 磁盘访问延迟;\delta 脏块比例;D_b 脏块数;C_b 缓存块数;\alpha 写放大;W_p 物理写次数;W_l 逻辑写次数。