Day 10 · 虚拟内存进阶:page fault、COW、mmap、swap、page cache

Week 02 · 周三 · 2026-05-20 · 主线:操作系统核心 · 预计 1.5h 阅读 + 30min 实操
今日导读:你的 64 位进程的虚拟地址空间是 256 TB,但你机器物理内存可能只有 64 GB。为什么没翻车?为什么 fork() 一个吃了 30GB 的 Python 进程几乎瞬间返回?为什么 cat /proc/meminfo 显示 free 只剩 2GB 但你的服务还能正常分配大块内存?为什么 top 里那个 30GB 大进程的 RES 字段实际只有 6GB?今天我们把昨天讲的"分配"换一个视角,从"内核如何让有限的物理内存看起来无限"切入:page fault 三类 → COW → mmap → swap → page cache → madvise/mlock。这六个概念串起来,你才能真正读懂 Linux 内存监控指标,而不再被 free -h 的数字吓到。
今日目录
  1. 从昨天回望:分配 ≠ 真的占内存
  2. Page fault 三类:minor / major / invalid
  3. COW:fork 为什么几乎免费
  4. mmap 完全解析:文件映射 vs 匿名映射
  5. swap:物理内存的"假肢"
  6. page cache:你以为内存满了,其实是缓存
  7. madvise / mlock:把内存当资源精细操作
  8. 与 AI / Agent 安全的连结
  9. 今日小练习(3 道)

1. 从昨天回望:分配 ≠ 真的占内存

昨天我们站在"分配器"的视角,看 buddy/slab/ptmalloc 怎么把内存切给你。但有一个关键事实昨天没明说——malloc 返回成功,不等于物理内存已经被占用。Linux 默认是 overcommit(超额承诺)的:你 malloc 10GB,内核可能根本没分一页物理内存,只是在你的虚拟地址空间里画了一块"账目上属于你"的区域

真正占用物理页发生在你第一次去读或写这块虚拟内存的瞬间。这个瞬间触发的硬件信号就是page fault(缺页异常),CPU 通知内核:"这个虚拟地址我查页表没找到映射,你处理一下。"内核要么补一页物理内存挂上去,要么发 SIGSEGV 把你杀掉。

malloc(1GB) 那一刻: ┌──────────────────────────────────────────────┐ │ 虚拟地址空间 [0x7f...0000 ~ 0x7f...4000_0000] │ ← 1GB 区域已标记 │ ↓ │ │ 页表 (还没有 PTE 条目) │ ← 完全空 │ ↓ │ │ 物理内存 (0 字节真实占用) │ └──────────────────────────────────────────────┘ 第一次 *p = 1 那一刻: ┌──────────────────────────────────────────────┐ │ CPU 访问 0x7f...0000 → MMU 查页表 → 没找到 │ │ ↓ │ │ 触发 page fault → 内核接管 │ │ ↓ │ │ 内核从 buddy 拿一页 4KB → 填零 → 挂上 PTE │ │ ↓ │ │ 重新执行那条指令 → 这次成功 │ └──────────────────────────────────────────────┘

这套机制叫 demand paging(按需分页)——用到才真分。它是 Linux 内存魔法的基石,下面所有概念都建立在这之上。

2. Page fault 三类:minor / major / invalid

page fault 不是一个事件,是三种完全不同性格的事件,分清楚它们是读懂内存监控的第一道门。

(a) Minor fault(轻微缺页)

页表里没映射,但要的数据不在磁盘上——典型场景是首次访问 malloc 出来的匿名页(内核分一页填零)、或者多个进程共享同一物理页(比如 libc,只需要把现有物理页的 PTE 复制一份)。不涉及磁盘 I/O,纯 CPU 开销,几百纳秒级

(b) Major fault(重大缺页)

页表里没映射,而且需要从磁盘读。两个典型来源:

major fault 涉及一次真实磁盘 I/O,开销是 minor fault 的 1000~10000 倍。在 NVMe 上一次约 100μs,在机械盘上是 10ms 级。LLM 服务突然 P99 暴涨,多半就是开始大量 major fault 了。

