I/O 性能
本文所有示例均可在本仓库中直接编译运行(Linux)。I/O 的性能模型与计算完全不同:瓶颈在系统调用次数、页缓存与拷贝次数,而不是指令吞吐。
心智模型:一次 read() 到底发生了什么
read(fd, buf, n) 的内核路径大致是:
- 陷入内核(上下文切换,几百 ns 起步);
- 在页缓存(page cache)中查找文件对应的页——命中则无磁盘动作;
- 把页内数据拷贝到用户缓冲区
buf; - 返回用户态。
两个直接推论:
- 系统调用是固定成本。传 1 字节和传 1 MiB 的陷入开销几乎相同,所以单次传输量越小,单位数据付出的固定成本越高;
- 顺序读热文件时磁盘不是瓶颈,页缓存命中后拼的是拷贝与调用开销。要测真实设备必须
O_DIRECT绕过页缓存——那是另一类实验。
读路径对照:read vs pread vs mmap
examples/07-io-performance/src/mmap_vs_read.cpp 用同一份页缓存驻留文件对照三条路径:
| 路径 | 机制 | 特点 |
|---|---|---|
read() 循环 | 每次调用拷贝一段进用户缓冲 | 通用;顺序偏移由 fd 维护 |
pread() 循环 | 带显式偏移的读,不动 fd 偏移 | 多线程可共享一个 fd,无需加锁 |
mmap + 顺序触摸 | 建立页表映射,按页缺页载入 | 无前置拷贝;访问即 I/O |
关键结论(与直觉相反的部分):顺序全扫描时 mmap 不会快一个数量级——最终读的页完全相同。本仓库实测(32 MiB 缓存驻留文件)mmap 约快 20%,来自省掉的内核→用户拷贝与更少的调用次数。mmap 真正的优势场景是:
- 随机访问:直接按地址取数,无需 seek+read 组合;
- 共享映射:多进程映射同一文件页,省去重复读取与拷贝;
- 大文件稀疏访问:只有被触摸的页才产生 I/O。
示例用 FNV-1a 校验和验证三条路径读到的内容逐字节一致——对照实验的前提是"做的是同一件事"。
写路径:系统调用开销的极端演示
examples/07-io-performance/src/buffered_write.cpp 把同一份 1 MiB 数据用三种方式写出:
| 方式 | 系统调用次数 | 本仓库实测 |
|---|---|---|
逐字节 write(fd, &b, 1) | 1,048,576 | ~0.7 MiB/s |
| 4 KiB 缓冲块 | 256 | ~930 MiB/s |
| 单次大块写 | 1 | ~1 GiB/s |
1300 倍的差距全部来自系统调用固定成本。这就是 fwrite、std::ofstream、日志库默认带缓冲的原因,也是"每条日志一次 write"在高频路径上必然后悔的原因。
规范库与基准
include/hpc/io_utils.hpp 提供模块共用的 POSIX 封装:RAII FileDescriptor(含 O_CREAT 强制 mode 参数,避免经典的缺 mode 未定义行为)、RAII MmapView(MAP_SHARED)、pread 整读、确定性图案临时文件。
examples/07-io-performance/bench/io_bench.cpp 用 Google Benchmark 在 1–128 MiB 尺寸上复测三条读路径,每个尺寸只创建一次临时文件(setup 不计入计时)。
边界说明
- 本模块是 Linux/POSIX 专属(
mmap/pread/mkstemp),非 Linux 平台配置时自动跳过; io_uring(Linux 5.1+ 的异步 I/O 接口,靠提交/完成队列摊薄系统调用)值得单独成篇,本仓库暂不提供示例——它依赖 liburing 且收益场景集中在高并发小 I/O,与本文的缓存驻留实验不同题;- 测设备真实带宽/延迟需要
O_DIRECT+ 对齐缓冲 + 控制预读(posix_fadvise),并注意 SSD 写放大与 GC 干扰。