fork() 一个吃了 30GB 的 Python 进程几乎瞬间返回?为什么 cat /proc/meminfo 显示 free 只剩 2GB 但你的服务还能正常分配大块内存?为什么 top 里那个 30GB 大进程的 RES 字段实际只有 6GB?今天我们把昨天讲的"分配"换一个视角,从"内核如何让有限的物理内存看起来无限"切入:page fault 三类 → COW → mmap → swap → page cache → madvise/mlock。这六个概念串起来,你才能真正读懂 Linux 内存监控指标,而不再被 free -h 的数字吓到。
昨天我们站在"分配器"的视角,看 buddy/slab/ptmalloc 怎么把内存切给你。但有一个关键事实昨天没明说——malloc 返回成功,不等于物理内存已经被占用。Linux 默认是 overcommit(超额承诺)的:你 malloc 10GB,内核可能根本没分一页物理内存,只是在你的虚拟地址空间里画了一块"账目上属于你"的区域。
真正占用物理页发生在你第一次去读或写这块虚拟内存的瞬间。这个瞬间触发的硬件信号就是page fault(缺页异常),CPU 通知内核:"这个虚拟地址我查页表没找到映射,你处理一下。"内核要么补一页物理内存挂上去,要么发 SIGSEGV 把你杀掉。
这套机制叫 demand paging(按需分页)——用到才真分。它是 Linux 内存魔法的基石,下面所有概念都建立在这之上。
page fault 不是一个事件,是三种完全不同性格的事件,分清楚它们是读懂内存监控的第一道门。
页表里没映射,但要的数据不在磁盘上——典型场景是首次访问 malloc 出来的匿名页(内核分一页填零)、或者多个进程共享同一物理页(比如 libc,只需要把现有物理页的 PTE 复制一份)。不涉及磁盘 I/O,纯 CPU 开销,几百纳秒级。
页表里没映射,而且需要从磁盘读。两个典型来源:
major fault 涉及一次真实磁盘 I/O,开销是 minor fault 的 1000~10000 倍。在 NVMe 上一次约 100μs,在机械盘上是 10ms 级。LLM 服务突然 P99 暴涨,多半就是开始大量 major fault 了。
访问的地址压根不在进程的虚拟地址空间合法区域里——典型就是空指针解引用、越界访问、释放后再用。内核发 SIGSEGV,进程死亡。也就是你熟悉的 segfault。
# 查任意进程的 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 里——两种情况都意味着延迟暴涨。
昨天讲 fork() 时一句话带过了 COW,今天展开。问题是:父进程吃了 30GB 内存,fork() 出子进程,难道要复制 30GB?如果真这样,Redis 做持久化时 fork 一次就要几十秒,根本不能用。
fork() 时内核做的是:
task_struct、新页表。所以 fork 的代价 ≈ 页表复制(与虚拟空间大小成正比,而不是物理内存),几十毫秒。30GB 进程的页表大约 60MB(按 8 字节 PTE × 1000 万页算),复制这个量级完全 ok。
父或子任意一方第一次写某一页时,CPU 检测到只读页被写入,触发 page fault → 内核接管 → 这一刻才真的复制那一页(COW = Copy on Write):
这就是"按需分裂"——只有真的修改的页才会复制,没改的页一辈子共享。Redis 的 RDB 持久化就靠这个:fork 出来的子进程慢慢把内存写入磁盘,主进程继续服务,大多数页其实没被改,所以两边大部分内存共享。
但 COW 有一个反直觉的坑:fork 后父进程写得越多,物理内存涨得越快。Redis 文档里那个"fork 时主进程内存可能翻倍"的警告就是这个原因——极端情况下父子修改的页都不一样,物理占用真的会翻一倍。生产 Redis 实例做 RDB 的瞬间监控内存峰值,就是为了防 COW 触发 OOM。
昨天讲 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 加载 |
# 传统方式
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。
read() 一次性读完反而总延迟更低。判断原则:顺序读 + 大文件 + 想共享 → mmap;随机读 + 小文件 → read。
当物理内存吃紧,内核会把一些看起来不太热的物理页写到磁盘上的 swap 分区/文件,腾出物理页给当下的请求。这页被换出的进程对此完全无感——它的虚拟地址还是那个,但 PTE 改成了"在 swap 里第 N 块"。等它下次访问 → major fault → 内核从 swap 读回来 → 重新挂物理页。
# 查当前值(默认 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 历史上要求 kubelet 关闭 swap,否则启动报错——因为 swap 会让 cgroup 的内存计量变得不准、QoS 模型失效。1.22 之后开始有限支持,但生产 LLM 推理集群依然推荐 swapoff -a 完全禁用。这是你部署 vLLM 时 90% 的教程都会让你执行的那条神秘命令的真正原因。
# 全局
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
新手看到 free -h 显示 used 60GB / free 2GB 立刻紧张:"要 OOM 了!"——其实大部分情况下是 page cache 占走了,不算真的"用掉"。
每次你读文件(read 或 mmap),内核都把磁盘上对应的块缓存在内存里。下次同样的读会直接命中内存,不去磁盘。这部分内存就叫 page cache。它属于内核,没"归属"任何进程,所以 ps 看进程 RSS 是看不到的。
page cache 的设计哲学是"反正空着也是空着,不如缓存"——所以它会自然涨到吃光"空闲"内存。但它可被瞬间回收:内核需要内存做别的事时,丢掉 page cache 即可。所以 page cache 大不是问题,是好事。
$ 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 中的可回收部分),不算真占用。
free 列会误判,永远看 available;(2) Linux 不存在"内存白白闲置"——闲着也会被 page cache 占满,这是正确行为。如果 available 在持续下降才该警觉。
# sync 把脏页刷盘,否则 drop 会丢数据
sync
# 1=丢 page cache;2=丢 dentry/inode;3=都丢
echo 3 | sudo tee /proc/sys/vm/drop_caches
什么时候用?性能基准测试要消除缓存影响时;或者怀疑某个文件被缓存的旧版本污染时。生产环境绝不要执行——你会让所有读操作变成磁盘 I/O,延迟瞬间起飞。
到这里你已经知道内核做了很多"自动决策"——什么时候 page fault、什么时候 swap、什么时候 page cache。但内核不知道你对未来访问模式的预期。madvise 和 mlock 就是你给内核的"友情提示"。
| 建议 | 含义 | 典型用途 |
|---|---|---|
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(addr, size) 告诉内核"这段虚拟内存永远不许被换出去"。即使系统再缺内存、即使 swappiness 调到 200,这块也碰不到 swap。
# 锁定一段地址
mlock(secret_key_buffer, KEY_SIZE);
# ... 关键操作 ...
munlock(secret_key_buffer, KEY_SIZE);
// 锁整个进程的所有页
mlockall(MCL_CURRENT | MCL_FUTURE);
三个最常见的 mlock 场景:
cat /proc/<PID>/status | grep -E "VmLck|VmRSS|VmSwap"
# VmLck = 被 mlock 的字节数
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?写一份你的决策和理由。