(c) Invalid fault(非法缺页)

访问的地址压根不在进程的虚拟地址空间合法区域里——典型就是空指针解引用、越界访问、释放后再用。内核发 SIGSEGV,进程死亡。也就是你熟悉的 segfault。

怎么看一个进程的 fault 数量

# 查任意进程的 fault 统计
cat /proc/<PID>/stat | awk '{print "minflt:",$10,"majflt:",$12}'

# 或者用 ps(minflt = 累计 minor,majflt = 累计 major)
ps -o pid,comm,minflt,majflt -p <PID>

# 实时观察某个程序运行期间的 fault
/usr/bin/time -v python3 your_script.py
# 关注输出中的:
#   Minor (reclaiming a frame) page faults: 123456
#   Major (requiring I/O) page faults: 5

判断方法:major fault 持续不为零 = 正在和磁盘较劲。生产环境如果 majflt 一直在增长,要么 swap 在被频繁使用,要么 mmap 的大文件不在 page cache 里——两种情况都意味着延迟暴涨。

3. COW:fork 为什么几乎免费

昨天讲 fork() 时一句话带过了 COW,今天展开。问题是:父进程吃了 30GB 内存,fork() 出子进程,难道要复制 30GB?如果真这样,Redis 做持久化时 fork 一次就要几十秒,根本不能用。

真实机制

fork() 时内核做的是:

  1. 给子进程建一个新的 task_struct、新页表。
  2. 把父进程页表的每一条 PTE 复制给子进程,但同时把父子两份 PTE 都标记成"只读"。
  3. 物理内存不复制。父子共享同一批物理页。

所以 fork 的代价 ≈ 页表复制(与虚拟空间大小成正比,而不是物理内存),几十毫秒。30GB 进程的页表大约 60MB(按 8 字节 PTE × 1000 万页算),复制这个量级完全 ok。

分裂的时机

父或子任意一方第一次写某一页时,CPU 检测到只读页被写入,触发 page fault → 内核接管 → 这一刻才真的复制那一页(COW = Copy on Write):

fork 后: 父进程虚拟页 A ──┐ ├──→ 物理页 X(只读) 子进程虚拟页 A ──┘ 父进程写 A: 父进程虚拟页 A ──→ 物理页 X'(新分配的副本,可写) 子进程虚拟页 A ──→ 物理页 X (还是原来的)

这就是"按需分裂"——只有真的修改的页才会复制,没改的页一辈子共享。Redis 的 RDB 持久化就靠这个:fork 出来的子进程慢慢把内存写入磁盘,主进程继续服务,大多数页其实没被改,所以两边大部分内存共享。

COW 的隐藏代价

但 COW 有一个反直觉的坑:fork 后父进程写得越多,物理内存涨得越快。Redis 文档里那个"fork 时主进程内存可能翻倍"的警告就是这个原因——极端情况下父子修改的页都不一样,物理占用真的会翻一倍。生产 Redis 实例做 RDB 的瞬间监控内存峰值,就是为了防 COW 触发 OOM。

4. mmap 完全解析:文件映射 vs 匿名映射

昨天讲 brk vs mmap 是从"用户态拿堆内存的两条路"的角度。今天换角度——mmap 本质是"把一段虚拟地址范围和某个数据源绑定"。数据源不同,得到不同的能力。

两大类四小种

类型flags数据源典型用途
匿名 + 私有MAP_ANONYMOUS | MAP_PRIVATE无(清零)malloc 大块、栈、堆扩展
匿名 + 共享MAP_ANONYMOUS | MAP_SHARED无(清零)父子进程共享内存、posix shm
文件 + 私有MAP_PRIVATE + fd文件动态库加载(.so)、COW 文件
文件 + 共享MAP_SHARED + fd文件多进程共享数据、零拷贝 IO、safetensors 加载

mmap 加载大文件 vs 传统 read

# 传统方式
fd = open("weights.bin", os.O_RDONLY)
buf = os.read(fd, 10_000_000_000)   # 10GB 全部读到用户态 buffer
# → 一次性 10GB 拷贝、巨大 RSS、内核 page cache 也复制一份

