4. I/O 模型演进:从阻塞到异步的进化之路
聊到高并发,I/O 模型是绕不开的坎。我见过不少团队,代码写得挺漂亮,一到高并发场景就卡死,问题往往出在 I/O 上。说白了,I/O 模型决定了你的程序怎么跟内核打交道,怎么等数据,怎么不浪费 CPU。
这一章,我们把这五种模型从头捋一遍。从最原始的阻塞 I/O,一路走到异步 I/O。每种模型我都会结合项目里的真实案例来讲,哪些坑我踩过,哪些场景该用哪种,咱们一次说清楚。
核心观点:I/O 模型的演进,本质上是「让 CPU 少等、让内核多干活」的过程。你想想看,从阻塞到非阻塞,从 select 到 epoll,再到真正的异步,每一步都是在减少「无效等待」。
4.1 阻塞 I/O:最原始,也最直观
阻塞 I/O 是啥?就是你的程序调用 read() 读数据,如果内核还没准备好,线程就挂在那等。数据从内核拷贝到用户空间之前,这个线程啥也干不了。
我刚开始写网络程序时,用的就是这种模型。一个线程处理一个连接,accept 之后 recv,收不到就卡住。那时候觉得挺自然的——等数据嘛,天经地义。
// 阻塞 I/O 示例
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);
char buf[1024];
// 这里会阻塞,直到有数据可读
int n = read(sock, buf, sizeof(buf));
// 数据已经拷贝到 buf 中
printf("收到 %d 字节\n", n);
我曾经踩过的坑:早期做一个聊天服务器,用阻塞 I/O + 多线程,每个连接一个线程。连接数到了 1000 左右,系统就扛不住了。线程切换开销巨大,内存也被吃光。后来才明白,阻塞 I/O 的并发能力受限于线程数,而线程数受限于系统资源。
阻塞 I/O 的优点是简单,代码好写。缺点是——一个线程只能伺候一个连接。连接多了,线程池再大也扛不住。
4.2 非阻塞 I/O:不等待,但得轮询
非阻塞 I/O 把 socket 设置成 O_NONBLOCK。这时候 read() 调用不会卡住,如果内核没数据,直接返回 -1,errno 设为 EAGAIN 或 EWOULDBLOCK。
听起来不错?但问题来了——你怎么知道数据啥时候到?只能轮询。不停地调用 read(),直到它返回数据为止。
// 非阻塞 I/O 示例
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
char buf[1024];
while (1) {
int n = read(sock, buf, sizeof(buf));
if (n > 0) {
// 有数据了
break;
} else if (n == 0) {
// 连接关闭
break;
} else {
// errno == EAGAIN,没数据,继续轮询
continue;
}
}
你想想看,这种忙轮询的方式,CPU 全浪费在循环检查上了。我一个朋友做过一个实验,单线程非阻塞轮询 1000 个连接,CPU 直接跑满 100%。数据没等到,CPU 先炸了。
所以非阻塞 I/O 很少单独用。它真正的价值,是给 I/O 多路复用打基础。
4.3 I/O 多路复用:select / poll / epoll
这才是高并发的核心。I/O 多路复用的思路是:一个线程监视多个 socket,内核告诉你有哪个 socket 准备好了,你再去做实际的读写操作。
说白了,就是把「谁准备好了」这件事交给内核去管。用户线程不用轮询,也不用阻塞在单个连接上。
4.3.1 select:老前辈,但有限制
select 是最早的多路复用方案。它把关心的 fd 放到三个集合里(读、写、异常),然后调用 select() 等待。
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);
struct timeval tv = {5, 0}; // 5 秒超时
int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
if (FD_ISSET(sock1, &readfds)) {
// sock1 可读
}
if (FD_ISSET(sock2, &readfds)) {
// sock2 可读
}
}
select 有三个硬伤:
- fd 数量限制:默认 1024,改内核参数也有限
- 每次都要拷贝集合:从用户态到内核态,数据量大了很慢
- O(n) 扫描:内核要遍历所有 fd 检查状态,连接越多越慢
我记得 2010 年左右做一个网关项目,用 select 管理 800 多个连接,每次 select 返回后要遍历所有 fd,CPU 开销越来越大。后来换成 epoll,问题才解决。
4.3.2 poll:突破了 1024,但没解决根本问题
poll 用 pollfd 数组代替了 fd_set,突破了 1024 的限制。但其他问题还在——每次调用都要传整个数组,内核还是要 O(n) 扫描。
struct pollfd fds[2];
fds[0].fd = sock1;
fds[0].events = POLLIN;
fds[1].fd = sock2;
fds[1].events = POLLIN;
int ret = poll(fds, 2, 5000); // 5 秒超时
if (ret > 0) {
if (fds[0].revents & POLLIN) {
// sock1 可读
}
}
poll 比 select 好一点,但本质没变。连接数上万时,性能还是会急剧下降。
4.3.3 epoll:Linux 上的王者
epoll 是 Linux 2.6 引入的,专门解决 select/poll 的性能问题。它的核心设计有三个:
- 事件驱动:只返回就绪的 fd,不用遍历全部
- mmap 共享内存:减少用户态和内核态的数据拷贝
- 回调机制:fd 就绪时内核主动回调,不是轮询检查
// epoll 使用示例
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock1;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev);
struct epoll_event events[1024];
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 直接处理就绪的 fd
handle_read(events[i].data.fd);
}
}
}
我个人习惯:用 epoll 时,尽量配合边缘触发(EPOLLET)模式。水平触发每次都会通知,边缘触发只在状态变化时通知一次,配合非阻塞 I/O 使用,性能更好。但要注意——边缘触发下必须把数据读完,否则会丢数据。
epoll 的 O(1) 复杂度让它能轻松处理数万甚至数十万连接。Nginx、Redis、Node.js 底层都在用 epoll。
4.4 信号驱动 I/O:异步通知的尝试
信号驱动 I/O 的思路是:给 socket 注册一个 SIGIO 信号处理函数,当数据到达时,内核发信号通知进程。
听起来很美好?但实际用起来问题不少:
- 信号处理函数里能做的事有限,不能调用非重入函数
- 多个 socket 共用一个信号,你得自己区分是哪个 fd 就绪了
- 信号可能丢失,高并发下不靠谱
我在项目中几乎没见过有人用信号驱动 I/O 做高并发。它更像是一个过渡方案,为后来的异步 I/O 探路。
4.5 异步 I/O:真正的「做完通知我」
前面四种模型,本质上都是「同步 I/O」。为什么?因为数据从内核拷贝到用户空间这一步,还是用户线程在做。
异步 I/O 不一样。你调用 aio_read() 之后,直接返回。内核负责把数据从磁盘或网卡拷贝到你指定的缓冲区,拷贝完了再通知你。整个过程用户线程不参与。
// POSIX AIO 示例(简化)
struct aiocb cb;
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = 1024;
cb.aio_offset = 0;
aio_read(&cb);
// 做其他事情...
while (aio_error(&cb) == EINPROGRESS) {
// 继续干别的
}
// 数据已经拷贝到 buf 了
int ret = aio_return(&cb);
Linux 原生的 POSIX AIO 实现不太好,线程池模拟的,性能一般。但 io_uring 是真正的突破——它用共享的环形缓冲区,让用户和内核之间零拷贝通信,性能非常惊人。
关键区别:同步 I/O 模型(包括 epoll)只是「通知你数据可读了」,读数据这个操作还是你来做。异步 I/O 是「数据已经读好了,你直接用」。前者是「叫你去取快递」,后者是「快递送到你桌上」。
4.6 五种模型对比
| 模型 | 阻塞阶段 | 数据拷贝 | 并发能力 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 阻塞 I/O | 等待数据 + 拷贝数据 | 用户线程做 | 极低 | 低 | 低并发、简单脚本 |
| 非阻塞 I/O | 拷贝数据 | 用户线程做 | 低 | 中 | 配合多路复用使用 |
| I/O 多路复用 | 拷贝数据 | 用户线程做 | 高 | 中高 | 高并发网络服务 |
| 信号驱动 I/O | 拷贝数据 | 用户线程做 | 中 | 高 | 极少使用 |
| 异步 I/O | 无阻塞 | 内核做 | 极高 | 高 | 高性能存储/网络 |
我曾经踩过的坑:有次把 epoll 当成异步 I/O 来用,以为设置了 EPOLLONESHOT 就能让内核帮我做数据拷贝。结果压测时发现 CPU 全花在 read() 调用上。后来才意识到,epoll 只是通知机制,真正的读写操作还得自己来。异步 I/O 是另一套东西。
总结一下。五种模型,从阻塞到异步,核心变化就两件事:谁等数据、谁拷数据。阻塞 I/O 两件事都自己做。非阻塞和多路复用解决了「等」的问题,但「拷」还得自己来。异步 I/O 两件事都交给内核。
选哪种?看场景。做 Web 服务器,epoll 足够了。做数据库或存储系统,io_uring 值得研究。别盲目追新,也别守着老东西不放。合适才是王道。