AI 资讯 知识库 操作系统内功操作系统内功 · 第 2 篇|虚拟内存:页表、TLB 与一次缺页中断的旅程

操作系统内功 · 第 2 篇|虚拟内存:页表、TLB 与一次缺页中断的旅程

先看两个现场。第一个:服务申请了 10GB 内存,ps 里 VSZ 显示 10GB、RSS 却只有 2GB,没人能说清另外 8GB 在哪。第二个:一台 64GB 的机器 mmap 了一个 500GB 的文件,程序跑得好好的。两个现象背后是同一台机器在运转——虚拟内存。它是操作系统最成功的一层「谎言」:每个进程都以为自己独占整块连续内存,而内核负责在缺页中断的时刻把谎言一点点兑现成物理页。本篇把这层谎言拆开:页表的层级与开销、一次缺页的完整旅程、TLB 的经济学,以及数据库社区与 THP 缠斗二十年的恩怨。

一、地址空间:每个进程都以为自己独占内存

x86-64 上,一个进程的虚拟地址空间是 48 位有效寻址、共 256 TiB——用户态与内核态各占一半,所以单个进程可用的用户空间是 128 TiB。这么大的空间显然不可能真的分配,内核的办法是把地址切成 4KB 的页,用多级页表记录「虚拟页 → 物理页」的映射。四级页表的层级是 PGD → PUD → PMD → PTE,每级表本身只占 4KB、装 512 个条目:查一个地址要从根表开始逐级索引,像查四层楼牌号。多级结构的妙处在于稀疏——没用到的地址区间对应的那一页表根本不用建,这就是 256 TiB 空间可以「纸上谈兵」的原因。

大页是层级里的「抄近路」通道:2MB 大页由 PMD 直接映射(跳过 PTE 层),1GB 大页由 PUD 直接映射。抄近路省的是页表结构和 TLB 条目,代价是分配粒度变粗、内存整理更难——这个伏笔在第四节引爆。PTE 本身也远不止一个页号:它还带着 present(在不在内存)、读写位、NX(不可执行位)、accessed 与 dirty 位等标志——上一篇的 CoW 靠读写位实现「先只读、后补页」,脏页回写靠 dirty 位,数据页防溢出攻击靠 NX 位。页表的每个位都对应着内核的一桩大生意。顺便把硬件的未来交代了:五级页表(LA57)把层级再加一层,虚拟空间扩展到 128 PiB、物理空间 4 PiB,内核在 4.14(2017 年 11 月)合入支持,硬件自 Intel Ice Lake-SP 起提供。对今天的应用而言 LA57 还远,但它的存在说明:地址空间这个「谎言」还会越讲越大。

二、缺页中断:谎言如何一步步兑现

malloc 返回的那一刻,内核几乎什么都没做:只是在虚拟地址空间里登记了一段区间(小的走 brk 扩堆,大的直接 mmap)。谎言的兑现发生在你第一次触碰这块内存时——CPU 查页表发现映射不存在,触发缺页中断,内核接管:分配一个物理页(匿名内存顺便清零,早期干脆先映射到共享的只读零页)、填好页表项、把指令重执行一遍。整个过程对应用完全透明,malloc 之后 memset 才真正付钱,这就是 VSZ 与 RSS 出现巨大落差的根源——RSS 统计的是「兑现过的」物理页。上一篇 Redis fork 后 RSS 偏低再逐渐上涨,也是同一个机制:CoW 页要等真正写入才计入。

缺页机制甚至决定了程序的启动方式:ELF 可执行文件是按需加载的——程序映像被映射进地址空间,代码页在第一次执行时才从磁盘调入(major fault),动态库同理。这意味着「启动时 RSS 很小、随执行路径逐渐长满」是常态而非泄漏,也意味着冷启动的 P99 里埋着大量 major fault——所谓预热(warmup),本质是主动把缺页的债提前还掉。一个常被引用的细节是 calloc 比 malloc 加 memset 快:内核为匿名读准备了共享的只读零页,未写入的部分全部映射到它,写入时才触发 CoW 换成真页——.bss 段与未触碰的堆,本质上都躺在零页上。

缺页分两档。minor fault 是内核还能就地解决的——分配一个空闲物理页、或把已在页缓存的文件页挂进页表,纯内存操作,微秒级;major fault 要真去磁盘读——文件映射(mmap)第一次访问、或页被回收后再次访问,成本是毫秒级磁盘 IO,差了三个数量级。监控里如果 major fault 频繁出现,说明你的工作集已经在和内核的页回收机制打架。mmap 本身是把双刃剑:随机访问索引类场景(Kafka 的索引文件、LMDB 这类 mmap 数据库)省掉一次用户态缓冲拷贝,非常划算;但文件被并发截断时访问映射区会收到 SIGBUS,而且错误以信号而非返回码出现——错误处理的复杂度经常超过省下的那点拷贝。映射还分共享与私有两族:MAP_SHARED 的写入同步回文件、多进程映射同一文件即是共享内存(System V 之外的轻量 IPC);MAP_PRIVATE 则复制到私有副本(CoW),改了不回写——父子进程能共享只读段靠的就是它,语言运行时的写时复制快照同样基于此。

