3. 虚拟内存与页缓存机制

零拷贝之所以能成立,前提是内核与硬件之间存在一套“地址翻译 + 缓存”的协作机制。理解虚拟地址映射、页表、缺页异常、页缓存与回写,才能明白为什么 sendfile、splice、mmap 能减少数据搬运,也才能判断在什么场景下它们反而更慢。

一、虚拟地址映射:进程看到的不是物理内存

每个进程拥有独立的虚拟地址空间。CPU 执行访存指令时给出的是虚拟地址(VA),由 MMU(内存管理单元)借助页表翻译为物理地址(PA)。翻译以“页”为粒度,常见页大小 4 KiB,也支持 2 MiB 的大页(HugePage)。

页表是分级的,x86-64 典型为 4 级(PGD→PUD→PMD→PTE),每级 512 项。一次翻译理论上要 4 次内存访问,因此引入 TLB(转译后备缓冲)缓存最近用过的映射。TLB 命中则翻译近乎零开销;TLB 未命中就要走页表遍历(page walk),这是随机访问大内存时性能下降的重要原因之一。

关键点:虚拟地址连续,物理地址未必连续。这决定了 DMA 与零拷贝的实现方式——设备通常需要物理地址或 IOMMU 映射后的地址,而用户态只能提供虚拟地址。

二、页表项与缺页异常

PTE 中除了物理页帧号,还有几个对 I/O 性能至关重要的标志位:

  • Present(P):页是否在内存中。为 0 时访问触发缺页异常。
  • Dirty(D):页是否被写过。回写机制靠它判断哪些页需要落盘。
  • Accessed(A):页是否被访问过,供页回收算法参考。
  • User/Supervisor、Read/Write:权限控制,决定用户态能否直接访问。

缺页异常分三类:

  1. 次缺页(minor fault):页已在页缓存中,只是当前进程页表没有建立映射,只需填 PTE,开销小。
  2. 主缺页(major fault):页不在内存,需要发起磁盘 I/O 读入,开销大,是 mmap 随机读性能波动的根源。
  3. 保护缺页:权限不符,通常导致 SIGSEGV。

对零拷贝而言,mmap 把文件映射进用户地址空间时,并不立即读数据,而是先建立 VMA(虚拟内存区域)。首次访问触发缺页,由内核把文件页读入页缓存并建立映射。因此 mmap 的“快”来自后续访问不再走 read 系统调用,但代价是缺页异常和 TLB 压力。

三、页缓存:内核的磁盘数据中转站

页缓存(page cache)以页为单位缓存文件内容,是 Linux 文件 I/O 的核心。无论 read 还是 write,数据都要经过它:

  • 读路径:read → 查页缓存 → 命中则直接拷贝到用户缓冲区;未命中则从磁盘读入页缓存,再拷贝给用户。
  • 写路径:write → 写入页缓存对应页并置 Dirty → 由回写线程异步刷盘。

这意味着传统 read/write 至少发生两次数据拷贝:磁盘↔页缓存、页缓存↔用户缓冲区。零拷贝要消除的正是后者。

关键结论:页缓存是“以内存换 I/O”的机制。顺序读大文件时,页缓存预读(readahead)能显著提升吞吐;但缓存本身占用内存,且写缓存带来数据一致性风险。

四、回写机制:脏页何时落盘

脏页不会立即写盘,而是由内核回写机制延迟处理,以合并写、提升吞吐。触发回写的条件主要有:

  • 脏页比例超过 vm.dirty_background_ratio(默认约 10%),后台 flusher 线程开始异步回写。
  • 脏页比例超过 vm.dirty_ratio(默认约 20%),写进程被强制同步回写,出现明显卡顿。
  • 脏页存活时间超过 vm.dirty_expire_centisecs(默认 3000,即 30 秒)。
  • 调用 fsync/fdatasync 或 O_SYNC 强制落盘。

