拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度拆解五大IO模型:从阻塞到epoll,高并发服务如何少踩坑

深度拆解五大IO模型:从阻塞到epoll,高并发服务如何少踩坑

先聊一个我在面试里经常问的问题:一个read调用打到内核里,数据没到的时候,你的程序到底在等什么?这个问题看着基础,但能讲清楚的人真不多。很多人都会背“阻塞IO、非阻塞IO、多路复用、信号驱动IO、异步IO”,可真到线上服务扛不住、CPU飙高、连接一多就卡的时候,往往发现自己其实没搞懂这些模型之间的区别,更不知道从 select 到 epoll 到底改进了什么。

我个人做网络服务这些年,最大的体会是:五大 IO 模型不是五个孤立的概念,它是一条“怎么把CPU从等数据里解放出来”的演进路线。而多路转接,就是这条路上最关键的一站。这篇文章我会把五个模型逐个拆开,再重点把 select、poll、epoll 这三代多路转接讲透,最后放一些我实际踩过的坑和排查思路。不管你是刚起步看内核的开发者,还是已经在写高并发服务的工程师,这篇文章都能给你一个可以直接拿去对照实践的框架。

1. 为什么搞懂 IO 模型是基本功

1.1 一次 read 调用到底经历了什么?

一个最简单的阻塞read,看起来就是你调用它,它给你返回数据。实际上内核做了两件事:第一,等数据到达接收缓冲区;第二,把数据从内核缓冲区拷贝到用户态缓冲区。

阻塞就阻塞在第一步。对网络 socket 来说,数据从网卡到内核接收队列,中间要经过中断、协议栈处理、TCP 校验、顺序重组等一堆环节。在数据没到之前,发起read的线程如果选择等待,那它就只能挂在那,什么事情都不做。这就好比你去奶茶店点单,店员跟你说“稍等,前面还有三杯”,你就在柜台前站着,盯着操作台,既不玩手机也不走开——这就是阻塞。

非阻塞的情况是:你问店员“好了没”,她说“没好”,你转头去逛了一圈,过两分钟再回来问一次。这期间你确实做了别的事,但反复回来问,是有代价的。多路转接则是另一种思路:你把一百个订单号都告诉店员,让她好了就喊一声“哪个号好了”,然后你在休息区睡一觉,有声音了再过去取。这一下就把“一个人盯一个奶茶杯”变成了“一个人盯一百个奶茶杯”。

搞懂这个底层逻辑,比记住几个 API 重要得多。因为线上服务真正的问题往往不是“CPU 不够快”,而是大量线程堵在read上白白挂死,系统线程上下文切换被打爆。

1.2 五大模型怎么用一张表看懂?

先给一张总表,后续每一节再展开。这张表我建议你直接存下来,面试和方案选型都能用。

模型数据没到时的行为应用进程能继续干活吗数据拷贝由谁完成典型实现
阻塞 IO进程睡眠等待不能同步拷贝,应用参与等待read/recv
非阻塞 IO立即返回EAGAIN,应用轮询能,但要反复检查同步拷贝,应用参与等待O_NONBLOCK+read
IO 多路转接阻塞在select/epoll_wait,但一次等很多 fd能,被通知后处理就绪 fd同步拷贝,但只处理已就绪 fdselect/poll/epoll
信号驱动 IO注册信号,数据就绪时内核发信号能,信号到了再去读同步拷贝,应用收到信号后读取SIGIO/O_ASYNC
异步 IO发起请求后完全不管能,完成回调/事件通知内核完成全部读拷贝再通知io_uring/aio_*

还有一对特别容易混的概念:阻塞/非阻塞说的是发起 IO 的线程自己能不能被挂起;同步/异步说的是数据从内核到用户态的整个 IO 过程是不是需要应用参与到底。阻塞 IO、非阻塞 IO、多路转接、信号驱动都是“同步”的,只有真正的异步模型(比如 io_uring)能让你发完请求就不管,内核把数据都放到你的缓冲区之后再叫你。

2. 五大 IO 模型逐个拆解

2.1 阻塞 IO:最省心,也最贵

阻塞 IO 是默认套路。一个 socket 默认就是阻塞模式,accept会一直等到有连接进来,read会一直等到有数据。写代码的人是爽了,逻辑一行到底,但服务扛不住。