# mmap 方式
import mmap
with open("weights.bin", "rb") as f:
    mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
    # 虚拟空间分了 10GB,但物理内存 0 字节
    chunk = mm[100_000_000:100_001_000]
    # 这一刻才 page fault → 内核从磁盘读这 1KB 所在的页 → 上挂

mmap 的好处:

这就是为什么 safetensors / GGUF / 大模型权重格式都强烈推荐 mmap 加载——同机多个 worker 进程共享同一份权重的物理内存,70B 模型只用一份 RAM 就能跑 4 个并行 worker。

陷阱

mmap 的隐藏代价:随机访问 mmap 文件时,每一页第一次访问都是 major fault,磁盘 I/O 是隐藏在普通内存读后面的。对热点不明显的数据,read() 一次性读完反而总延迟更低。判断原则:顺序读 + 大文件 + 想共享 → mmap;随机读 + 小文件 → read

5. swap:物理内存的"假肢"

当物理内存吃紧,内核会把一些看起来不太热的物理页写到磁盘上的 swap 分区/文件,腾出物理页给当下的请求。这页被换出的进程对此完全无感——它的虚拟地址还是那个,但 PTE 改成了"在 swap 里第 N 块"。等它下次访问 → major fault → 内核从 swap 读回来 → 重新挂物理页。

关键指标 swappiness

# 查当前值(默认 60,范围 0~200)
cat /proc/sys/vm/swappiness

# 数字越大 = 内核越倾向于把匿名页换出去(即使 page cache 还有空间可回收)
# 数字越小 = 优先回收 page cache,尽量不动匿名页

# 数据库/低延迟服务通常调小
sudo sysctl -w vm.swappiness=10

服务延迟敏感的,几乎都把 swappiness 调到 1~10 甚至完全禁用 swap。原因:一旦你的进程的活跃工作集被换到 swap,每次访问都触发 major fault,延迟从纳秒级跳到毫秒级,相当于慢 10000 倍。

K8s 与 swap 的恩怨

K8s 历史上要求 kubelet 关闭 swap,否则启动报错——因为 swap 会让 cgroup 的内存计量变得不准、QoS 模型失效。1.22 之后开始有限支持,但生产 LLM 推理集群依然推荐 swapoff -a 完全禁用。这是你部署 vLLM 时 90% 的教程都会让你执行的那条神秘命令的真正原因。

看 swap 用量

# 全局
free -h
# 看 Swap: 行的 used 是否大于 0、是否在涨

# 单进程
cat /proc/<PID>/status | grep -E "VmSwap|VmRSS"

# 哪些进程 swap 用得最多
for f in /proc/[0-9]*/status; do
    awk '/VmSwap|Name/{printf "%s ",$2}END{print ""}' $f
done | sort -k2 -h -r | head

6. page cache:你以为内存满了,其实是缓存

新手看到 free -h 显示 used 60GB / free 2GB 立刻紧张:"要 OOM 了!"——其实大部分情况下是 page cache 占走了,不算真的"用掉"

什么是 page cache

每次你读文件(read 或 mmap),内核都把磁盘上对应的块缓存在内存里。下次同样的读会直接命中内存,不去磁盘。这部分内存就叫 page cache。它属于内核,没"归属"任何进程,所以 ps 看进程 RSS 是看不到的。

page cache 的设计哲学是"反正空着也是空着,不如缓存"——所以它会自然涨到吃光"空闲"内存。但它可被瞬间回收:内核需要内存做别的事时,丢掉 page cache 即可。所以 page cache 大不是问题,是好事。

正确读 free -h

$ free -h
              total        used        free      shared  buff/cache   available
Mem:           64Gi        12Gi       2.0Gi       500Mi        50Gi        51Gi
Swap:          0B          0B         0B

这台机器看起来很紧张,但关键看最后一列 available——51GB。这才是"如果新进程要内存,内核能立刻腾出来给你的量"。buff/cache 是可回收的(page cache + slab 中的可回收部分),不算真占用。

