高级I/O:非阻塞I/O、I/O多路复用、异步I/O、存储映射I/O

各位同学,今天我们来聊聊高级I/O。说实话,I/O这块是系统编程里最容易让人头疼的地方。我早年做网络服务器的时候,就被阻塞I/O坑过好几次——一个客户端卡住了,整个服务都跟着瘫痪。后来才慢慢摸清楚这些高级I/O的门道。

咱们今天要讲四个东西:非阻塞I/O、I/O多路复用、异步I/O,还有存储映射I/O。它们解决的核心问题其实就一个:怎么让程序不卡在I/O操作上

核心思想:让CPU在等待I/O的时候别闲着,去干点别的有用的事。

高级I/O 知识体系 高级 I/O 非阻塞 I/O I/O 多路复用 异步 I/O (AIO) 存储映射 I/O 立即返回,不阻塞 需轮询检查状态 同时监控多个fd select/poll/epoll 内核通知完成 信号驱动 文件映射到内存 mmap系统调用 共同目标:避免进程因I/O操作而阻塞等待 让CPU资源得到更充分的利用

一、非阻塞I/O

先说说非阻塞I/O。说白了,就是告诉内核:"别等我,有数据就给我,没有我就先干别的"

设置非阻塞有两种方式:

  • 打开文件时指定 O_NONBLOCK 标志
  • fcntl 动态设置
// 方式一:open时指定
int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);

// 方式二:用fcntl设置
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

非阻塞模式下,read() 如果没有数据可读,会立即返回 -1,并设置 errnoEAGAINEWOULDBLOCK。嗯,这里要注意:这两个错误码在Linux上是同一个值,但写代码时最好两个都判断,保证可移植性。

我的经验:非阻塞I/O配合轮询用,适合简单的场景。但如果你要监控几十上百个文件描述符,千万别用轮询——CPU空转太浪费了。这时候就该I/O多路复用上场了。

二、I/O多路复用(回顾)

I/O多路复用,说白了就是一个线程同时盯着多个文件描述符。哪个有动静了,就去处理哪个。

Linux下主要有三个:

机制 特点 适用场景
select 跨平台,但fd数量有限(默认1024) 少量fd,兼容性要求高
poll 无上限,但效率随fd数线性下降 中等数量fd
epoll Linux专属,事件驱动,效率高 大量fd,高性能场景

我个人习惯是:新项目直接用epoll。除非你要写跨平台的代码,那才考虑select或poll。

// epoll 核心用法
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

struct epoll_event events[64];
while (1) {
    int n = epoll_wait(epfd, events, 64, -1);
    for (int i = 0; i < n; i++) {
        // 处理就绪的事件
    }
}

我曾经踩过的坑:用epoll做边缘触发(ET模式)时,一定要循环读取直到返回EAGAIN。否则数据没读完,下次就不会再触发了。这个bug我调了整整一个下午才找到原因。

三、异步I/O(AIO)

异步I/O和前面几种思路完全不同。前面都是"我主动去检查",AIO是"你好了通知我"

Linux的AIO有两种:

  • POSIX AIO(glibc实现):用户态模拟,性能一般
  • Linux AIO(内核原生):真正的异步,但只支持O_DIRECT模式
// POSIX AIO 示例
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = 1024;
cb.aio_offset = 0;

// 发起异步读
aio_read(&cb);

// 干点别的事...

// 检查完成状态
while (aio_error(&cb) == EINPROGRESS) {
    // 还没完成,继续等
}

// 获取结果
ssize_t ret = aio_return(&cb);

注意:Linux内核原生的AIO限制比较多。我建议普通应用用POSIX AIO就够了,或者干脆用libuv、libevent这些封装好的库。除非你在做数据库、存储系统这类对性能极致追求的东西,才需要考虑内核AIO。

四、存储映射I/O(mmap)

存储映射I/O,说白了就是把文件直接映射到进程的地址空间。之后读写文件就像读写内存一样,不需要read/write系统调用。

#include <sys/mman.h>

// 映射文件
void *addr = mmap(NULL,              // 内核选地址
                  length,            // 映射长度
                  PROT_READ | PROT_WRITE,  // 读写权限
                  MAP_SHARED,        // 共享映射
                  fd,                // 文件描述符
                  0);                // 偏移量

// 直接通过指针读写
memcpy(addr, "hello", 5);

// 解除映射
munmap(addr, length);

mmap的好处很明显:

  • 减少系统调用:不用频繁read/write
  • 共享内存:多个进程可以映射同一个文件
  • 延迟加载:只有访问到的页面才真正读入内存

我的建议:处理大文件时,mmap比read/write快很多。但要注意,映射的文件大小不能超过地址空间范围(32位系统尤其明显)。另外,频繁的小文件读写,用mmap反而可能更慢——因为建立映射本身有开销。

避坑指南:我曾经在项目中用mmap映射了一个日志文件,然后另一个进程在写这个文件。结果映射端看到的内容不一致——因为mmap不保证实时同步其他进程的写入。解决办法是用msync强制同步,或者用MAP_SHARED_VALIDATE标志。

五、四种I/O方式对比

方式 阻塞点 CPU利用率 编程复杂度 典型场景
阻塞I/O 全程阻塞 简单脚本
非阻塞I/O 轮询时CPU忙 少量fd
I/O多路复用 select/poll/epoll 中高 网络服务器
异步I/O 不阻塞 高性能存储
存储映射I/O 缺页时 大文件处理

最后说一句:没有银弹。每种方式都有自己的适用场景。我见过有人非要用epoll去读一个普通文件,结果性能还不如直接read——因为文件已经在page cache里了,epoll反而多了一层开销。

选型的时候,多想想你的实际场景:并发量多大?数据量多大?实时性要求多高?想清楚了,自然就知道该用哪个了。

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