6.2 按需分页、缺页与 mmap
虚拟内存大厅给每个进程一张宽阔得近乎奢侈的地址地图。你沿着某个地址推门,门后却未必已经放着一页 DRAM:它可能要从文件取来,也可能等第一次写入时才真正分配。地图上的地址是承诺,不是所有物理资源都已提前到位。
6.1 虚拟地址、页表与 TLB说明地址怎样翻译。这一课追问 PTE 尚未就绪时发生什么,以及匿名内存、文件映射、copy-on-write、reclaim 与 overcommit 怎样共同决定这次访问能否继续。
page fault 是一次受控的同步异常
程序执行 load、store 或 instruction fetch 时,MMU 可能发现:
- page 尚未 present;
- 当前访问违反 read/write/execute 或 user/supervisor 权限;
- page 被特意设成只读,以便实现 copy-on-write;
- 地址不属于任何合法映射。
处理器暂停当前指令,保存足够的架构状态,切入内核 page-fault handler。内核查找对应 VMA 和访问类型,再决定修复映射、等待 I/O、向进程发送信号,或采取其他平台定义的动作。若修复成功,原指令可重新执行,用户代码通常看不到一次显式函数返回。
因此 page fault 不是 page 不在 DRAM 的同义词,也不都是程序错误。它是“当前 translation 无法直接满足访问”的统一入口。
minor、major 与 SIGSEGV 不在同一分类轴
Linux 等系统常把成功处理的 fault 计入:
- minor fault:无需等待从块设备读取目标 page,例如分配 zero-filled anonymous page、建立已有 page-cache page 的 PTE、完成某些 COW;
- major fault:为满足 fault 需要等待存储 I/O,例如目标文件页不在 page cache 或匿名页已换出。
访问 unmapped 地址或违反无法修复的权限时,内核可能向进程发送 SIGSEGV;映射文件被截短后访问失效范围,Unix 上常见 SIGBUS。它们是 fault 的处理结果,不应与 minor/major 并列成“三种缺页”。
minor 不保证固定“几十微秒”,major 也不保证一定是机械磁盘毫秒级。page cache、SSD、内存压力、调度和文件系统会改变代价。
匿名页通常按首次触碰提交
一段 anonymous mapping 可以先占据 virtual range,在首次写入某 page 时才分配并清零 physical page。只读首次访问还可能共享内核的 zero page,直到写入触发 COW。
下面的 Linux/POSIX 示例通过 mmap 明确申请匿名区域,并用 getrusage 观察 fault 计数变化:
#define _DEFAULT_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <unistd.h>
int main(void) {
const size_t length = 64U * 1024U * 1024U;
long page_size = sysconf(_SC_PAGESIZE);
if (page_size <= 0) {
fputs("cannot determine page size\n", stderr);
return 1;
}
volatile unsigned char *region = mmap(
NULL, length, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (region == MAP_FAILED) {
perror("mmap");
return 1;
}
struct rusage before;
struct rusage after;
if (getrusage(RUSAGE_SELF, &before) != 0) {
perror("getrusage");
munmap((void *)region, length);
return 1;
}
for (size_t offset = 0; offset < length; offset += (size_t)page_size) {
region[offset] = 1;
}
if (getrusage(RUSAGE_SELF, &after) != 0) {
perror("getrusage");
munmap((void *)region, length);
return 1;
}
printf("minor delta=%ld major delta=%ld\n",
after.ru_minflt - before.ru_minflt,
after.ru_majflt - before.ru_majflt);
if (munmap((void *)region, length) != 0) {
perror("munmap");
return 1;
}
return 0;
}不要期待 fault 数严格等于 length/page_size。transparent huge page、预取、预填充、内核 accounting 和此前映射状态都会改变结果。实验应同时记录 THP 配置、page size 与系统负载。
普通 malloc 的首次触碰行为还受 allocator 复用影响。它可能返回已经 backed 的 arena 区域,所以研究 paging 时用明确的 mmap 更容易控制变量。
copy-on-write 让复制推迟到写入
fork() 后,父子进程拥有独立 virtual address space 语义,却可暂时共享同一批 physical page。内核把相关映射设为只读并记录 COW:
父 virtual page --+
+--> shared physical page (read-only PTEs)
子 virtual page --+一方写入时触发 protection fault。若仍有共享者,内核分配新 page、复制旧内容、把写入方 PTE 指向新 page 并恢复可写权限;另一方继续看到原内容。
COW 节省的是“未被写过的复制”。若子进程很快改写整个大 heap,复制成本仍会出现,并可能导致突发内存压力。多线程进程 fork() 后还要遵守 async-signal-safe 等运行时规则,不能只从 page 数判断安全性。
reclaim 不是一条简单 FIFO 队列
物理内存紧张时,内核可回收 clean file-backed page,因为需要时能从文件重新读取;dirty file page 要先进入 writeback;anonymous page 若要保留内容,通常需要 swap 或其他后备机制。
教材常用三种算法建立直觉:
FIFO
按进入时间淘汰,不考虑近期访问。它可能发生 Belady anomaly:增加 frame 反而产生更多 fault。经典引用串:
1 2 3 4 1 2 5 1 2 3 4 5在 FIFO 下,3 个 frame 有 9 次 fault,4 个 frame 反而有 10 次。
LRU
淘汰最久未访问的 page。LRU 具有 stack property,因此增加 frame 不会产生 Belady anomaly;但它不是理论最优算法。已知未来引用序列时,淘汰“未来最晚再使用”页面的 OPT/MIN 才给出最低 fault 数,只是在线系统无法知道未来。
精确维护每次内存访问的全局 LRU 顺序成本很高,OS 会借助 accessed/reference bit、采样和分代近似热度。
Clock / second chance
环形扫描 frame:reference bit 为 1 时清零并跳过,为 0 时选择 victim。它给近期使用 page 第二次机会,是理解近似 LRU 的经典模型。
真实 Linux reclaim 随内核版本演进,可能使用 active/inactive list、generation、working-set detection 和不同匿名/file 策略,不能概括成“Linux 就是 Clock”。容器 cgroup、NUMA node 和 memory pressure 也会限制候选集合。
一个可执行的 FIFO/LRU/Clock 对比
from collections import OrderedDict, deque
def fifo_faults(references, capacity):
resident = set()
order = deque()
faults = 0
for page in references:
if page in resident:
continue
faults += 1
if len(resident) == capacity:
resident.remove(order.popleft())
resident.add(page)
order.append(page)
return faults
def lru_faults(references, capacity):
resident = OrderedDict()
faults = 0
for page in references:
if page in resident:
resident.move_to_end(page)
continue
faults += 1
if len(resident) == capacity:
resident.popitem(last=False)
resident[page] = None
return faults
def clock_faults(references, capacity):
frames = [None] * capacity
referenced = [False] * capacity
positions = {}
hand = 0
faults = 0
for page in references:
if page in positions:
referenced[positions[page]] = True
continue
faults += 1
while frames[hand] is not None and referenced[hand]:
referenced[hand] = False
hand = (hand + 1) % capacity
victim = frames[hand]
if victim is not None:
del positions[victim]
frames[hand] = page
referenced[hand] = True
positions[page] = hand
hand = (hand + 1) % capacity
return faults
trace = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
assert fifo_faults(trace, 3) == 9
assert fifo_faults(trace, 4) == 10
assert lru_faults(trace, 3) == 10
assert lru_faults(trace, 4) == 8
assert clock_faults(trace, 3) == 9
assert clock_faults(trace, 4) == 10模型要求 capacity > 0;生产级模拟器应显式拒绝零或负容量,并记录每步 resident set,而不只输出总数。
mmap 把档案馆页面接入地址地图
虚拟内存大厅的一扇门也可以直接对应档案馆里的文件页。程序通过地址访问,第一次触碰时由 fault handler 把相应 page 接进来;这改变了接口和加载时机,却没有让存储 I/O 凭空消失。
file-backed mapping 让 virtual page 对应文件 offset。首次访问不在 page cache 的页时,fault handler 读取文件数据并建立 PTE;已有 page-cache page 则可能只需 minor fault。
MAP_PRIVATE 的写入走 COW,不更新底层文件;MAP_SHARED 的修改进入共享 file page,并可按 API 规则写回文件。它们都不自动解决多进程数据结构同步。
下面的 POSIX 示例只修改一个非空文件的首 byte,并认真处理长度和错误:
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void) {
int result = 1;
int fd = open("mapped.bin", O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
struct stat metadata;
if (fstat(fd, &metadata) != 0) {
perror("fstat");
goto close_file;
}
if (metadata.st_size <= 0) {
fputs("mapped.bin must be non-empty\n", stderr);
goto close_file;
}
size_t length = (size_t)metadata.st_size;
unsigned char *mapping = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (mapping == MAP_FAILED) {
perror("mmap");
goto close_file;
}
printf("first byte before: %u\n", (unsigned int)mapping[0]);
mapping[0] = (unsigned char)'A';
if (msync(mapping, length, MS_SYNC) != 0) {
perror("msync");
goto unmap;
}
result = 0;
unmap:
if (munmap(mapping, length) != 0) {
perror("munmap");
result = 1;
}
close_file:
if (close(fd) != 0) {
perror("close");
result = 1;
}
return result;
}映射长度不能安全地超过可访问文件范围;文件被其他进程截短后再访问失效页,常会收到 SIGBUS。并发改变文件大小需要额外协议。
msync(MS_SYNC) 请求把修改同步到文件映射的后备对象,但“断电后必然落到稳定介质”还取决于文件系统、设备缓存与平台保证;需要 durability 时,应按目标系统文档组合 msync、fsync、目录同步或事务协议,而不是只依赖 munmap。
mmap 不是自动更快的“零拷贝”按钮
对 file read,mmap 可把 page-cache page 直接映射进进程,省去 read() 把 page cache 复制到用户 buffer 的那一步。但 CPU 后续若把数据复制到另一个位置,字节仍被复制;page fault、TLB 和 VMA 管理也有成本。
顺序流式 I/O 中,read 使用固定 buffer 可能更容易控制 readahead 与错误,性能也可能很好。随机访问大文件时,mmap 的地址式接口很方便。向 socket 发送文件可评估 sendfile,文件到文件可评估 copy_file_range;各 API 都有文件系统与平台限制。
不要预设胜者。比较时记录冷/热 cache、fault 数、系统调用、CPU 时间、总吞吐和错误处理语义。
共享 mapping 的“修改可被另一进程看到”也不等于无锁并发安全。进程共享 mutex、适合跨进程的原子对象或明确定义的文件格式协议,才负责更新顺序与崩溃恢复。
overcommit 把失败时机推后
Linux memory overcommit 控制的是 virtual memory commitment accounting。大额 malloc/mmap 可能先成功,实际触碰 page 时才消耗更多 physical memory 与 swap;这让稀疏空间、COW fork 等模式可行,也可能把资源不足推迟到运行中。
/proc/sys/vm/overcommit_memory 常见模式是:
| 值 | 概念 |
|---|---|
| 0 | heuristic overcommit |
| 1 | always overcommit 的宽松承诺检查 |
| 2 | 按 commit limit 做较严格 accounting |
模式 2 的 limit 与 swap、overcommit_ratio 或 overcommit_kbytes 等配置相关,但具体规则要查运行内核文档。没有“所有关键系统一律设 2”的通用答案:数据库、无 swap 容器节点、桌面和批处理对早失败、吞吐及 OOM 行为的要求不同。
内存压力下,内核可能 reclaim、writeback、swap、让分配/ fault 失败,或触发 OOM killer。Linux 的 victim 选择综合 memory cgroup、oom_score_adj、占用等因素,不等同于“总杀最大泄漏者”。保护某个进程也会把风险转移给其他工作负载,必须按系统级策略配置。
动手观察承诺与驻留
- 运行匿名映射示例,记录 page size、THP 配置和 minor/major delta;解释为什么结果不必等于 page 数。
- 为 FIFO、LRU、Clock 模型加入
capacity <= 0检查,并输出每步 frame 状态。 - 构造一个
MAP_PRIVATE文件映射,写入后验证原文件不变。 - 解释文件被
truncate后访问旧 mapping 为什么可能不是普通SIGSEGV。 - 比较
read、mmap、copy_file_range复制同一文件,分别做 cold-cache 与 warm-cache 实验。 - 在容器中解释 host OOM、memory cgroup limit 与进程自己的 virtual address limit 如何产生不同失败。
fault 最终会越过用户与内核边界
page fault 是处理器异常的一种。下一章进入异常与系统调用,区分 trap、fault、interrupt 和 syscall,并追踪硬件怎样切换特权级、内核又怎样安全返回用户态。