I/O优化:从慢如蜗牛到快如闪电

说到I/O优化,我脑子里立刻浮现出几年前的一个深夜。当时我在调一个日志处理系统,磁盘读写成了瓶颈,CPU空转,业务堆积。那晚我盯着perf报告,心里只有一个念头:I/O这堵墙,必须得翻过去。

今天我们就来聊聊,怎么让数据在内存和磁盘之间飞起来。

同步I/O:最朴素的代价

先看最基础的同步I/O。你调用read(),线程就挂在那等。数据从磁盘到内核缓冲区,再从内核缓冲区拷到用户空间,整个过程线程啥也不干。

// 同步I/O示例
int fd = open("data.bin", O_RDONLY);
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));  // 线程阻塞在这里
// 只有read返回了,才能继续往下走
process_data(buf, n);

这种模式简单,但代价高。我见过不少新手写的网络服务,每个连接一个线程,线程里全是同步read/write。连接一多,线程数暴涨,上下文切换开销直接让CPU跪了。

核心问题:同步I/O让CPU和I/O设备串行工作。CPU等I/O,I/O等磁盘旋转,时间就这么浪费了。

异步I/O:让CPU不再空等

异步I/O的思路很简单:你发起I/O请求,然后立刻返回做别的事。等I/O完成了,系统通知你。

// Linux AIO示例(简化)
struct iocb cb;
struct io_event events[1];
io_context_t ctx;

memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = sizeof(buf);
cb.aio_lio_opcode = IOCB_CMD_PREAD;

io_setup(128, &ctx);
io_submit(ctx, 1, &cb);  // 提交请求,立即返回

// 做其他计算...
do_other_work();

// 回头检查结果
io_getevents(ctx, 1, 1, events, NULL);

嗯,这里要注意:传统的AIO(POSIX AIO)在Linux上表现并不理想。我在项目中试过,线程池+epoll反而更稳定。直到io_uring出现,情况才真正改变。

mmap:绕开一次拷贝

mmap把文件直接映射到进程地址空间。你读写内存,就是在读写文件。省去了read/write那两次系统调用,也省去了内核到用户空间的数据拷贝。

// mmap示例
int fd = open("large_file.bin", O_RDONLY);
struct stat st;
fstat(fd, &st);

void *map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);  // 映射后可以关闭fd

// 直接像访问数组一样访问文件内容
const char *data = (const char *)map;
for (size_t i = 0; i < st.st_size; i++) {
    if (data[i] == '\n') line_count++;
}

munmap(map, st.st_size);

我的经验:mmap适合大文件、随机访问场景。但小文件频繁映射/解除映射,开销反而比read大。我曾经在一个配置读取模块里用了mmap,结果每次启动都要映射几十个小文件,后来改回read反而更快。

零拷贝技术:终极武器

零拷贝的目标是:数据从磁盘到网卡,中间不经过CPU拷贝。sendfile就是典型代表。

// sendfile零拷贝示例
int src_fd = open("bigfile.bin", O_RDONLY);
int dst_fd = socket_fd;  // 目标可以是socket

off_t offset = 0;
ssize_t sent = sendfile(dst_fd, src_fd, &offset, file_size);
// 数据直接从文件缓存区送到socket缓冲区,用户空间零拷贝

为什么快?传统方式:磁盘→内核缓冲区→用户缓冲区→内核socket缓冲区→网卡。sendfile直接:磁盘→内核缓冲区→网卡。省了两次拷贝,一次系统调用。

我曾经踩过的坑:sendfile不支持从socket到socket。有次我想在两个socket之间转发数据,直接用sendfile,结果返回EINVAL。后来老老实实用splice,或者走用户空间中转。

splice是另一个零拷贝接口,它可以在两个文件描述符之间移动数据,不需要用户空间参与。

// splice示例:管道中转
int pipefd[2];
pipe(pipefd);

// 从输入fd读到管道
splice(in_fd, NULL, pipefd[1], NULL, len, SPLICE_F_MOVE);
// 从管道写到输出fd
splice(pipefd[0], NULL, out_fd, NULL, len, SPLICE_F_MOVE);

