高级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。说白了,就是告诉内核:"别等我,有数据就给我,没有我就先干别的"。
设置非阻塞有两种方式:
- 打开文件时指定
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,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。嗯,这里要注意:这两个错误码在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反而多了一层开销。
选型的时候,多想想你的实际场景:并发量多大?数据量多大?实时性要求多高?想清楚了,自然就知道该用哪个了。