1. 先想清楚一个问题:五大IO模型到底在解决什么
我在面试别人或者看别人写的网络服务时,经常遇到一个现象:一说“五大IO模型”,阻塞、非阻塞、多路转接、信号驱动、异步,名字背得滚瓜烂熟;真到了线上调一个“连接明明建立上了,就是收不到数据”的问题,又开始瞎猜。原因很简单,很多人把IO模型当成八股,却没想过它本质上是两个阶段、四个维度的取舍。
一次完整的IO操作,可以拆成两段:
- 等待数据从设备到达内核缓冲区;
- 把内核缓冲区里的数据拷贝到用户态缓冲区。
阻塞和非阻塞,看的是进程在这两段里要不要“原地等”;同步和异步,看的是数据到底准备好了没、拷贝完了没,以及谁来告诉进程“你可以继续了”。把这两句话搞清楚,后面五个模型就全串起来了。
五大IO模型具体指哪五个?就是 Unix 网络编程里那套:阻塞式IO、非阻塞式IO、IO多路转接(也叫IO多路复用)、信号驱动IO、异步IO。其中的“多路转接”,往下落就是 select、poll、epoll 这套机制。这也是很多人把它单独拎出来当重点的原因:它是最能解决高并发连接问题的模型,也是理解现代网络框架的基石。
这五个模型看着像五种并列的技术,实际上更像一条演进路线。从阻塞到非阻塞,从非阻塞到多路转接,再到信号驱动和异步,每一步都是为了解决上一步留下的某个痛点。下面我把每个模型放到具体场景里拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 逐个拆开五大模型:每一档的代价和收获
2.1 阻塞式IO:一个连接一个线程的“排队等号”
阻塞式IO是默认行为。进程调用 read/recvfrom 之后,如果 socket 的接收缓冲区里没有数据,进程会从运行状态变成睡眠状态,挂到这个 socket 的等待队列上。等网络包到了,内核把数据放进接收缓冲区,再唤醒进程,recvfrom 把数据从内核拷贝到用户缓冲区,调用才返回。
这段代码写起来最直观:
c复制int n = read(fd, buf, sizeof(buf));
if (n < 0) {
perror("read");
}
阻塞式IO最大的优点是简单:业务代码不用管数据什么时候来,反正 read 没返回就说明还没读完/还没数据。但代价也很直接:一个进程如果只处理一个连接,在等待期间它什么都干不了。
最朴素的并发服务端就是“一个连接一个线程”,每个线程里 read 阻塞在自己那个 socket 上。问题在于连接一多,线程数量跟着爆炸,线程栈占用内存、上下文切换消耗 CPU,最后往往是线程调度先撑不住,而不是网卡。这也就是 C10K 问题最早期的形态:不是机器不够快,而是“每连接一线程”的模式扛不住连接数增长。
2.2 非阻塞式IO:不等了,但你要不断回去问
非阻塞式IO把 fd 设置成 O_NONBLOCK 后,read 发现缓冲区里没有数据时不会睡眠,而是立刻返回,错误码是 EAGAIN 或者 EWOULDBLOCK。业务代码需要自己循环调用 read,直到成功或者拿到 EAGAIN。
c复制set_nonblocking(fd);
for (;;) {
n = read(fd, buf, sizeof(buf));
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 当前没有数据,下次再来
}
perror("read");
break;
}
if (n == 0) {
close(fd);
break;
}
// 处理 buf 里的数据
}
这段代码看着不难,但问题马上暴露:如果连接少,你循环轮询没什么压力;如果连接有几千几万个,绝大多数连接在某个瞬间都是空闲的,你每个连接都要 syscall 一次去问“有没有数据”,CPU 全花在空转的系统调用上。这种“用户态忙轮询”比阻塞还可怕,因为阻塞至少把等待交给了内核调度,而忙轮询让进程一直占用 CPU。
纯非阻塞IO在工程上很少单独作为并发模型用,更多时候它只作为一个基础属性存在:让 fd 不因为单次读写而把线程卡死。真正把“多个连接”管起来的是下一招:多路转接。
2.3 多路转接:让内核帮你“看住”一批fd
多路转接,也就是常说的 IO 多路复用,核心思想是:把一批 fd 登记给内核,进程阻塞在 select/poll/epoll 的等待调用上,内核发现有任意一个 fd 就绪时返回;进程拿到就绪的 fd 列表后再做真正的 read/write。
这里有个容易混淆的点:“多路”指的是多个 fd,“转接”可以理解为一个调度点同时看管这些 fd。它解决的痛点是:几千个连接里大部分时间只有几个活跃,与其一个个问,不如睡一觉等内核来叫。
select、poll、epoll 都是这个模型的实现,但三者的内部机制差别很大,这也是全文真正要展开的重点。必须先说清楚一个结论:多路转接本质上是同步IO模型。因为它只告诉你“可以读了”,并没有替你把数据从内核拷贝到用户缓冲区。把这件事记牢,后面很多面试题都能秒答。
2.4 信号驱动IO:内核喊一声,进程再动手
信号驱动IO的思路是:进程提前给内核登记好,当 fd 可读时内核发送 SIGIO 信号,进程在信号处理函数里再去 read。
在 Linux 上大致这样的配置:
c复制fcntl(fd, F_SETOWN, getpid());
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC);
配置好之后,内核发现这个 fd 上有事件,就会向进程发信号。进程收到信号后,在 handler 里处理这个 fd。相比非阻塞轮询,它省去了用户态反复调用的浪费;但它的工程痛感也很明显:SIGIO 是非实时信号,事件多了容易合并丢失;信号 handler 里能做的事有限,还要自己维护 fd 和业务对象的对应关系;每个 fd 都走信号,语义比 select/epoll 复杂太多。
信号驱动IO在 Unix 网络编程的教科书分类里占了一个位置,但在实际服务端代码里非常少见,因为它能做的事,epoll 的“就绪列表”做得更好,还更可控。所以我的建议是:理解它,但别把它当首选。
2.5 异步IO:内核把活全干完,只给结果
异步IO和前四类有本质不同:前四类无论怎么等,最多等到“数据就绪”,最终拷贝数据还是要进程调 read 完成;异步IO则是进程发起请求后直接返回,内核负责等待数据、把数据拷贝到用户缓冲区,一切做完之后,再把“完成”的结果告诉进程。
Linux 上现在最有代表性的异步 IO 是 io_uring。它通过内核和用户态共享的 SQ/CQ 环来提交请求、收割结果,进程不需要在数据到达时被唤醒做拷贝。
如果你把阻塞式IO想成“在餐厅排队等菜端上来”,异步IO就是“点完外卖,该干嘛干嘛,等骑手打电话说餐到了”。它的收益是极高的并发能力和更低的线程切换成本;代价是编程模型复杂,提交、完成、取消、资源回收都要仔细处理。它在存储场景和定制化高性能网络场景里很能打,但并不是所有业务都需要一上来就用它。
五类模型放一起看,本质是“谁在等、等的时候干什么、通知你的是‘可以开始’还是‘已经完成’”三个问题的不同组合。阻塞和非阻塞是过程姿态,异步才是结果姿态。
3. 深入多路转接:select、poll、epoll 的差异细节
3.1 select:位图、1024,和“改你没商量”
select 是最老牌的多路转接 API。它把三类事件分开处理:可读、可写、异常。每个类别用一个 fd_set 位图,FD_SET 把 fd 放进去,然后调用 select。
c复制fd_set master_set, read_set;
FD_ZERO(&master_set);
FD_SET(listen_fd, &master_set);
int max_fd = listen_fd;
while (1) {
read_set = master_set;
int n = select(max_fd + 1, &read_set, NULL, NULL, NULL);
if (n <= 0) {
continue;
}
for (int fd = 0; fd <= max_fd; fd++) {
if (FD_ISSET(fd, &read_set)) {
handle(fd);
}
}
}
select 有两个特别容易踩的坑。第一,内核返回时会修改 fd_set,只保留就绪的 fd,所以每次调用前必须从 master_set 重新拷贝一份。第二,timeout 参数也可能被内核修改,不能重复使用同一个 timeval。
select 的核心限制在数据结构:fd_set 是位图,大小由 FD_SETSIZE 决定,经典实现下默认是 1024。想支持更多连接,就得改宏甚至重新编译,而且不是所有平台都允许。更关键的是,不管上限多少,select 每次调用都要把整个 fd_set 从用户态拷到内核态,内核再线性扫描一遍;返回后用户态还要再从 0 到 max_fd 遍历一遍,找出哪些 fd 就绪。连接数一多,O(n) 的成本就像钝刀子割肉。
3.2 poll:结构体数组让上限松动,但复杂度没变
poll 换了数据结构,不再用位图,而是用一个 pollfd 数组。每个元素包含 fd、关注的事件 events、返回的事件 revents:
c复制struct pollfd fds[1024] = {0};
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
int ret = poll(fds, 1024, -1);
if (ret > 0 && (fds[0].revents & POLLIN)) {
handle(listen_fd);
}
poll 解决了 select 最明显的两个问题:没有 FD_SETSIZE 那种固定位图上限,events 和 revents 分离,内核不会改掉调用方的 events。你不需要每次重新构造数组,只需要在循环开始前把 fd 放进去一次。
但 poll 的算法模型没变:每次调用,内核还是要遍历整个 pollfd 数组,检查每个 fd 的状态;调用结束前,整个数组也要从用户态拷贝到内核态。连接少的时候这无所谓,连接一旦多起来,而且大部分空闲,CPU 时间就会浪费在对一堆空闲 fd 的扫描上。poll 适合中小规模连接,也适合对可移植性有要求的场景,但它不是 epoll 的替代品。
3.3 epoll:把“每次全量查”改成“谁就绪谁上链表”
epoll 是 Linux 上的多路转接主力,设计上和 select/poll 最大的区别是:它把“注册关注哪些 fd”和“等待就绪事件”分开了。
注册阶段用 epoll_ctl,把 fd 加入 epoll 实例;等待阶段用 epoll_wait,只取已经就绪的事件。
c复制int epfd = epoll_create(1);
struct epoll_event ev = {0};
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++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// accept 新连接
} else {
// 处理当前就绪的连接
}
}
}
epoll 快的原因不是玄学,而是它把“找就绪 fd”这件事从每次调用时的全量扫描,变成了数据到达时的回调积累。内核在接收数据后会触发 socket 等待队列的唤醒逻辑,epoll 对应的回调会把该 fd 挂到 epoll 实例的就绪链表上。epoll_wait 返回时,只需要把这个就绪链表里的内容拷贝给用户,进程也只遍历这些真正有事的 fd。
所以 epoll 的复杂度更多取决于“有多少 fd 就绪”,而不是“总共注册了多少 fd”。大量空闲连接在线时,它的优势最明显。连接数少或者每个连接都高频收发时,epoll 比 select/poll 的优势就不那么悬殊,因为回调、链表操作也有成本。
3.4 三兄弟横向对比:选型看什么
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 经典 FD_SETSIZE 位图限制 | 无固定位图上限 | 受系统 fd 上限和内存影响 |
| 每次调用拷贝范围 | 整个 fd_set | 整个 pollfd 数组 | 主要拷贝就绪事件 |
| 检查方式 | 内核线性扫描位图 | 内核线性扫描数组 | 回调机制维护就绪链表 |
| 就绪 fd 少时 | 也要全量扫描 | 也要全量扫描 | 只处理就绪项 |
| 事件模式 | 水平触发 | 水平触发 | 水平触发和边沿触发 |
| 平台 | 几乎所有平台 | 大部分 Unix 平台 | Linux 专属 |
一句话总结:select 适合几百个连接的简单场景;poll 适合中小规模和要兼容 Unix 系平台的场景;Linux 上的大规模高并发,优先 epoll。很多跨平台框架(比如 libuv、Netty 的 native 实现)做的就是把这三者包装成统一的事件循环接口。
4. 从数据进入网卡到 read 返回:每个模型卡在哪一段
4.1 Linux 网络 read 的基本路径
以 TCP 包为例。网卡收到数据后通过 DMA 把数据放到内核内存的 ring buffer,触发中断;内核协议栈处理包,经过 TCP 解析后,把数据放到对应 socket 的接收队列里。进程调用 read/recvfrom 时,内核把接收队列里的数据拷贝到用户缓冲区。
这个过程里,“数据到达内核”和“数据拷贝到用户空间”是两个关键节点。五大IO模型的差别,恰恰就落在进程是怎么等这两个节点的。
4.2 五个模型在链路上的位置
阻塞式IO:进程调用 recvfrom 后直接睡眠,直到数据到达 socket 接收队列,内核唤醒进程,recvfrom 完成拷贝才返回。等待和拷贝都在系统调用里,业务方无感知。
非阻塞式IO:进程调用 recvfrom 时,如果 socket 接收队列没有数据,立刻返回 EAGAIN;如果有数据,才执行拷贝。等于把“等待”责任丢回给业务进程,让进程自己想办法决定什么时候再来。
多路转接:进程调用 epoll_wait 等待,内核把进程挂在对应 fd 的等待机制上;数据到达后,epoll 回调把就绪 fd 挂到 ready 链表并唤醒 epoll_wait。进程拿到就绪 fd 列表后,再调 read 完成拷贝。
信号驱动IO:数据到达后内核发 SIGIO 信号,进程在信号处理函数里再做 read,拷贝仍由进程发起。
异步IO:进程提交请求后直接返回,网络数据从到达内核到拷贝进用户缓冲区的整个过程都由内核推进,最后把完成事件交给进程。进程连“拷贝”这一步都不用亲自做。
所以,前四种模型都是“就绪通知”模型,异步IO是“完成通知”模型。这个区别不是措辞上的差别,而是系统调用流程和线程占用方式的本质差别。
4.3 就绪之后,read 为什么还可能要非阻塞
epoll_wait 返回可读事件,说明 socket 接收队列里有数据,这时候调 read 大概率不会阻塞。但在多线程环境、边沿触发模式、或者数据被其他消费者抢走的情况下,“就绪”并不意味着“read 一定立刻返回”。
最常见的例子就是 ET(边沿触发)模式。ET 模式下,内核只在 fd 状态发生变化时通知一次。如果你读完一部分数据但没有读完,内核不会因为缓冲区里还残留数据而再次通知你;只有新的数据到来才会再次触发。正确的做法是用非阻塞 fd 循环读,直到 read 返回 EAGAIN。如果 fd 是阻塞的,最后一次 read 就会直接睡在 socket 上,整个事件循环卡死。
这也是为什么几乎所有成熟的网络框架都会把连接 fd 设成 O_NONBLOCK,即使它们用的是 epoll。epoll 只负责第一层等待,真正的读写仍然按“有数据就处理,没数据就返回”的非阻塞方式来。
5. 实战选型:事件循环、LT/ET,和几个我踩过的坑
5.1 为什么主流方案是“非阻塞 + epoll”,而不是异步IO
现在看 Redis、Nginx、Netty、Node.js 这些服务,底层事件循环大多建立在 select/poll/epoll/kqueue 这类多路转接之上,搭配非阻塞 fd 使用。真正直接用异步IO做网络的场景,反而少很多。
原因有三:一是多路转接加非阻塞已经能支撑几十上百万连接的瓶颈主要在业务逻辑和内存,不在模型本身;二是异步IO的编程模型复杂,提交、完成、取消、内存所有权管理都要小心处理,普通业务团队很难驾驭;三是在 Linux 网络上,io_uring 这类异步机制虽然潜力大,但生态和运维经验还需要积累,不是所有项目都值得一上来就踩这个复杂度。
所以我的选型习惯是:先搞清楚自己的瓶颈在哪里。如果只是常规高并发的网络读?写,非阻塞 fd 加 epoll 事件循环是最成熟、最容易排查问题的组合;如果业务是大量磁盘 IO 或者需要极致压满硬件,再去研究 io_uring 异步提交。
5.2 LT 和 ET:水平通知与边沿通知怎么选
epoll 支持两种触发模式。
LT(水平触发)是默认行为:只要 fd 上有未读完的数据,每次 epoll_wait 都会把它返回。好处是逻辑简单,不容易漏事件;坏处是如果数据一直没读完,内核会反复通知你,可能造成不必要的系统调用和重复遍历。
ET(边沿触发)只在状态发生变化时通知一次:缓冲区从无数据变成有数据,或者又有新数据到达。好处是通知更精准,减少重复事件;坏处是要求你一次把能读的全读完,也就是循环读直到 EAGAIN,否则残留的数据可能要等下一批数据到了才会再触发。
一段标准的 ET 读循环是这样:
c复制set_nonblocking(fd);
for (;;) {
n = read(fd, buf, sizeof(buf));
if (n > 0) {
append_to_input_buf(buf, n);
} else if (n < 0 && errno == EAGAIN) {
break; // 当前数据读完了
} else if (n == 0) {
close(fd);
break;
}
}
新手建议先用 LT 把业务跑通,再考虑 ET 优化。LT 模式下即使偶尔读慢一点,也不会丢事件;ET 模式一旦漏读或阻塞,问题非常隐蔽。我见过线上服务偶发卡死,最后定位到的原因就是 ET 加阻塞 fd,某次读到一半没有数据,线程直接睡过去了。
5.3 我在线上遇到过的几个多路转接坑
第一个坑是 select 的 fd_set 没复制。每轮循环必须从 master_set 重新拷贝 read_set,因为内核会修改它。如果直接复用 read_set,第二轮到后面,很多 fd 会莫名其妙消失,事件越处理越少。
第二个坑是定义事件结构时没有初始化。epoll_ctl 注册前,struct epoll_event ev 最好明确 ev.events = EPOLLIN; ev.data.fd = fd;。尤其是 data 字段,很多人的 fd 或指针是靠它带出来的,漏了就容易处理到错误的连接。
第三个坑就是上面说的 ET 加阻塞 fd。我排查过一个连接建立成功、但业务请求迟迟不被处理的案例。后来用 strace 看到线程卡在 read 上,fd 没有 O_NONBLOCK,而代码在 ET 模式下只读了一次,剩下的数据没有被及时消费。
第四个坑是惊群。多个工作进程同时 epoll_wait 同一个 listen fd,新连接到来时可能多个进程同时被唤醒,但只有一个能 accept 成功,其他都白跑一趟。Linux 4.5 之后可以用 EPOLLEXCLUSIVE 减少这种唤醒,或者用 SO_REUSEPORT 让每个进程有自己的监听队列。这个问题在连接量大的时候会放大无效唤醒的开销,不能忽视。
5.4 小技巧:用 strace 看系统调用
遇到 IO 模型相关的问题,与其盯着代码猜,不如直接看系统调用。一条命令能暴露大部分问题:
bash复制strace -f -e trace=epoll_wait,epoll_ctl,read,recvfrom -p <pid>
观察 read 返回码:频繁 EAGAIN 是正常的,说明非阻塞读在等数据;如果一个线程长时间卡在 read 上不返回,而该 fd 又注册了 ET 模式,十有八九是用阻塞 fd 做了边沿触发读。这种定位方式比肉眼 review 代码快得多。
6. 把几个经常被绕晕的边界概念一次说清
6.1 阻塞/非阻塞和同步/异步不是一回事
阻塞和非阻塞描述的是调用者在等待中的状态:是原地睡觉,还是先返回干别的。同步和异步描述的是“谁来完成整件事、完成后怎么通知你”:是调用者自己等结果拿结果,还是内核完成后主动通知。
一个常见误解是“非阻塞就是异步”。非阻塞调用很可能仍然是同步的,因为它只是立刻返回,等数据真正来了你还得再发起一次调用,数据并没有被“安排好”。真正的异步,是你放心干别的,数据到自己到用户缓冲区,完成通知再回来。
6.2 select/poll/epoll 是就绪通知,不是异步IO
我把这条列出来提醒大家,是因为太多人在这翻车:epoll 只告诉你“fd 可以读了”,不负责把数据拷给你。拷贝还是由进程调 read 完成,所以它属于同步IO分类下的“多路转接”。
衡量一个模型是不是异步,唯一标准是:你是否拿到了“完成事件”,而不是“就绪事件”。io_uring 的 CQ 里放的是完成项,epoll 的 ready 列表里放的是就绪项。两者一字之差,模型类别天差地别。
6.3 一点个人经验:先 LT 跑通,再用 ET 优化
我自己的习惯是,新服务一律先用 LT 模式,把所有连接 fd 设成非阻塞,读写都用循环处理到 EAGAIN 为止。LT 模式下即使事件处理有遗漏,下一轮 epoll_wait 还会继续报,问题不容易毒化整个进程。
业务稳定之后,如果确实需要降延迟、减无效唤醒,再逐个模块切换成 ET,同时配套写清楚“读到 EAGAIN 才算结束”的约定。这看起来保守,但能帮你省下大量半夜排查时间。多路转接这个东西,真正难的不是 API 怎么调,而是理解“等待、就绪、完成”三个状态在系统里的位置。理解了这三层,再看任何事件循环框架,底层思路都是同一套。
