跳到内容

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 计数变化:

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、预取、预填充、内核 accounting 和此前映射状态都会改变结果。实验应同时记录 THP 配置、page size 与系统负载。

普通 malloc 的首次触碰行为还受 allocator 复用影响。它可能返回已经 backed 的 arena 区域,所以研究 paging 时用明确的 mmap 更容易控制变量。

copy-on-write 让复制推迟到写入

fork() 后,父子进程拥有独立 virtual address space 语义,却可暂时共享同一批 physical page。内核把相关映射设为只读并记录 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 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,并认真处理长度和错误:

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 (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 时,应按目标系统文档组合 msyncfsync、目录同步或事务协议,而不是只依赖 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_ratioovercommit_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. 为 FIFO、LRU、Clock 模型加入 capacity <= 0 检查,并输出每步 frame 状态。
  3. 构造一个 MAP_PRIVATE 文件映射,写入后验证原文件不变。
  4. 解释文件被 truncate 后访问旧 mapping 为什么可能不是普通 SIGSEGV
  5. 比较 readmmapcopy_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