4. I/O 模型演进:从阻塞到异步的进化之路

聊到高并发,I/O 模型是绕不开的坎。我见过不少团队,代码写得挺漂亮,一到高并发场景就卡死,问题往往出在 I/O 上。说白了,I/O 模型决定了你的程序怎么跟内核打交道,怎么等数据,怎么不浪费 CPU。

这一章,我们把这五种模型从头捋一遍。从最原始的阻塞 I/O,一路走到异步 I/O。每种模型我都会结合项目里的真实案例来讲,哪些坑我踩过,哪些场景该用哪种,咱们一次说清楚。

核心观点:I/O 模型的演进,本质上是「让 CPU 少等、让内核多干活」的过程。你想想看,从阻塞到非阻塞,从 select 到 epoll,再到真正的异步,每一步都是在减少「无效等待」。

I/O 模型演进路线 阻塞 I/O 同步等待数据 非阻塞 I/O 轮询检查状态 I/O 多路复用 select/poll/epoll 信号驱动 I/O SIGIO 通知 异步 I/O AIO / io_uring 关键分界线:阻塞 vs 非阻塞(第一阶段)| 同步 vs 异步(第二阶段) 用户进程阻塞 用户进程轮询 内核通知就绪 信号异步通知 内核完成拷贝 CPU 利用率低 浪费 CPU 轮询 高并发首选 实现复杂 真正的异步 早期 UNIX 1980s 1990s (select/poll) 2002 (epoll) 2019 (io_uring)

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 的性能问题。它的核心设计有三个:

  1. 事件驱动:只返回就绪的 fd,不用遍历全部
  2. mmap 共享内存:减少用户态和内核态的数据拷贝
  3. 回调机制: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 值得研究。别盲目追新,也别守着老东西不放。合适才是王道。