你写一个最简单的多线程服务,来一个连接开一个线程。连接少的时候没事,连接到几千个,线程数和 CPU 核数严重失真,操作系统为了切换这些等待中的线程要疯狂保存恢复上下文,这些线程大多数都阻塞在read上,一个数据都没等来。内存也被线程栈吃掉,一个线程默认栈 8MB,4000 个线程光栈就 32GB,服务器直接给你脸色看。

所以阻塞 IO 适合连接少、每个连接都有持续大流量传输的场景,比如少量长连接做文件传输。你要是想用它撑上万客户端,不是不行,但要把线程池、IO 超时、连接数量管理全部做得非常精细,成本远大于收益。

2.2 非阻塞 IO:从“死等”变成“反复问”

非阻塞 IO 是给 socket 加上O_NONBLOCK标志,数据没到的时候read立刻返回 -1,errno是EAGAIN或EWOULDBLOCK。这样单线程就能同时“盯”多个连接:每个都读一次,读不到就去干别的,回头再读。

但这个方案的致命伤是轮询。假设你有 10000 个连接,每次想知道有没有数据,就要对这 10000 个 fd 全部执行一遍非阻塞read或者用ioctl(fd, FIONREAD)去查可读字节数。大部分调用都是无功而返,CPU 被这种空转的系统调用烧掉了,性能反而更差。

非阻塞真正的价值不是单独用,而是作为多路转接的辅助手段:epoll 通知你“这个 fd 可读”之后,你用一个非阻塞的read去尽量把数据读完,读到EAGAIN就收手,这样既不会阻塞在某个连接上,也不会因为慢读拖垮事件循环。

2.3 信号驱动 IO:内核主动喊你,但细节劝退

信号驱动 IO 的思路是:给 fd 绑定SIGIO信号,数据就绪时内核发信号给进程,进程在信号处理函数里再去读取。听着比非阻塞轮询省 CPU,因为它不用反复“问”内核。

但现实里这个模型很尴尬。信号处理函数运行时的上下文限制很多,你不能在里面随便调用非异步安全函数;不同 Unix 系系统对SIGIO的语义还不完全一致,Linux 上对 socket 的支持和 BSD 系表现也有差异。写起来既要处理信号又要处理主循环,一不小心就把状态搞乱了。

在 Linux 服务端,信号驱动 IO 一直是“听说过、很少用”的状态。它算一种过渡思路,能帮你理解“内核主动通知”这件事,但真正把这个理念发扬光大的,是后面的多路转接和异步 IO。

2.4 异步 IO:发完请求就撒手

异步 IO 是终极形态。你发起一个读请求,不需要等待,不需要轮询,不需要信号,内核在数据到达并拷贝到你的用户态缓冲区之后,通过完成事件通知你。你全程不参与等待和拷贝,干别的事情去。

Linux 上早期的aio_read/io_submit主要面向磁盘 IO,限制不少。近几年真正把异步 IO 做出名堂的是io_uring,它用内核与用户态共享的 SQ/CQ 环形队列来提交请求和收割结果,减少了系统调用次数,性能非常猛。现在很多存储引擎和高性能网络框架都在向 io_uring 靠拢。

不过 io_uring 对内核版本有要求,实际落地时还要处理注册固定文件、固定内存缓冲区、内核线程池等一堆细节。现阶段做网络服务,主流的可靠选择仍然是 epoll;io_uring 更像是给未来备着的大杀器,等你真有百万级 IOPS 需求时再上。

2.5 模型的演进逻辑:从等一个到等一批

把五个模型串起来看,脉络很清楚:阻塞 IO 让线程等单个数据,非阻塞 IO 让线程反复查单个数据,多路转接让线程一次等一批数据,信号驱动让内核主动通知单个 fd,异步 IO 让内核把整个 IO 干完再通知。

演进的核心动力是“规避无效等待”。你希望 CPU 要么在算业务逻辑,要么在真正等到数据时被唤醒,而不是在一个个没数据的 fd 上空转。多路转接之所以至今还是网络服务的中流砥柱,正是因为它用“一个等待点管理海量连接”的方式,把无效等待的浪费压到了理论最低。

3. 多路转接第一代:select 为什么撑不住万级连接?

3.1 select 的原理和基本流程

