6.2 按需分页、缺页与 mmap
进程可以拥有很大的虚拟地址范围,但其中某个地址可用,不代表对应 DRAM 页已经就位。它可能要从文件载入,也可能等第一次写入时才真正分配。虚拟映射先定义地址语义,物理资源则按策略逐步落实。
6.1 虚拟地址、页表与 TLB说明地址怎样翻译。这一课追问 PTE 尚未就绪时发生什么,以及匿名内存、文件映射、copy-on-write、reclaim 与 overcommit 怎样共同决定这次访问能否继续。
page fault 是一次受控的同步异常
程序执行 load、store 或 instruction fetch 时,页表检查可能发现 present 位或权限不满足。OS 还要判断原因,例如:
- page 尚未 present;
- 当前访问违反 read/write/execute 或 user/supervisor 权限;
- page 被特意设成只读,以便实现 copy-on-write;
- 地址不属于任何合法映射。
处理器暂停当前指令,保存足够的架构状态,切入内核 page-fault handler。内核查找对应 VMA 和访问类型,再决定修复映射、等待 I/O、向进程发送信号,或采取其他平台定义的动作。若修复成功,原指令可重新执行,用户代码通常看不到一次显式函数返回。
因此 page fault 不是 page 不在 DRAM 的同义词,也不都是程序错误。这是分页机制报告缺页或保护条件的入口;MMU 不直接理解 VMA、文件或 COW 意图,那些由内核解释。
minor、major 与 SIGSEGV 不在同一分类轴
Linux 等系统常把成功处理的 fault 计入:
- minor fault:处理时不需要从磁盘载入目标 page,例如分配 zero-filled anonymous page、建立已有 page-cache page 的 PTE、完成某些 COW;
- major fault:处理时需要载入目标 page,通常涉及后备存储读取,例如目标文件页不在 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 接口及 MAP_ANONYMOUS 扩展,申请 64 MiB 并观察进程 fault 计数。保存为 touch_pages.c,用 cc -std=c17 -Wall -Wextra -Werror touch_pages.c -o touch_pages 编译,运行 ./touch_pages:
#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、内核批量填充、进程同期的其他 fault 和内核 accounting 都会改变结果。CPU 的普通 cache 预取不等于替用户访问分配匿名页。实验应同时记录 THP 配置、page size 与系统负载。
普通 malloc 的首次触碰行为还受 allocator 复用影响。它可能返回已经 backed 的 arena 区域,所以研究 paging 时用明确的 mmap 更容易控制变量。
copy-on-write 让复制推迟到写入
fork() 后,父子进程拥有独立 virtual address space 语义,却可暂时共享同一批 physical page。这里说的是可写的 private 映射,内核把需要 COW 的相关 PTE 设为只读;MAP_SHARED 保持共享语义,不能把所有继承映射都归入 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 check_capacity(capacity):
if not isinstance(capacity, int) or isinstance(capacity, bool):
raise TypeError("capacity must be an integer")
if capacity <= 0:
raise ValueError("capacity must be positive")
def fifo_faults(references, capacity):
check_capacity(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):
check_capacity(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):
check_capacity(capacity)
empty = object()
frames = [empty] * 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 empty and referenced[hand]:
referenced[hand] = False
hand = (hand + 1) % capacity
victim = frames[hand]
if victim is not empty:
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模型拒绝非正或非整数容量,reference 中的 page 标识须可哈希,None 也可以是合法标识。Clock 用独立哨兵标记空槽。这里只统计 fault;排查策略差异时还应输出每步 resident set。
mmap 把文件页映射进虚拟地址空间
虚拟页也可以对应文件 offset。程序通过地址访问,第一次触碰时由 fault handler 建立所需映射;这改变了访问接口和加载时机,却没有让存储 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 规则写回文件。它们都不自动解决多进程数据结构同步。
在新建练习目录准备一个非空普通文件 mapped.bin。下面的 POSIX 程序只映射并修改首 byte,因此不必把可能大于 SIZE_MAX 的整文件长度窄化为 size_t。保存为 map_file.c,用 cc -std=c17 -Wall -Wextra -Werror map_file.c -o map_file 编译,再运行:
#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 (!S_ISREG(metadata.st_mode) || metadata.st_size <= 0) {
fputs("mapped.bin must be a non-empty regular file\n", stderr);
goto close_file;
}
const size_t length = 1;
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;
}成功 mmap 不代表映射中的每个 byte 都有文件数据支撑。Linux 上,文件最后一个不完整页的尾部通常补零,对该尾部的写不会扩展文件;访问完全超出 EOF 的页通常触发 SIGBUS。文件被其他进程截短也可能使原本可用的页失效,需要额外的大小变更协议。
msync(MS_SYNC) 同步修改到映射的后备文件并等待完成。崩溃持久性还要遵循文件系统和设备的保证:按目标协议处理 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 数。
- 为三种模型验证非法 capacity、空 trace 和
None标识的行为,并输出每步 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,并追踪硬件怎样切换特权级、内核又怎样安全返回用户态。