
聊到网络编程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模型第一阶段等待数据第二阶段拷贝数据通知机制线程/进程占用典型代表阻塞IOBIO进程阻塞等待进程阻塞拷贝无一连接一线程早期Tomcat BIO非阻塞IONIO轮询检查不阻塞进程阻塞拷贝返回EAGAIN单线程可处理多连接但CPU空转配合多路复用使用IO多路复用内核代监听通知就绪进程阻塞拷贝select/poll/epoll单线程能管理大量连接Nginx、Netty、Redis信号驱动IO数据到达时发信号进程阻塞拷贝SIGIO信号单进程可管理多连接不常用于TCP异步IOAIO内核等待内核完成拷贝后通知完成回调真正的异步回调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上把一个已经创建好的文件描述符设为非阻塞最经典的方式是fcntlint 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限制常为1024O(n)每次都遍历全部fd支持不支持Windows/Unix都常用连接数小简单场合poll无上限链表保存fdO(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才退出循环读直到EAGAINwrite返回部分字节后程序卡住没处理部分写和EAGAIN查看发送缓冲区大小用应用层缓冲 EPOLLOUT事件select最多只能监听1024FD_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 tracenetwork追踪系统调用看看read/write/epoll_wait的返回值、errno和时间戳。很多时候代码看似没问题strace一跑EAGAIN遍地都是你就知道该在哪层做优化了。IO模型不是什么高不可攀的理论它就是一组实用工具你多上手几次自然就熟了。