select 的核心是把一批 fd 放进三个集合(可读、可写、异常),交给内核去等。内核会遍历这些 fd,检查它们有没有变化,然后再把结果写回集合。

使用流程大致是:FD_ZERO清空集合,FD_SET把 fd 放进去,调用select(nfds, &readfds, &writefds, &exceptfds, &timeout)。nfds要填最大 fd 加 1。返回后,用FD_ISSET(fd, &readfds)判断哪个 fd 就绪。

一个常见误区是 nfds 的填法。很多人直接填 1024,写大一点不影响?内核只看你填的 bit 范围,填大了会多扫描很多无效位;填小了又会漏掉高位 fd。正确姿势是遍历时维护一个max_fd,每次取最大的那个再加 1。

3.2 select 的三个硬伤

第一个硬伤是 fd 数量上限。fd_set在 glibc 里默认最多 1024 个 bit,也就是最多监听 1024 个 fd。你可以通过重新定义FD_SETSIZE再包含头文件来把这个值加大,但很多底层逻辑是按位图来的,改起来既不优雅也不可移植,生产环境根本不敢乱动。

第二个硬伤是每次调用都要重新填写集合。select 返回时会把内核就绪状态写回到同一个集合里,把你原来“想监听哪些 fd”的信息冲掉了。下一次调用前必须把所有 fd 重新FD_SET一遍,这个复制和遍历成本是实打实的 O(n)。

第三个硬伤是内核和用户态的扫描成本。内核要遍历所有 fd 判断是否就绪,返回后用户还要遍历整个集合找出到底哪个就绪。连接数一多,比如一万个 fd 里只有两个就绪,你却要翻一万次集合,浪费很严重。这就是 select 面对 C10K 直接力不从心的根本原因。

3.3 一个 select 的代码骨架

下面这个例子只演示骨架,重点是集合处理流程。为了聚焦,我省略了完整错误处理和地址初始化。

#include <stdio.h> #include <sys/select.h> #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <string.h> int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8080); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); fd_set read_fds; FD_ZERO(&read_fds); FD_SET(listen_fd, &read_fds); while (1) { fd_set tmp = read_fds; // 必须备份 struct timeval tv = {5, 0}; int ret = select(listen_fd + 1, &tmp, NULL, NULL, &tv); if (ret < 0) break; if (ret == 0) continue; // 超时 if (FD_ISSET(listen_fd, &tmp)) { int client_fd = accept(listen_fd, NULL, NULL); if (client_fd >= 0) { FD_SET(client_fd, &read_fds); // 后续还应该更新 listen_fd 与 client_fd 的最大值 } } } }

注意这个例子里我用了tmp = read_fds来避免集合被内核改写,这是每个写 select 的人都该养成的习惯。还有维护max_fd的逻辑没有写完整,实际项目里如果新增的 client_fd 比 listen_fd 大,必须同步更新 nfds,否则高位的 fd 永远等不到通知。

4. poll 和 epoll:多路转接的进化之路

4.1 poll:改掉了数量限制,但没改掉扫描本质

poll 比 select 更进一步,它不再用位图,而是用一个struct pollfd数组。每个元素包含fd、events(你要监听的事件)和revents(内核回填的就绪事件)。内核只改revents,不会破坏你原来的events,所以不需要像 select 那样每次重新设置。

数量上限也解除了,poll 理论上可以监听任意多个 fd,只要内存扛得住。它的事件类型更丰富,POLLIN、POLLOUT、POLLERR、POLLHUP用起来比位图清晰。

但 poll 的核心问题还在:内核每次都要把整个 pollfd 数组从用户态拷进去,然后线性扫描一遍所有 fd。返回后,用户也要线性扫一遍数组才能知道谁就绪。连接数越大,扫描成本越明显。也就是说,poll 优化了“易用性”,但没有优化“复杂度”,O(n) 的本质没变。

4.2 epoll:红黑树加就绪链表解决复杂度

epoll 和 select/poll 最大的区别是,它把“每次传入一批 fd”变成了“先把 fd 注册进内核,由内核长期维护”。这套设计由三个接口组成:

epoll_create创建 epoll 实例,epoll_ctl增删改 fd 的监听事件,epoll_wait等待就绪事件返回。用户注册过的 fd 会挂在一棵红黑树上,内核不需要再从用户态重新拷贝整个集合;同时内核在设备驱动层挂了一个回调,只要 fd 上有数据到达,回调就会把这个 fd 塞进就绪链表。

