I/O 优化:缓冲 I/O vs 直接 I/O、mmap、异步 I/O、io_uring
聊到 I/O 优化,很多人的第一反应是「换个 SSD 就好了」。嗯,硬件升级确实管用,但软件层面的 I/O 模型选型,往往才是决定系统吞吐量的关键。我这些年调过不少存储系统,从数据库到消息队列,从日志收集到视频转码,几乎每个项目最后都会卡在 I/O 上。
说白了,I/O 优化的本质就一句话:减少等待,提高并发。今天我把几种主流的 I/O 方式掰开揉碎讲清楚,包括它们的适用场景、性能差异,以及我踩过的坑。
1. 缓冲 I/O(标准 C 库 I/O)
这是大多数人最早接触的 I/O 方式。fopen、fread、fwrite 这一套,背后是 C 标准库帮你维护了一个用户态缓冲区。
它的工作流程是这样的:
- 用户调用 fread,数据先从内核页缓存拷贝到用户缓冲区
- 如果页缓存命中,直接返回,不触发磁盘 I/O
- 如果没命中,内核发起磁盘读取,数据先到页缓存,再拷贝到用户缓冲区
好处很明显:小数据量读写时,缓冲区减少了系统调用次数。比如你每次写 1 字节,缓冲 I/O 攒到 4KB 才真正调用一次 write,效率提升巨大。
但坏处也很致命:多了一次内存拷贝。数据从磁盘到页缓存,再从页缓存到用户缓冲区,CPU 和内存带宽都被白白消耗了。
我的经验: 在日志收集场景下,缓冲 I/O 是首选。因为日志通常是顺序写、小数据量,缓冲区能显著降低系统调用开销。但如果你在做数据库或视频处理,缓冲 I/O 反而会成为瓶颈。
2. 直接 I/O(O_DIRECT)
直接 I/O 绕过了内核的页缓存。数据直接从用户缓冲区到磁盘设备,中间不经过内核缓存。
使用方式很简单:open 时加上 O_DIRECT 标志。但有个坑——要求用户缓冲区地址和大小必须按扇区对齐(通常是 512 字节或 4KB)。
// 直接 I/O 示例
int fd = open("/data/file", O_RDWR | O_DIRECT);
char *buf;
posix_memalign(&buf, 4096, 4096); // 4KB 对齐
read(fd, buf, 4096);
直接 I/O 的好处是:
- 节省了一次内存拷贝,CPU 开销更低
- 避免了页缓存污染——你想想看,如果是一次性读取的大文件,缓存了也没用
- 适合数据库这类自己管理缓存的场景
但代价也很明显:每次读写都是真正的磁盘 I/O,没有缓存命中这一说。如果你频繁读取同一份数据,直接 I/O 反而更慢。
我曾经踩过的坑: 有一次做视频转码优化,我直接上了 O_DIRECT,结果发现性能反而下降了。后来分析才发现,转码过程中会反复读取同一帧数据做解码,页缓存其实帮了大忙。去掉 O_DIRECT 后,性能提升了 30%。
3. mmap 内存映射
mmap 是个很有意思的方案。它把文件直接映射到进程的虚拟地址空间,读写文件就像读写内存一样。
它的核心优势是:
- 省去了 read/write 的系统调用开销
- 数据只在内核页缓存和用户空间之间共享,没有拷贝
- 操作系统自动管理缓存和预读
// mmap 示例
int fd = open("/data/file", O_RDWR);
char *map = mmap(NULL, file_size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
// 直接读写 map 指针即可
memcpy(map + offset, data, len);
munmap(map, file_size);
mmap 特别适合随机访问的场景。比如数据库的索引文件,你只访问其中一小部分,mmap 按需加载页面,比 read 整个文件高效得多。
但 mmap 也有自己的问题:
- 文件大小受限于虚拟地址空间(32 位系统只有 4GB)
- 频繁的页错误(page fault)会带来性能抖动
- 多线程并发写同一个 mmap 区域需要自己加锁
我的建议: 如果你要处理大文件(比如几十 GB),别用 mmap。页错误会频繁触发,而且一旦内存不足,系统开始 swap,性能直接崩盘。这时候用异步 I/O 更靠谱。
4. 异步 I/O(AIO)
前面几种 I/O 都是同步的——你调用 read,线程就阻塞在那里等数据。异步 I/O 不一样,你发起一个 I/O 请求后,线程可以继续干别的事,等 I/O 完成后再通知你。
Linux 原生的 POSIX AIO(aio_read/aio_write)说实话不太好用。接口设计得别扭,而且底层实现依赖线程池,并发高了反而有锁竞争。
我更喜欢用 libaio,它直接跟内核的 AIO 接口打交道,性能更好:
// libaio 示例
io_context_t ctx;
io_setup(128, &ctx); // 创建 128 个请求槽
struct iocb cb;
io_prep_pread(&cb, fd, buf, size, offset);
struct iocb *cbs[] = {&cb};
io_submit(ctx, 1, cbs); // 提交请求
// 干别的事...
struct io_event events[1];
io_getevents(ctx, 1, 1, events, NULL); // 等待完成
异步 I/O 最大的价值在于:用少量的线程处理大量的并发 I/O。比如一个 Web 服务器,用 4 个线程就能处理几千个并发连接,每个连接都在做异步 I/O。
但要注意,异步 I/O 的编程模型比较复杂。回调地狱、状态机管理、错误处理,都是容易出 bug 的地方。
5. io_uring——新时代的 I/O 王者
io_uring 是 Linux 5.1 引入的新 I/O 框架,可以说是集大成者。它通过两个共享的环形缓冲区(Submission Queue 和 Completion Queue)来减少系统调用。
它的工作流程是这样的:
- 应用程序把 I/O 请求写入 SQ(提交队列)
- 通过一次系统调用(io_uring_enter)通知内核处理
- 内核处理完后,把结果写入 CQ(完成队列)
- 应用程序从 CQ 读取结果
io_uring 最厉害的地方是:支持完全无系统调用的 I/O。通过 SQPOLL 模式,内核线程会轮询 SQ,应用程序只需要写 SQ、读 CQ,完全不需要系统调用。
// io_uring 示例(简化版)
struct io_uring ring;
io_uring_queue_init(128, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, size, offset);
io_uring_submit(&ring); // 提交
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 等待完成
// 处理结果...
io_uring_cqe_seen(&ring, cqe);
我的实战感受: 去年我把一个消息队列的存储引擎从 libaio 迁移到 io_uring,在 64 核机器上,吞吐量提升了 2.5 倍,CPU 占用反而下降了 40%。io_uring 的批处理能力太强了,一次系统调用可以提交几百个请求。
6. 五种 I/O 方式对比
| 特性 | 缓冲 I/O | 直接 I/O | mmap | 异步 I/O | io_uring |
|---|---|---|---|---|---|
| 系统调用次数 | 少(缓冲区) | 每次 I/O 一次 | 极少(仅 mmap/munmap) | 每次 I/O 两次 | 极少(可批处理) |
| 内存拷贝次数 | 2 次 | 1 次 | 0 次(共享) | 1 次 | 1 次 |
| 缓存管理 | 内核自动 | 用户自己 | 内核自动 | 用户自己 | 用户自己 |
| 并发模型 | 同步阻塞 | 同步阻塞 | 同步(页错误) | 异步回调 | 异步轮询/回调 |
| 适用场景 | 小文件、日志 | 数据库、大文件 | 索引、随机访问 | 高并发网络 | 极致性能 |
| Linux 版本要求 | 任意 | 任意 | 任意 | 2.6+ | 5.1+ |
7. 如何选择?
说实话,没有银弹。我一般按这个思路来选:
- 小文件、顺序读写、数据量不大 → 缓冲 I/O,省心省力
- 数据库、自己管理缓存 → 直接 I/O,避免双缓存
- 随机访问、索引文件 → mmap,按需加载
- 高并发、大量连接 → 异步 I/O 或 io_uring
- 追求极致性能、Linux 5.1+ → io_uring,没有之一
一个小技巧: 如果你不确定用哪种,先用缓冲 I/O 跑个基准测试。然后根据性能瓶颈,逐步尝试其他方案。别一上来就上 io_uring,编程复杂度高,调试也麻烦。
8. 知识体系图
下面这张图展示了五种 I/O 方式在数据路径上的差异,以及各自的适用场景:
9. 避坑指南
最后分享几个我实战中踩过的坑:
- 别混用缓冲 I/O 和直接 I/O:同一个文件,一会儿用 fread,一会儿用 read + O_DIRECT,数据一致性会出问题。因为页缓存和磁盘数据可能不同步。
- mmap 注意文件截断:如果 mmap 后文件被截断,访问超出新文件大小的区域会触发 SIGBUS。我当年因为这个 bug 排查了两天。
- io_uring 的 CQ 溢出:如果应用程序处理 CQ 的速度跟不上内核完成的速度,CQ 会溢出,导致丢事件。记得把队列深度设大一点。
- 直接 I/O 的对齐要求:不同设备对齐要求不一样。NVMe 盘可能要求 4KB 对齐,老硬盘可能只要 512 字节。用 posix_memalign 最保险。
I/O 优化这条路,没有终点。每次硬件升级、内核更新,都可能带来新的优化空间。但万变不离其宗——理解数据路径,减少不必要的拷贝和等待,你的系统就能跑得更快。