可用 /proc/meminfo 观察 Dirty、Writeback 字段,用 vmstat 1 看 bo(块写出)速率。若 Dirty 长期高位且 bo 抖动,说明回写跟不上写入速度,此时零拷贝的收益会被磁盘瓶颈吞掉。

五、这些机制如何影响零拷贝

把上述机制串起来看,零拷贝的底层依赖就清晰了:

  • sendfile:数据从页缓存直接经 DMA 送到网卡,跳过用户态。它依赖页缓存中已有数据页,因此对已缓存文件收益最大。
  • mmap + write:mmap 建立文件页到用户地址空间的映射,write 时内核直接从该映射取页,省去一次拷贝,但仍需页表与缺页配合。
  • splice:借助管道缓冲区在页缓存与 socket 之间移动页引用,不拷贝数据本身,依赖页缓存页可被“借用”。

反过来,若文件未被缓存、访问模式随机、页表压力大,零拷贝可能因主缺页和 TLB miss 而失去优势。工程上应先用 perf stat 观察 page-faults、dTLB-load-misses,再决定是否采用零拷贝路径。

虚拟内存 · 页表 · 页缓存 · 回写 与 I/O 路径 用户态进程 虚拟地址 VA read / write / mmap 用户缓冲区 MMU + TLB VA → PA 翻译 4 级页表遍历 TLB 命中 / miss 页表 PTE P 存在位 · D 脏位 A 访问位 · 权限位 缺页异常触发点 页缓存 Page Cache 以页为单位缓存文件内容 读命中:直接拷贝给用户 写命中:置 Dirty,延迟回写 预读 readahead 提升顺序吞吐 回写 Writeback dirty_background_ratio ≈ 10% dirty_ratio ≈ 20% 触发同步阻塞 dirty_expire_centisecs = 3000 fsync / O_SYNC 强制落盘 块设备 / 磁盘 DMA 传输 脏页 读入 刷盘 零拷贝路径 sendfile / splice / mmap 跳过用户缓冲区拷贝 缺页异常 → 读入页缓存

六、实践观察:用数据判断瓶颈在哪

下面给出一组可复现的观察方法(示例命令,实际数值随机器而异):

# 观察缺页与 TLB 情况
perf stat -e page-faults,dTLB-load-misses,minor-faults,major-faults \
  dd if=bigfile of=/dev/null bs=1M count=1024

# 观察脏页与回写压力
grep -E 'Dirty|Writeback' /proc/meminfo
vmstat 1 5

# 调整回写阈值(示例,需 root)
sysctl -w vm.dirty_background_ratio=5
sysctl -w vm.dirty_ratio=15

判断逻辑:若 major-faults 很高,说明数据未缓存,零拷贝收益有限;若 dTLB-load-misses 占比高,考虑大页或调整访问局部性;若 Dirty 持续接近 dirty_ratio,写入会被回写拖慢,此时应降低单次写入量或改用 O_DIRECT 绕过页缓存。

经验法则:顺序大文件传输优先考虑 sendfile/splice;需要随机访问或反复读同一文件,mmap 更合适;对延迟敏感且数据不需缓存,O_DIRECT 可避免页缓存污染,但会失去预读收益。
注意:mmap 映射的文件若被截断,访问越界页会触发 SIGBUS;O_DIRECT 要求缓冲区地址与长度按块大小对齐,否则 write 返回 EINVAL。这些细节直接决定零拷贝方案能否稳定落地。

总结来看,虚拟内存提供地址抽象,页表与 TLB 决定翻译成本,缺页异常决定数据何时进入内存,页缓存决定数据是否可复用,回写决定写入何时真正落盘。零拷贝不是绕过这些机制,而是在正确的前提下复用它们:让数据留在页缓存、让 DMA 直接搬运、让用户态少一次拷贝。理解这条链路,才能在真实系统中选对路径、调对参数、看懂性能数据。