记住两条铁律:(1) 只看 free 列会误判,永远看 available;(2) Linux 不存在"内存白白闲置"——闲着也会被 page cache 占满,这是正确行为。如果 available 在持续下降才该警觉。

主动清掉 page cache(调试用,生产慎用)

# sync 把脏页刷盘,否则 drop 会丢数据
sync

# 1=丢 page cache;2=丢 dentry/inode;3=都丢
echo 3 | sudo tee /proc/sys/vm/drop_caches

什么时候用?性能基准测试要消除缓存影响时;或者怀疑某个文件被缓存的旧版本污染时。生产环境绝不要执行——你会让所有读操作变成磁盘 I/O,延迟瞬间起飞。

7. madvise / mlock:把内存当资源精细操作

到这里你已经知道内核做了很多"自动决策"——什么时候 page fault、什么时候 swap、什么时候 page cache。但内核不知道你对未来访问模式的预期madvisemlock 就是你给内核的"友情提示"。

madvise:告诉内核你打算怎么用

建议含义典型用途
MADV_SEQUENTIAL我会顺序读预读更激进,读完即丢
MADV_RANDOM我会随机读关闭预读,省内存
MADV_WILLNEED很快要用内核预先把页加载进 cache
MADV_DONTNEED不再需要立即释放物理页(但保留映射)
MADV_HUGEPAGE试着用大页提升 TLB 命中
// C 示例:mmap 大文件,告诉内核我要顺序读
void *addr = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
madvise(addr, size, MADV_SEQUENTIAL);   // 内核预读更激进
madvise(addr, size, MADV_WILLNEED);     // 立刻把热门部分拉进来
# Python 也有
import mmap, os
with open("weights.bin", "rb") as f:
    mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
    mm.madvise(mmap.MADV_SEQUENTIAL)
    # 现在大块顺序读会快很多

mlock:把页钉在物理内存里

mlock(addr, size) 告诉内核"这段虚拟内存永远不许被换出去"。即使系统再缺内存、即使 swappiness 调到 200,这块也碰不到 swap。

# 锁定一段地址
mlock(secret_key_buffer, KEY_SIZE);
# ... 关键操作 ...
munlock(secret_key_buffer, KEY_SIZE);

// 锁整个进程的所有页
mlockall(MCL_CURRENT | MCL_FUTURE);

三个最常见的 mlock 场景:

mlock 的代价:被 mlock 的内存就是真实物理占用——OS 失去回收弹性。如果你 mlock 了 100GB 但机器只有 128GB,留给其他人的就 28GB,挤别人去 swap 或 OOM。所以 mlock 一定要配合限额(ulimit -l 或 cgroup memory.max)使用。

查看一个进程哪些页被锁了

cat /proc/<PID>/status | grep -E "VmLck|VmRSS|VmSwap"
# VmLck = 被 mlock 的字节数

🔗 与 AI / Agent 安全的连结

1. 大模型权重的 mmap 共享是攻击面也是优化点。生产 LLM 推理服务为了节省内存,会让多个 worker 进程 mmap 同一个 safetensors 文件——物理内存只一份。但这意味着任何能写这个文件的人,等于改了所有 worker 正在运行的模型权重。运行时检测:把权重文件挂只读、加 dm-verity / IMA 完整性度量、定期对内存里的权重做 hash 校验。Agent 安全产品做模型完整性监控时,必须理解 mmap 的语义,否则会被悄悄换权重而不知道。

2. 密钥与 prompt 不应该落到 swap。你的 Agent 系统拿到用户的 API key、企业内网凭证、敏感 system prompt 后,如果存在普通堆里,进程长跑遇到内存压力可能被换到磁盘——磁盘上的 swap 不加密的话,任何能读 swap 分区的攻击者都能取回这些秘密。最佳实践:(a) 关键 buffer 用 mlock;(b) 所在节点强制开 swap 加密(dm-crypt + swap)或禁用 swap;(c) 用完立刻 memset 清零再 free。这是底层 hardening,Python 层做不了,要走 C 扩展或 cffi。