epoll_wait只需要把就绪链表里的那几个 entry 拷贝到用户态数组,复杂度从 O(n) 降到了 O(就绪数)。这就很漂亮了:一万个连接里只有两三个活跃,你只处理两三个。

4.3 LT 和 ET:要命的边缘触发

epoll 的工作模式有两种,很多人就是在这踩坑。

水平触发(LT)是默认模式。只要 fd 的缓冲区里还有数据可读,每次epoll_wait都会告诉你这个 fd 可读。这个模式的优点是你不怕漏读,这次没读完,下次还会通知你。缺点是如果数据频繁到来而你处理得慢,同一个 fd 会被反复唤醒,造成一定程度的忙唤醒。

边缘触发(ET)模式要在注册事件时加上EPOLLET。它只在 fd 状态发生“变化”时通知一次,比如缓冲区从无数据变成有数据。如果这次通知你只读了一部分数据,读完之后内核不会因为“还有剩余数据”而再次告诉你。你必须用非阻塞 fd,在EPOLLIN触发后循环调用read直到返回EAGAIN,把数据尽可能读完,否则剩下的数据可能要等下一次新数据到达时才触发,业务上很容易造成消息延迟甚至粘包截断。

我个人的建议是:新手做练习直接用 LT,能把业务写明白;线上追求性能再用 ET,但一定要配合非阻塞 fd 和严格的“循环读至 EAGAIN”逻辑。

4.4 一个可落地的 epoll 服务端骨架

下面这段代码不是能直接扔上线的高可用工程,但核心骨架是完整的,我用 ET 模式加非阻塞 fd:

#include <stdio.h> #include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <fcntl.h> #include <unistd.h> #include <string.h> #include <errno.h> static void set_nonblock(int fd); // 用 fcntl 加 O_NONBLOCK int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8080); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); set_nonblock(listen_fd); int epfd = epoll_create1(EPOLL_CLOEXEC); 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]; char buf[4096]; while (1) { int n = epoll_wait(epfd, events, 64, -1); for (int i = 0; i < n; ++i) { int fd = events[i].data.fd; if (events[i].events & EPOLLERR) { close(fd); continue; } if (fd == listen_fd) { while (1) { // ET 模式下 accept 也要循环处理 int cfd = accept(listen_fd, NULL, NULL); if (cfd < 0) break; set_nonblock(cfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); } } else { while (1) { // ET 模式下 read 必须读到 EAGAIN ssize_t r = read(fd, buf, sizeof(buf)); if (r > 0) { // 处理业务数据,这里只做展示 } else if (r == 0) { close(fd); break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; close(fd); break; } } } } } }

这段骨架里有两个容易被忽略的细节:一是 listen_fd 也设成了非阻塞,否则 ET 模式下 accept 循环可能被最后一个空连接卡住;二是read返回 0 表示对端关闭,必须从 epoll 状态里摘除并 close,否则会一直触发EPOLLERR或EPOLLHUP。

5. 实操选型、常见问题与避坑记录

5.1 不同场景到底选哪个模型?

选型不是越新越好,要结合连接规模、活跃度、业务是否重 IO。

场景特征推荐方案理由
连接量小(几十到几百),逻辑简单阻塞 IO + 线程池代码清晰,维护成本最低
连接量中等,但每个连接流量稳定poll 或 LT 模式 epoll避免 ET 带来的复杂读取逻辑
连接上万,但活跃连接比例低epoll + LT/ET事件驱动复杂度最优,空闲连接不消耗 CPU
连接多且每个连接频繁大数据读写epoll + 多线程 worker + 非阻塞读写分担,避免单线程事件循环被慢客户端拖死
磁盘 IO 密集,比如日志/存储引擎io_uring / 异步 IO内核完成拷贝后回调,减少同步等待

还有一个被问烂了的问题:既然多路转接监听的是 fd,而 fd 本质是内核对象,那 Redis 早期为什么用 ae 事件库包 epoll?答案是单线程模型下,事件循环把所有连接等好之后,命令处理还是同步的,这并不矛盾。事件驱动解决的是“等网络事件”的问题,不是“让业务逻辑异步”的问题。理解这个分界,能避免很多架构上的误判。

