缺页并不总是意味着读盘
把两类缺页一概当作慢磁盘看待会混淆差别;对内存大户调优时,‘次要还是主要’这个区分才是关键。 次缺页只用映射 缺页也分主次:缺页时如果页面其实已经常驻内存,只是进程还没有把这张页表映射,就只需补一张页表——把虚拟地址指到那块已存在的物理内存即可。这也解释了为什么「新开一页」这类动作是便宜的:它不过是在页表上多一条指针,不读磁盘、不耗内存。首次接触刚归零的区域、共享库被别的进程先行加载、或一次从 swap 换入还没有短缺时,走的都是这条路。缺页省下的成本几近于零,也几乎不构成性能问题。 主缺页要读磁盘 主缺页就完全不一样了。它意味着所需数据必须落地到磁盘(或从 swap 读出来),这一步通常几十到几百微秒、甚至毫秒级,比次缺页慢好几个量级。进程冷启动、首次读入大二进制、或又要从 swap 里找回被换出的页面,最常见的表现就是一大串主缺页。两者的差别,主要是「这数据是不是已经在本地」——主缺页多,说明缓存与前读没有把你要的那片东西预先备好。 分开统计才有意义 工具把一个数字拆成两个,不是没事找事,而是为了分清病因。ps、sar、/proc/self/stat 都会分别报 minor/major。一个健康的服务器应该是「次缺页常年不断、主缺页几乎为零」。看的是比例而非总数:主缺页密集、次缺页稀疏,多半是缓存未命中与冷启动;次缺页猛增却没什么主缺页,往往是在页面表上抖动,而不是磁盘紧张。把两者分开看,一旦主缺页冒头,第一反应是补缓存与预读,而不是去怪磁盘。 坏掉的样子 主缺页风暴看起来像延迟毛刺,本质却是缓存未命中。每次发布、容器重启或新节点上线后,所有工作进程会同时第一次触碰二进制、共享库和数据页。这时用 perf stat -e minor-faults,major-faults 观察,会看到主缺页从接近于零突然涨到每秒数千,而处理器利用率反而很低,尾延迟被拉长到数百毫秒。此时加核或加线程没有意义,正确做法是预热工作集、保持镜像常驻,或者错开重启节奏,避免所有进程同时挤兑同一批页面,否则毛刺会随实例数放大。 交换分区会让同样的信号产生共振。当常驻工作集超过物理内存,内核会把不常用的页换出到交换设备,下次访问又要从磁盘换回来。主缺页随换入一起上涨,负载攀升,每个请求都要替磁盘等待,即便代码一行没改。有人会加线程扛延迟,结果工作集更大,抖动更严重,甚至进入换入换出互相等待的恶性循环。此时应该先算内存账:让热数据真正装进内存,或者把冷数据搬出请求主路径,而不是提高并发。 第三类真实失败是误读 mmap 大文件顺序读。顺序扫描一个文件映射,第一次触碰每一页同样会缺页,主缺页很多并不代表随机读坏了,也不代表存储变慢。把所有缺页都当成磁盘寻道,就会去调预读或怪硬盘,真正该问的是这些页当初该不该映射进来。改输入输出参数之前,先用 mincore 确认常驻情况,再决定是预读、缓存还是改访问方式,避免把映射策略问题误判成存储故障。 快速验证 把下面程序保存为 fault_probe.py,运行 python3 fault_probe.py 即可。它只用标准库,不需要特权,在常见发行版和苹果系统上都能直接运行。程序用 resource.getrusage 读取进程的次缺页与主缺页计数,在全新匿名映射上做两轮逐页触碰,并用 time.perf_counter 记录每轮耗时,前后对比即可区分两类缺页,也能直观看出映射建立前后的时间差异。 import mmap import resource import time PAGE = 4096 PAGES = 20000 # 约八十兆:足够看出差值,又不会给持续集成带来负担 def counters(): usage = resource.getrusage(resource.RUSAGE_SELF) return usage.ru_minflt, usage.ru_majflt def touch(mapping): for offset in range(0, len(mapping), PAGE): mapping[offset] = 1 def main(): mapping = mmap.mmap(-1, PAGES * PAGE) base_min, base_maj = counters() start = time.perf_counter() touch(mapping) first_time = time.perf_counter() - start mid_min, mid_maj = counters() start = time.perf_counter() touch(mapping) # 此时页面已经映射好 second_time = time.perf_counter() - start end_min, end_maj = counters() mapping.close() first_min = mid_min - base_min first_maj = mid_maj - base_maj second_min = end_min - mid_min second_maj = end_maj - mid_maj print(f"first touch: minflt={first_min} majflt={first_maj} time={first_time:.4f}s") print(f"second touch: minflt={second_min} majflt={second_maj} time={second_time:.4f}s") assert first_min > 1000, f"expected thousands of minor faults, got {first_min}" assert first_maj == 0, f"anonymous first touch should not cause majors, got {first_maj}" assert second_min < first_min // 10, f"mapped pages should barely fault: {second_min} vs {first_min}" assert second_maj == 0, f"unexpected major faults on second pass: {second_maj}" print("PASS: first touch faults as minors without disk reads; second touch is almost fault-free") if __name__ == "__main__": main() 第一轮逐页写会触发大量次缺页,预期次缺页增加数千,主缺页几乎不变,而且耗时明显更长。第二轮访问已经映射好的页面,预期新增缺页接近于零,耗时也显著缩短。这个反差正是结论:匿名内存第一次触碰只产生次缺页,不需要读盘,第二次访问已经不需要建立映射。 ...