3. Page fault 是侧信道。fault 数量、fault 时机会泄露内存访问模式。学术界已有用 page fault 序列恢复 SGX 飞地内部秘密的攻击。对 Agent 来说更现实的场景:通过观察推理服务的 page fault rate 推断当前正在处理的 prompt 长度 / 是否命中 KV cache prefix。共享租户环境里这种侧信道是真实威胁。

4. fork + COW 是 LLM 推理服务初始化的标准模式。vLLM、HuggingFace TGI 都靠"一个父进程加载完模型 → fork 出 N 个 worker"来快速启动多 worker——靠 COW 让所有 worker 共享只读权重的物理内存。但如果有 worker 偶然写了一下"看起来是只读"的权重区(比如 bug、比如恶意输入触发越界写),COW 会悄悄复制那一页,物理内存翻倍。监控这种意外 COW 是 LLM 服务内存暴涨的常见 root cause。

5. OOM、page cache、swap 共同决定了"DoS 多难"。攻击者要打垮你的推理服务,最便宜的不是直接打到 OOM,而是逼内核疯狂换页——发一批引起大量 host 内存分配(长 prompt、大 batch、复杂 tokenizer 分词)的请求,让活跃工作集超过物理内存,内核开始把权重页换出去,下一次请求又把它换回来。这就是thrashing(抖动)。表象不是 OOM,而是 P99 延迟暴涨 100 倍、服务"还活着"但完全不可用。监控指标加上 pgmajfault 速率(vmstat 1 里的 si/so 列),比单纯看 RSS 更早发现这种攻击。

📝 今日小练习

115 分钟 · 动手:观察 demand paging 与 major fault

下面这段 Python 直观地展示了 malloc 后内存不算占用、touch 后才占用:

cat > demand.py <<'EOF'
import os, time, ctypes
PID = os.getpid()

def rss():
    with open(f"/proc/{PID}/status") as f:
        for line in f:
            if line.startswith("VmRSS"):
                return line.split()[1] + " kB"

print("起始 RSS:", rss())

# 申请 1GB 但不 touch
buf = ctypes.create_string_buffer(1024 * 1024 * 1024)
print("申请 1GB 后 RSS:", rss())  # 几乎不涨

# 真的 touch 每一页
for i in range(0, 1024 * 1024 * 1024, 4096):
    buf[i] = 1
print("touch 后 RSS:", rss())  # 涨到 ~1GB
EOF

# 跑两次,第二次用 /usr/bin/time -v 看 major/minor fault
python3 demand.py
/usr/bin/time -v python3 demand.py 2>&1 | grep -E "Maximum resident|Minor|Major"

观察:申请的瞬间 RSS 几乎不变,逐页 touch 后才上涨;minor fault 应该有几十万次,major fault 应该接近 0(数据不在磁盘上)。然后试试用一个 mmap 文件替代 malloc,看 major fault 数量。

210 分钟 · 观察:page cache 的真容

验证"内存看起来满了但其实是 page cache":

# 起始状态
free -h

# 制造一个大文件并读一遍(让它进 page cache)
dd if=/dev/zero of=/tmp/big.bin bs=1M count=2048
cat /tmp/big.bin > /dev/null

# 再看 free -h,buff/cache 涨了 ~2GB,但 available 几乎没降
free -h

# 验证 page cache 可被瞬间回收
sync && echo 1 | sudo tee /proc/sys/vm/drop_caches
free -h   # buff/cache 大幅下降,available 不变(说明它"不算真占用")

# 清理
rm /tmp/big.bin

问题:(a) 第二次读 cat /tmp/big.bin > /dev/null 比第一次快很多,为什么?(b) 如果你在生产服务器上看到 buff/cache 占了 80% 物理内存,你慌不慌?

35 分钟 · 思考:mlock 的取舍

你部署一个 LLM Agent 网关,host 内存 128GB,关键组件:

你考虑给哪些组件加 mlock?(a) 全 mlock 有什么风险?(b) 只 mlock session 缓存(保护密钥不进 swap)但 swap 关了的话,mlock 还有意义吗?(c) 如果这台机器同时跑推理和 KV cache 服务,应该禁用 swap 还是开 swap 但限制 swappiness?写一份你的决策和理由。