yuqi-zheng

虚拟内存与延迟:低延迟系统的实用指南


虚拟内存是”看不见的”,直到它显现出来。对大多数应用而言,page fault 和 TLB miss 只是启动期付一次的后台税。但对延迟敏感的系统 —— 高频交易、实时音频、控制系统 —— 它们会以”不可预测的尖峰”形式出现,打破尾部延迟的要求。本指南覆盖每一个来源,以及如何消除它。


简短清单

  • 减少 page fault:预 fault、锁内存、禁用 swap。
  • 减少 TLB miss:压缩工作集大小,使用大页。
  • 避免 TLB shootdown:启动后不修改页表。
  • 避免可写的文件映射,以免触发 page cache 回写停顿。
  • 关闭透明大页(THP)—— 后台合并守护进程会引入延迟尖峰。
  • 关闭 KSM—— 内核同页合并会锁页表。
  • 关闭自动 NUMA 平衡—— 页迁移会触发 TLB shootdown。

Page Fault

内核把物理内存分配推迟到页面被访问时(按需分页)。从延迟角度看,两类 fault 重要:

Major fault:所需页不在 RAM 里,必须从磁盘读入。代价与一次 read 系统调用相当 —— 数百微秒到毫秒。

Minor fault:页在 RAM(或 page cache)里,但页表项尚未建立。内核更新 PTE 然后返回。这仍然要进内核,在多线程程序中可能与页表锁争用。

匿名内存(来自 mallocmmap(MAP_ANONYMOUS))在首次写入时会发生 minor fault:内核分配一页用 0 填充,并把它连上。

消除 Page Fault

在热路径之前把所有内存预 fault

// mmap 或 malloc 后,触碰每一页以建立映射
char* mem = (char*)mmap(nullptr, size, PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS | MAP_POPULATE, -1, 0);
// MAP_POPULATE 在 mmap 过程中就触发立即映射

或者用 mlock(),既把页锁在 RAM,也触发预 fault:

mlock(ptr, size);  // 防止被换出并预 fault

系统级禁用 swap,防止任何匿名页被逐出:

swapoff -a

关闭自动 NUMA 平衡,它会迁移页并造成额外 fault:

echo 0 > /proc/sys/kernel/numa_balancing

监控

perf stat -e faults,minor-faults,major-faults ./program

TLB Miss

TLB 缓存虚拟地址到物理地址的转换。它容量有限 —— 通常 L1 64 项(4 KiB 页),L2 1536 项(混合尺寸)。未命中需要一次硬件 page table walk,代价数十纳秒。

减少 TLB 压力

压缩工作集。 热点结构所占页数越少,留给其他数据的 TLB 容量就越多。偏好紧凑、稠密的布局,少用指针跳转。

使用大页。 一条 2 MiB 的 TLB 项覆盖的地址空间等于 512 条标准 4 KiB 项。对于大数组和缓冲区,这会大幅降低 miss 率。

void* p = mmap(nullptr, size, PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);

监控

perf stat -e dTLB-loads,dTLB-load-misses ./program

如果 dTLB-load-misses / dTLB-loads 超过 5%,大页会有帮助。


TLB Shootdown

CPU 之间并不维护 TLB 一致性。当内核修改页表(权限变更、unmap)时,它必须向每个其他核发送处理器间中断(IPI),以使它们的过期 TLB 项失效。这就是 TLB shootdown

接收中断的核必须中断当前工作、进入内核、flush 相关 TLB 项、再返回。对实时工作负载而言,这是一次不可预测的中断。

哪些事会触发 shootdown

  • munmapmprotect(显式)
  • glibc 中的 free(),它内部可能调用 madvise(MADV_FREE)munmap
  • 透明大页(khugepaged 的后台合并)
  • 内核同页合并(KSM)
  • 自动 NUMA 平衡(页迁移)
  • 内存合并(kcompactd

消除 shootdown

在启动时把所有需要的内存映射好、然后永不 unmap。这要么需要一个不会把内存还给操作系统的自定义 allocator,要么需要一个生命周期长的内存池。

对 allocator 来说,mimalloc 可以配置为不还内存:

MIMALLOC_RESERVE_HUGE_OS_PAGES=4
MIMALLOC_PAGE_RESET=0

监控

egrep 'TLB|CPU' /proc/interrupts

Page Cache 回写停顿

可写的文件映射(对普通文件以 PROT_WRITEmmap)引入了一个微妙的坑:当内核把脏页刷回磁盘时,会把这些页暂时标记为只读。如果应用在这个窗口内写入这样的页,内核必须处理一次 page fault 并等待磁盘 I/O 才能让写入进行。

在一台装 NVMe SSD 的 Ryzen 3900X 上,这能产生高达 777 微秒 的延迟尖峰 —— 在平均基准测试里看不见,但对尾延迟是灾难性的。

修复办法: 不要在热路径中使用可写的文件映射。使用 MAP_ANONYMOUS,或把文件放在 tmpfs / hugetlbfs(没有磁盘后备、也没有回写)。

审核现有映射:

cat /proc/self/maps | grep '.w.s'

任何同时带 w(可写)和 s(共享,即文件映射)的条目都是潜在的回写危险。


透明大页(THP)

THP 会自动把一段标准页提升为 2 MiB 大页。虽然这减少了 TLB miss,但后台守护进程 khugepaged 会扫描内存并做提升,这需要修改页表并触发 TLB shootdown。kcompactd 会在连续物理内存不足时运行。

这些后台动作表现为没有明显用户代码诱因的随机延迟尖峰。

关闭 THP:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

然后在真正受益于大页的地方使用显式 MAP_HUGETLB 分配。


NUMA

在多插槽系统上,内存延迟取决于物理页属于哪个 NUMA 节点。Linux 的自动 NUMA 平衡会把页迁移到访问它的核那边 —— 但迁移涉及页表修改和 TLB shootdown。

在分配时显式把内存固定到正确的 NUMA 节点:

numactl --membind=0 --cpunodebind=0 ./program

或在代码里用 set_mempolicy / mbind。关闭自动平衡:

echo 0 > /proc/sys/kernel/numa_balancing

参考

  • Ulrich Drepper, “What Every Programmer Should Know About Memory”(2007)
  • Linux 内核文档:Documentation/vm/
  • Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A, Chapter 4