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:权限控制,决定用户态能否直接访问。
缺页异常分三类:
- 次缺页(minor fault):页已在页缓存中,只是当前进程页表没有建立映射,只需填 PTE,开销小。
- 主缺页(major fault):页不在内存,需要发起磁盘 I/O 读入,开销大,是 mmap 随机读性能波动的根源。
- 保护缺页:权限不符,通常导致 SIGSEGV。
对零拷贝而言,mmap 把文件映射进用户地址空间时,并不立即读数据,而是先建立 VMA(虚拟内存区域)。首次访问触发缺页,由内核把文件页读入页缓存并建立映射。因此 mmap 的“快”来自后续访问不再走 read 系统调用,但代价是缺页异常和 TLB 压力。
三、页缓存:内核的磁盘数据中转站
页缓存(page cache)以页为单位缓存文件内容,是 Linux 文件 I/O 的核心。无论 read 还是 write,数据都要经过它:
- 读路径:read → 查页缓存 → 命中则直接拷贝到用户缓冲区;未命中则从磁盘读入页缓存,再拷贝给用户。
- 写路径:write → 写入页缓存对应页并置 Dirty → 由回写线程异步刷盘。
这意味着传统 read/write 至少发生两次数据拷贝:磁盘↔页缓存、页缓存↔用户缓冲区。零拷贝要消除的正是后者。
四、回写机制:脏页何时落盘
脏页不会立即写盘,而是由内核回写机制延迟处理,以合并写、提升吞吐。触发回写的条件主要有:
- 脏页比例超过
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,再决定是否采用零拷贝路径。
六、实践观察:用数据判断瓶颈在哪
下面给出一组可复现的观察方法(示例命令,实际数值随机器而异):
# 观察缺页与 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 绕过页缓存。
总结来看,虚拟内存提供地址抽象,页表与 TLB 决定翻译成本,缺页异常决定数据何时进入内存,页缓存决定数据是否可复用,回写决定写入何时真正落盘。零拷贝不是绕过这些机制,而是在正确的前提下复用它们:让数据留在页缓存、让 DMA 直接搬运、让用户态少一次拷贝。理解这条链路,才能在真实系统中选对路径、调对参数、看懂性能数据。