聊到网络编程,IO模型永远是绕不开的基本功。很多同事干了几年业务代码,一问五种IO模型还是只能背出名字,但真正到了定位高并发问题、调优服务时,往往卡在“非阻塞IO”和“IO多路复用”这对组合上。这篇我就把这些概念放到一个业务请求的真实流程里拆开讲,重点把非阻塞IO讲透,再顺手把IO多路复用、信号驱动IO和异步IO都串清楚。不管你是刚接触socket编程的入门者,还是写后端服务想搞明白Netty、Nginx、Redis那套并发模型的开发者,这篇文章应该能帮你省下不少自己翻内核源码的时间。
1. 五种IO模型全景图:从一次read开始
1.1 一次数据读取,到底经历了什么
想要彻底理解IO模型,得先把一次socket读取拆成一个两阶段过程。假设你的程序收到了一条客户端发来的数据,当我们调用read(sockfd, buf, len)时,底层实际上做了两件事:
第一阶段,等待数据准备好。数据从客户端的网卡进入内核协议栈,经过TCP/IP栈处理,最终落到socket接收缓冲区里。在此之前,应用进程拿不到任何数据,只能干等。
第二阶段,数据从内核拷贝到用户空间。内核把接收缓冲区里已经完整到达的数据,通过copy_to_user之类的方式复制到应用传入的buf地址。这一步同样需要时间,而且期间进程很可能被阻塞。
IO模型之间的差异,基本就是应对这两个阶段的方式不同。教科书上常说的“阻塞”和“非阻塞”,最核心的区别在第一阶段:阻塞IO是应用主动等内核,非阻塞IO是应用一直问内核“好了没”。而多路复用、信号驱动、异步IO,本质是在解决“谁来通知、怎么通知、通知之后谁干活”的问题。
我用一个生活化的类比来帮助记忆:你在一家餐厅门口排队等位,阻塞IO是你坐在候餐椅上,一步不动地等到服务员叫你;非阻塞IO是你每隔30秒跑去问一次“有位了吗”,没位就去做别的事;IO多路复用是餐厅门口装了一块电子屏,所有顾客都盯着屏,谁的号亮了谁就自己走进去;信号驱动IO是店家给你发短信“位子准备好了”,收到短信你才起身过去;异步IO则是你把点好的菜单交给店家,店家做好菜后直接端到你面前。这么一比,后面的内容就好理解了。
1.2 五种模型一句话速览
表格始终是最好的对照工具,我先给一张总表,后面再逐个展开。
| IO模型 | 第一阶段等待数据 | 第二阶段拷贝数据 | 通知机制 | 线程/进程占用 | 典型代表 |
|---|---|---|---|---|---|
| 阻塞IO(BIO) | 进程阻塞等待 | 进程阻塞拷贝 | 无 | 一连接一线程 | 早期Tomcat BIO |
| 非阻塞IO(NIO) | 轮询检查,不阻塞 | 进程阻塞拷贝 | 返回EAGAIN | 单线程可处理多连接,但CPU空转 | 配合多路复用使用 |
| IO多路复用 | 内核代监听,通知就绪 | 进程阻塞拷贝 | select/poll/epoll | 单线程能管理大量连接 | Nginx、Netty、Redis |
| 信号驱动IO | 数据到达时发信号 | 进程阻塞拷贝 | SIGIO信号 | 单进程可管理多连接 | 不常用于TCP |
| 异步IO(AIO) | 内核等待 | 内核完成拷贝后通知 | 完成回调 | 真正的异步回调 | Linux io_uring、Windows IOCP |
注意,这里的“阻塞IO”和“非阻塞IO”中,“阻塞”和“非阻塞”主要针对用户进程调用read后是否被挂起而言。到了多路复用阶段,虽然select/epoll_wait本身是阻塞的,但它能够同时等待多个fd,所以整体代价被摊薄了。这就可以引出下文的核心问题:非阻塞IO到底怎么用,为什么它单独存在的时候那么别扭。
1.3 阻塞与非阻塞的本质差异
说到底,非阻塞IO并不是什么高深魔法。把socket设为非阻塞后,每次调用read、write都不会一直傻等,而是立刻返回。如果数据没有就绪,read返回-1,并且errno被设为EAGAIN或EWOULDBLOCK。如果发送缓冲区满了,write也同样返回-1和EAGAIN。
这里有个非常常见的理解误区:很多人以为非阻塞IO等于“读写数据时不等待”,其实不对。非阻塞IO在第二阶段(内核把数据从内核空间拷贝到用户空间)仍然可能阻塞。也就是说,当内核确实有数据,正在做拷贝时,调用线程还是会同步等待拷贝完成。非阻塞IO只是把“等待数据到达”这个阶段变得可以脱身,而不是让整个IO流程异步化。
一句话总结本质:阻塞IO是被动等,非阻塞IO是主动查;但查这件事需要你自己反复去做,不然你根本不知道数据什么时候到。所以非阻塞IO常常要搭配IO多路复用,让内核替你去“查”,从而避免用户态反复轮询的开销。
2. 非阻塞IO的原理与实操:从EAGAIN说起
2.1 非阻塞IO是怎么工作的
从内核角度看,非阻塞IO的工作方式其实很朴素。调用read时,内核发现socket接收缓冲区为空,不会把当前进程挂到等待队列上,而是直接返回一个错误码。这个错误码不是fatal error,而是告诉用户“当前没有数据,你可以稍后再试”。在Linux上,这个错误码就是EAGAIN,有时也用EWOULDBLOCK宏定义(两者在多数平台上是同一个值)。
这里有一个很重要的点:非阻塞IO不仅适用于read,也适用于write和connect。write在非阻塞模式下,如果socket的发送缓冲区没有足够空间,会立刻返回EAGAIN,而不是让线程傻等到缓冲区有位置。connect则更有意思,非阻塞模式下的connect通常不会等待三次握手完成,而是立刻返回EINPROGRESS,表示连接正在建立。之后你需要通过select/epoll检测这个socket是否可写,才能判断连接是否建立成功。
实际工程里,非阻塞IO很少单独使用。原因很简单:你不可能写一个死循环无限调用read去轮询每一个fd,那样CPU会被直接打满。但理解它的基本行为,是用好多路复用和异步IO的前提。
2.2 把socket设置成非阻塞的三种方式
在Linux上,把一个已经创建好的文件描述符设为非阻塞,最经典的方式是fcntl:
int flags = fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);建议先把F_GETFL拿到的旧flags保留,再用|=方式加上O_NONBLOCK,而不是直接赋值。因为文件描述符的状态标志里可能还有其他位,直接覆盖可能弄丢已有状态。
第二种方式是在创建socket时直接指定:
int sfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);Linux内核2.6.27以后支持SOCK_NONBLOCK,一条语句搞定,省掉了后续fcntl调用,在多线程环境下还能避免临时窗口期。注意这个用法在某些老系统上不可用,所以如果你需要考虑老内核,还是用fcntl更稳妥。
第三种方式是用accept4接受新连接时直接给返回的fd设置非阻塞:
int cfd = accept4(lfd, addr, &addrlen, SOCK_NONBLOCK);accept4同样不是所有平台都有,但在Linux服务器上非常实用。如果你用accept接受连接,拿到的cfd默认会继承监听fd的一些属性,但不一定继承非阻塞标志,所以记得再fcntl一下。这块早期我踩过坑,以为设置了监听socket非阻塞,accept返回的socket也非阻塞,结果读出EAGAIN也没当回事,后来抓包才发现读fd是阻塞模式。
Python的socket更简单,直接:
sock.setblocking(False)这条语句本质上也是调用了fcntl设置O_NONBLOCK。Go语言则默认就是非阻塞内部加epoll封装,所以顶层模型又不一样。
2.3 非阻塞read与write的返回值细节
非阻塞读写返回值需要仔细处理,不然很容易出隐性bug。举几个典型情况:
- 调用
read(sockfd, buf, 1024),返回-1且errno == EAGAIN:当前没有数据可读,正常情况,不是异常,千万不要打印错误日志刷屏。 - 返回
0:表示对端已关闭连接。通常需要关掉这个连接,做相应清理。 - 返回正值
n:表示实际读到n字节,注意可能小于你请求的字节数。TCP是流协议,不是一次read就能拿完整包,可能需要循环读取。 - 调用
write(sockfd, data, len),返回-1且errno == EAGAIN:发送缓冲区暂满,应把未发送的数据缓存到应用层,等待fd可写事件再继续发送。 - 返回
n < len:写入了部分数据,剩余部分也要缓存后面再发。这叫做部分写(partial write)。
比较容易被忽略的是EINTR。如果进程收到信号,阻塞中的read可能返回-1并置errno为EINTR,表示“调用被信号打断”。稍后我会在问题汇总里专门说,很多新手在非阻塞代码里把EINTR当成故障处理,直接关闭连接,这是很冤枉的。
2.4 非阻塞IO容易踩的坑:忙轮询与CPU拉满
如果直接把socket设成非阻塞,然后在主循环里对每个fd不停轮询,代码大致是这样:
while (1) { n = read(cfd, buf, sizeof(buf)); if (n == -1 && errno == EAGAIN) { // 继续轮询 } else if (n > 0) { // 处理数据 } }这种代码看起来功能没问题,但在只有少数连接没数据时,read会反复返回EAGAIN,空转大量CPU时间。我曾经在一个压测环境里见过,一个非阻塞轮询服务器在无请求时CPU占用率直接冲到30%以上,两个核就满了。原因是高并发连接状态下,即便没数据,每个连接都在空转检查,白白消耗CPU。
正确的做法是不要自己轮询,而是用select/poll/epoll去挂起线程,等到某个fd就绪后才去读。这也正是IO多路复用存在的意义——把“有没有数据”的判断统一交给内核,用户进程睡觉,内核叫醒你干活。
3. IO多路复用:非阻塞IO的最佳搭档
3.1 为什么有了非阻塞还得有select、poll、epoll
刚才提到,非阻塞IO不能单独用,否则CPU空转。而select/poll/epoll提供了一种“同时监控多个fd”的能力。你可以把一堆socket fd丢给select或epoll,然后进程阻塞在select调用上,内核一旦发现某个fd有数据可读,就返回,你再对那个fd执行read。这样,等待这件事从“每个连接自己死等”变成了“一个监视者同时帮所有人盯梢”。
所以最流行的组合是:非阻塞IO + IO多路复用。epoll_wait返回就绪事件后,你调用的read因为socket是非阻塞的,所以基本不会把事件处理线程卡死,而且如果出现多线程消费事件的情况,非阻塞还能避免一个线程把所有数据读完导致另一个线程一直阻塞等待。
以Redis为例,Redis为什么能用单线程支撑大量连接?它的事件循环本质就是epoll + 非阻塞socket。主线程调用epoll_wait等待命令fd可读,然后执行命令写响应。如果某个client发送数据很慢,Redis不会为它单独开线程等待,而是等待下次可读事件。这里非阻塞写也重要,send即使返回EAGAIN,也可以把响应先挂在输出缓冲区,等可写事件再flush。这套思路是很多高性能网络库的地基。
3.2 select、poll、epoll怎么选
选型是一个老生常谈的问题,我直接给一个实用性对比。
| 机制 | fd数量限制 | 每次检查复杂度 | 水平触发 | 边缘触发 | 跨平台性 | 适用场景 |
|---|---|---|---|---|---|---|
| select | 受FD_SETSIZE限制,常为1024 | O(n),每次都遍历全部fd | 支持 | 不支持 | Windows/Unix都常用 | 连接数小,简单场合 |
| poll | 无上限,链表保存fd | O(n) | 支持 | 不支持 | Unix/Linux为主 | fd较多但性能要求不极端 |
| epoll | 无上限,内核事件表 | O(1)级,只返回就绪事件 | 支持 | 支持 | Linux独有 | 万级以上连接的高性能服务 |
实际项目中,Linux后端服务优先选epoll,因为它采用红黑树管理监听fd,用就绪链表返回活动事件,不需要每次把全部fd重新拷贝一遍到内核。而select每次调用都要把fd_set从用户态拷贝进内核态,还要在内核里遍历所有fd,一旦fd数量上千,性能会断崖式下跌。poll解决了数量上限问题,但时间复杂度还是O(n)。
如果你的服务只需要管理几十个连接,用select也完全没问题;但追求并发质量,就从epoll起步。Windows上有IOCP,那是另一套异步模型——真正异步IO在Windows的反响比Linux早很多。
3.3 水平触发与边缘触发,一个简单的例子
epoll有LT(水平触发)和ET(边缘触发)两种模式。理解它们最简单的例子是电梯门:水平触发好比电梯门开着,只要有人没进来,门就保持“提醒你进出”的状态;边缘触发好比电梯门只在刚打开的瞬间给一次“叮”的通知,你如果反应慢,门已经关上了,下次可能不再提醒,除非门再开一次。
放在网络事件里:
- 水平触发:只要socket接收缓冲区里还有数据,
epoll_wait就会不断返回可读事件。如果你一次没读完,下一次再调epoll_wait还会继续返回同一个fd可读。这样写代码简单,但可能导致一个慢速读频繁被唤醒。简单场景用LT完全OK。 - 边缘触发:只有当socket从“无数据”变为“有数据”时,才通知一次。如果你没把数据读完,剩余数据可能不再产生新通知,而你如果一直不读,后续新数据到达时可能又造成事件丢失风险。所以ET模式下,通常必须用非阻塞fd,并且在收到可读事件后一直循环调用
read直到返回EAGAIN,确保一次事件把数据尽量取完。
ET性能更好,但编程难一些。我的建议:新手先别急着上ET,先把LT配合非阻塞的模型跑通,比如用epoll LT管理一千个连接,体会一下事件循环;之后再切到ET,加上循环读直到EAGAIN的经验,就会理解为什么社区常说“ET + NIO才是高性能网络编程的入门姿势”。
3.4 一个简单的epoll事件循环骨架
这里给一个示意性质的代码结构(C风格伪代码),重点看流程,不追求编译完整。
int epfd = epoll_create1(0); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; // LT模式默认 ev.data.fd = listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listenfd) { // accept新连接(accept返回fd也设为非阻塞) } if (events[i].events & EPOLLIN) { // 循环或单次调用read,处理EAGAIN } if (events[i].events & EPOLLOUT) { // 发送缓冲可写,flush应用层缓存 } } }如果是ET模式,ev.events = EPOLLIN | EPOLLET;,然后在可读事件分支里加一个while (read(...) > 0),直到EAGAIN跳出。注意仅靠判断返回值还不够,read返回0表示关闭连接,需要单独处理。
4. 信号驱动IO与异步IO:理解高级模型
4.1 信号驱动IO:内核通知时机提前
信号驱动IO在Linux上的实现是通过fcntl把socket的宿主进程绑定为SIGIO信号的接收者,然后当socket上数据到达时,内核向进程发送SIGIO信号。进程可以在信号处理函数里调用read读取数据。
它的核心优势是“数据到达时有人通知你”,你不用在用户态轮询,也不用借助select内部遍历。但缺点也很明显:
- 信号处理函数不能做耗时操作,最好只是设置标志位或唤醒工作线程,否则容易丢信号。
- 高并发下信号可能频繁触发,进程长期忙于处理
SIGIO,系统开销很大。 - 如果TCP连接同时有可读和可写事件,信号触发场景不够精细,你很难知道这个信号到底是哪个socket产生的,除非每次都扫描全部socket。
- 信号处理本身就是异步的,再加上业务代码,调试复杂度高。
所以实际服务里,信号驱动IO很少用于TCP连接,偶尔在UDP或某些特殊设备驱动场景下能看到。它算是从“主动查”到“被动通知”的一次尝试,但由于信号机制天然粗糙,很快被多路复用和异步IO取代。了解它的位置就好,别轻易上生产。
4.2 异步IO:真正用回调收尾
前面所有模型,第二阶段“把数据从内核拷贝到用户空间”都是进程自己发起的,所以即使非阻塞、即使被信号通知,进程在读取时还是会阻塞,直到拷贝完成。异步IO则把这一步也包揽了。进程发起aio_read或io_uring_read时,把文件描述符、缓冲区地址、长度等一次性交给内核。内核等待数据到位,然后自己完成数据拷贝,最后通过信号、回调或者完成队列通知进程。整个调用过程中,进程完全没有阻塞,数据已经躺在你给的缓冲区里了。
举个抽象的比喻:前面是“位子好了叫你过去吃”,异步IO是“你点餐后直接等外卖送到家”。
Linux上早年标准异步IO生态比较混乱,POSIX AIO在glibc里有各种限制。后来io_uring出现,借助共享内存环形队列,成了现代Linux异步IO的主流方案。很多云原生存储系统和数据库也开始在关键路径上使用io_uring。如果你主要写网络服务,其实异步IO在网络socket上并不像磁盘IO那么普及,因为epoll + 非阻塞IO在大多数网络场景下已经能跑得很好,再上异步IO会复杂不少。但如果你做的是文件IO密集型服务,或者是高性能网关,值得认真研究io_uring。
4.3 五种模型的取舍对比
理解了五种模型后,最后做一个硬核对比,方便在架构设计时选型。
| 模型 | 等待阶段会阻塞吗 | 拷贝阶段谁来做 | 并发策略 | 用户态复杂度 | 典型性能瓶颈 |
|---|---|---|---|---|---|
| 阻塞IO | 阻塞 | 进程 | 一连接一线程 | 低 | 线程开销极大 |
| 非阻塞IO | 不阻塞 | 进程 | 轮询/忙等 | 中 | CPU空转,容易消耗光 |
| IO多路复用 | 阻塞但可同时等多个fd | 进程 | 事件驱动单线程或多线程 | 中高 | 每次读拷贝可能成为瓶颈 |
| 信号驱动IO | 不阻塞,但信号通知不精细 | 进程 | 信号+线程 | 高 | 信号处理开销 |
| 异步IO | 不阻塞 | 内核 | 回调/IO队列 | 高 | 内存和队列管理复杂 |
选型建议直接给结论:常规并发网络服务,选epoll(多路复用)+ 非阻塞IO;追求极致的低延迟和高吞吐,研究io_uring异步IO;只是写个工具脚本或串行传输程序,阻塞IO简单直接,别为了“非阻塞”而自找麻烦。
5. 实战中的经验总结:问题排查与性能心法
5.1 我踩过的几个坑
代码写多了,多路复用下的坑是一坑连一坑。我按踩过的顺序记录三个最值得说的。
第一个坑是“只设非阻塞但不处理EAGAIN”。潜意识里总以为read有数据一定会返回正数,没数据返回0,于是把返回-1也顺手当成错误关闭了连接。结果是高并发空闲连接经常被误断开。正确姿势是:返回-1时优先看errno,只有EAGAIN/EWOULDBLOCK才算正常无数据,EINTR要重试,其他错误才考虑关闭。
第二个坑是“ET模式下没有循环读到EAGAIN”。我早期照猫画虎用ET,但收到EPOLLIN事件后只读一次缓冲区,如果一次没读完,后续数据到达可能不会再触发新事件,表现就是偶尔丢报文,线上排查了很久。后来改成循环读直到EAGAIN,才彻底解决。记住:ET模式下EAGAIN不是失败,而是结束标志。
第三个坑是“epoll惊群”。多进程/多线程都调epoll_wait监听同一个fd时,一个事件会唤醒多个等待者,但只有一个能处理好socket接受或读取,其他被唤醒的线程只能空转。早期Nginx就经历过这个,后来用EPOLLEXCLUSIVE或SO_REUSEPORT分摊。实际编码时,要么用一个专门的线程做accept,要么用EPOLLEXCLUSIVE做事件分发。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查步骤 | 解决建议 |
|---|---|---|---|
| read返回-1后连接频繁断开 | 把EAGAIN当错误处理 | 打断点或日志打印errno | 区分EAGAIN/EINTR/真实错误 |
| CPU占用高但连接数不多 | 非阻塞IO忙轮询 | perf查看热点函数 | 改用select/epoll阻塞等待 |
| epoll事件丢失 | ET模式没循环读 | 检查read是否读到EAGAIN才退出 | 循环读直到EAGAIN |
| write返回部分字节后程序卡住 | 没处理部分写和EAGAIN | 查看发送缓冲区大小 | 用应用层缓冲 + EPOLLOUT事件 |
| select最多只能监听1024 | FD_SETSIZE限制 | 查看系统头文件 | 换poll或epoll |
| 连接数多了后性能下降 | select/poll遍历fd太多 | 压测和strace调用开销 | 迁移epoll,合理设计事件表 |
这个表虽然简单,但都是我实际项目里碰过的,建议收藏。很多情况下,一个“偶发”问题往往就藏在这些返回值细节里。
5.3 给入门者的实践路径
如果你刚开始接触这块,别急着直接上手epoll。推荐的路径是:
先写一个最简单的阻塞式echo server,理解accept、read、write的阻塞行为。然后把socket设为非阻塞,直接在一个循环里轮询几个连接,感受一下CPU空转和EAGAIN是什么样的。再用select把轮询改掉,这时你会理解为什么多路复用能解决忙轮询。最后上epoll,并且尝试从LT切到ET,体会两种模式的差异。整个过程花一个周末就能有体感,比啃十遍书都管用。
我自己带新人的时候,通常会布置一个小任务:让一个单线程服务器用epoll管理1万个模拟连接,处理简单的echo请求,并且要求CPU占用率不能持续高于10%。能完成这个任务,你基本就掌握了IO多路复用和非阻塞IO的核心玩法。等你能把这套模型迁移到理解Netty的EventLoop、Nginx的worker进程上,说明你已经不是停留在概念层面了。
最后再分享一个小技巧:调试非阻塞IO问题,一定要学会用strace -f -e trace=network追踪系统调用,看看read/write/epoll_wait的返回值、errno和时间戳。很多时候代码看似没问题,strace一跑,EAGAIN遍地都是,你就知道该在哪层做优化了。IO模型不是什么高不可攀的理论,它就是一组实用工具,你多上手几次,自然就熟了。