三、页表的经济学:TLB 是唯一解药

多级页表的代价在查表本身:四级页表最坏情况要 4 次内存访问才能翻译一个地址——每读一个数,先多读四次。解药是 TLB(翻译后备缓冲):一块缓存「最近用过的虚拟页号 → 物理页号」的小硬件表,命中则零成本翻译。TLB 本身也分层的:L1 dTLB 通常只有几十到上百条,L2 级 TLB 上千条——对动辄几十 GB 的工作集而言杯水车薪,这也解释了为什么大页在数据库场景从「优化」变成「必需品」。程序的空间局部性越好,TLB 命中率越高;而 TLB 单条只能覆盖 4KB,于是大页的经济学登场。算一笔账:用 4KB 页映射 1GB 内存,需要 512 张叶子页表、合计 2MB 页表结构;用 2MB 大页映射同样的 1GB,页表只要 4KB——结构开销差 512 倍,TLB 里 512 条记录压缩成 1 条,覆盖率天壤之别。数据库这类大内存、顺序扫描型负载钟爱大页,根本原因在此。

TLB 还有一个经常被忽略的运行时成本:shootdown(击落)。页表是每个 CPU 各自缓存在 TLB 里的,当你 munmap、mprotect 改了映射,内核必须用处理器间中断把其他所有核的对应 TLB 条目作废——所有核都要停下来处理。频繁调整映射的多线程程序(动态加载、JIT、频繁扩缩的内存池)会在这里产生难以解释的延迟毛刺。上一篇 Meltdown 事件里 DTLB walk 吃掉一半 CPU 周期的惨案,本质也是这个经济学的极端案例:TLB 一旦失守,四级页表的查表成本原形毕露。

四、THP 与 MGLRU:自动化大页的代价与页回收的新算法

透明大页(THP)想自动兑现大页的红利:内核在合适时机把连续 4KB 页合并成 2MB 大页,应用无感。但 kernel.org 官方文档白纸黑字写着风险:enabled=always 时,应用请求 THP 若分配失败,会同步停顿、直接回收内存页并做内存压缩整理——内核停下来现场拼一块连续的 2MB,拼不出来就先搬家再拼。这类停顿正是延迟敏感型数据库的噩梦:DataStax(Cassandra)、Oracle 的官方文档至今明确要求禁用 THP,Hadoop 生态的健康检查见到 THP 开启就告警。工程共识是:要大页就用 hugetlbfs 显式预留(启动时锁定,绝不抖动),把 THP 至少调成 madvise(应用声明才启用)。

页回收侧也在换代。老 LRU 算法在超大内存机器上要花大量时间「找冷页」——链表太长,扫一遍代价高昂。MGLRU(multi-gen LRU,多代 LRU)把页按「代」组织,冷热分层一目了然,由 Google 的 Yu Zhao 主导,随 Linux 6.1(2022 年 12 月)合入主线。官方披露的基准数字:Chrome OS 上 OOM kill 减少 59%,Android 减少 18%,数据中心场景的尾部延迟与 kswapd 行为也有改善。它的启示和 EEVDF 一样:内核最深的这些机制依然在快速进化,升级内核本身就是一项免费的性能优化——前提是你像升级应用一样为它采集前后基线。页回收还要处理匿名页与文件页的分账:文件页回收后可以从文件重读,匿名页要么换入 swap、要么留在内存——没有 swap 的容器里,内存压力最终只能以 OOM 收场,这正是上一篇 OOM 判决书的前置条款。

五、工程落地清单

  1. 分清 VSZ 与 RSS。容量规划、OOM 排查、容器 memory.max 都看 RSS 与增长趋势,VSZ 只说明「登记过」。
  2. 数据库服务器禁 THP 或用 madvise。要大页红利就 hugetlbfs 显式预留,把分配抖动从系统里赶出去。
  3. 看 major fault 而不只是 CPU。vmstat 的 si/so 与缺页率一起看,major fault 频繁说明工作集超出了内存预算。
  4. 热路径减少映射变更。频繁 mmap/munmap/mprotect 的多线程服务改用常驻映射或内存池,避开 TLB shootdown 的隐性中断。
  5. 内核 ≥ 6.1 评估 MGLRU。大内存节点(尤其 512GB+)上收益最明显,灰度对比尾部延迟。
  6. 随机访问大文件用 mmap,顺序流式读交给 read + page cache——后者是下一篇的主题。

结语

虚拟内存是计算机系统里「延迟换容量」哲学的最佳代言:用一次缺页中断的代价,换来 256 TiB 的纸面空间、进程隔离与页缓存共享。它的所有成本都藏在三个词里——页表(结构开销)、缺页(兑现延迟)、TLB(翻译缓存),而大页、THP、MGLRU 三个关键词不过是围绕这三个词的持续再平衡。理解了这层,你就理解了为什么数据库对内核参数如此挑剔。下一篇进入物理内存的属地:伙伴系统、SLUB,以及 glibc 分配器碎片化引发的连环事故。

参考资料

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?