5.2 常见问题速查表

问题现象排查与解决
select/poll 连接数一多性能崩CPU 高,空转明显改 epoll,或者先查是否有 fd 没设置非阻塞导致 while 循环卡住
ET 模式下丢数据只 read 一次就处理,剩余数据被留在内核缓冲必须循环 read 直到EAGAIN,并用应用层缓冲拼接完整消息
epoll_ctl返回EEXIST重复添加同一个 fd先EPOLL_CTL_DEL再 ADD,或直接EPOLL_CTL_MOD修改事件
epoll_ctl返回ENOENT试图删除不存在的 fd检查 fd 是否已经被 close;多线程并发 close 容易导致这种问题
accept返回EMFILE进程 fd 数耗尽提高ulimit -n,并可先临时 accept 后立刻 close 来缓解;设计上要限制单连接资源
多个线程同时epoll_wait出现惊群,多个线程同时被唤醒使用EPOLLEXCLUSIVE,或每个线程独立一个 epoll fd,或用SO_REUSEPORT+ 分流管理
调用read返回 0对端已关闭这不只是“没有数据”,必须 close 并移除监听,否则会一直触发事件

5.3 几个我实际踩过的坑

先说 ET 模式下的消息截断问题。有一次我把一个服务改成 ET,结果上游发来的一条超大消息,在第一次触发时只读完一部分,处理逻辑却直接按完整消息解析了,剩下半截数据留在内核缓冲里。因为 ET 不再重复通知,服务就一直等,直到下一条新消息到来才触发,把两条消息拼在一起,彻底乱套。后来我把读取逻辑改成“循环读至 EAGAIN,再交给一个按长度拆包的应用层缓冲”,才算真正解决。这个教训很值钱:ET 省下来的唤醒次数,要靠更严谨的读取代码来保障。

再说惊群。多线程各持一个 epoll fd 还好,但如果是多个线程epoll_wait同一个 epoll fd,内核版本较老时,一个事件可能把多个等待线程同时唤醒,只有一个线程抢到 accept,其他线程白醒。线上压测时我看到这种问题特别头疼。解决办法是给 epoll 事件加EPOLLEXCLUSIVE,或者用多个 epoll fd 配合SO_REUSEPORT做多核分摊。后来我更喜欢后者,因为每个线程独立 epoll fd,还能避免跨线程处理连接时的锁竞争。

还有一次排查epoll_wait疯狂返回EPOLLERR,查了半天才意识到是连接被对端重置后,我又忘记从监听里移除。每次epoll_wait都会把这个坏 fd 扔回来,形成醒死循环。所以你在事件处理里看到EPOLLERR或EPOLLHUP时,第一反应不是读数据,而是清理资源。这也是很多新人的盲区。

5.4 压测和上线前的小经验

我自己每次写完一个事件驱动服务,都会先做三件事。第一,把所有新 accept 回来的连接都设成非阻塞,即使我用的是 LT 模式,也要防住某个连接在read时把整个事件循环拖死。第二,把listen_fd也设非阻塞,因为在 accept 循环里,如果最后一个 accept 刚好遇到空队列,阻塞的 accept 会卡住循环,导致后续连接全部处理不了。第三,编译时别忽略警告,多线程环境下一定要用accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)这类原子操作,避免 fd 泄漏和线程间竞态。

线程模型方面,如果业务逻辑里包含数据库查询、外部 RPC、复杂计算等耗时操作,不要在事件循环线程里直接做。事件循环只负责快速接收数据,然后把任务投递到工作线程池。等结果回来需要往 fd 写数据时,再通过事件通知主循环。这个模型的本质是把“IO 就绪”和“业务计算”彻底分层,你可以先按“事件循环 + 线程池”的模板起步,后面再加背压、限流、超时管理。

最后再分享一个细节:epoll 的timeout参数单位是毫秒,但这个精度并不准,它受内核调度和负载影响,实际唤醒可能晚几毫秒甚至几十毫秒。定时任务、心跳超时这类逻辑不要把宝全押在 epoll 的超时上,最好单独维护一个定时器堆,比如小根堆或时间轮,把最紧急的过期时间作为epoll_wait的超时值。这样既不用 spins,也不会因为 sleep 太久误了超时判断。

返回列表