跳到内容

第 3 卷 / 第 6 章 / 第 2 课

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:

c
#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:

text
父 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。经典引用串:

text
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 对比 ​

python
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 编译,再运行:

c
#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 常见模式是:

值概念
0heuristic overcommit
1always 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、占用等因素,不等同于“总杀最大泄漏者”。保护某个进程也会把风险转移给其他工作负载,必须按系统级策略配置。

动手观察承诺与驻留 ​

  1. 运行匿名映射示例,记录 page size、THP 配置和 minor/major delta;解释为什么结果不必等于 page 数。
  2. 为三种模型验证非法 capacity、空 trace 和 None 标识的行为,并输出每步 frame 状态。
  3. 构造一个 MAP_PRIVATE 文件映射,写入后验证原文件不变。
  4. 解释文件被 truncate 后访问旧 mapping 为什么可能不是普通 SIGSEGV。
  5. 比较 read、mmap、copy_file_range 复制同一文件,分别做 cold-cache 与 warm-cache 实验。
  6. 在容器中解释 host OOM、memory cgroup limit 与进程自己的 virtual address limit 如何产生不同失败。

fault 最终会越过用户与内核边界 ​

page fault 是处理器异常的一种。下一章进入异常与系统调用,区分 trap、fault、interrupt 和 syscall,并追踪硬件怎样切换特权级、内核又怎样安全返回用户态。

参考资料 ​

Built with VitePress | Software Systems Atlas