io_uring:新时代的异步I/O

io_uring是Linux 5.1引入的,它彻底改变了异步I/O的玩法。核心思想:用两个共享内存的环形队列,一个提交请求,一个收割结果。系统调用几乎降为零。

// io_uring基础示例
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);

// 准备一个读请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);

// 提交
io_uring_submit(&ring);

// 做其他事...
do_other_work();

// 收割结果
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res > 0) {
    // 读取成功
}
io_uring_cqe_seen(&ring, cqe);

io_uring最厉害的地方:它支持缓冲I/O和非缓冲I/O,支持固定缓冲区(注册缓冲区避免每次映射),支持多队列。我在一个KV存储项目里用io_uring替代了epoll+线程池,延迟降低了30%,吞吐量翻了一倍。

为什么io_uring这么强?

  • 系统调用次数大幅减少:一次submit可以提交多个请求
  • 支持提前注册缓冲区、文件描述符,减少内核态开销
  • 支持poll模式,对高速设备(NVMe)特别友好
  • 支持链接请求(依赖链),一个请求完成自动触发下一个

知识体系总览

下面这张图,把今天讲的内容串起来了。你可以看到不同I/O方式在数据路径上的差异。

I/O优化技术对比 磁盘/SSD 内核缓冲区 用户缓冲区 Socket缓冲区 同步I/O: 3次拷贝, 2次系统调用 mmap: 直接映射, 省去内核→用户拷贝 sendfile: 零拷贝, 从内核缓冲区直接到Socket io_uring 共享内存环形队列 · 批量提交 · 固定缓冲区 💡 颜色说明: 🔴 红色虚线 = 同步I/O(最慢,拷贝多) 🟢 绿色 = mmap(减少一次拷贝) 🟣 紫色 = sendfile(零拷贝)

如何选择?

说实话,没有银弹。我一般这样选:

场景 推荐方案 理由
小文件、低频读写 同步I/O + 缓冲区 简单可靠,mmap/io_uring的初始化开销不划算
大文件、顺序读写 mmap 或 sendfile 减少拷贝,CPU利用率高
高并发、随机读写 io_uring 批量提交,系统调用少,延迟低
网络文件传输 sendfile / splice 零拷贝,CPU几乎不参与数据搬运
数据库、KV存储 io_uring + 直接I/O 绕过页缓存,自己管理缓存策略

我的建议:别一上来就上io_uring。如果你的场景同步I/O就能满足性能要求,那就用同步。优化要有的放矢,先profile,再动手。我曾经见过一个项目,花了两周把I/O改成io_uring,结果瓶颈在业务逻辑上,I/O只占5%的时间——白忙活了。

避坑指南

最后分享几个我踩过的坑:

  • mmap的文件大小变化:如果文件在mmap后被截断,访问超出原大小的区域会触发SIGBUS。我曾经因为这个导致线上服务crash,后来加了个信号处理函数才稳住。
  • sendfile的offset参数:必须传有效的off_t指针,不能传NULL。有次我传了NULL,结果每次发送都是从文件头开始,数据全乱了。
  • io_uring的SQPOLL模式:内核线程轮询提交队列,适合高负载场景。但CPU占用会高一些,低负载时反而浪费。我一般只在QPS超过10万时才开SQPOLL。
  • 直接I/O的对齐要求:使用O_DIRECT时,缓冲区地址、偏移量、长度都必须按块大小对齐(通常是512字节)。不满足会返回EINVAL,我第一次用的时候被这个坑了好几个小时。

I/O优化这条路,说白了就是跟数据搬运较劲。搬得次数越少,搬得越直接,性能就越好。从同步到异步,从多拷贝到零拷贝,从系统调用到共享内存——每一步都是在减少不必要的开销。

你想想看,数据从磁盘到用户手里,中间经过了多少层?每一层都是延迟,每一层都是机会。把这些层摸透了,你的程序就能跑得比别人快。

公众号:蓝海资料掘金营,微信deep3321