
1. 先把钓鱼和IO模型的关系理顺1.1 为什么讲IO模型要先讲钓鱼我最早啃《UNIX网络编程》里五种IO模型时啃得相当痛苦。阻塞、非阻塞、多路复用、信号驱动、异步每个词都认识放一起就分不清了。直到后来看到有人用钓鱼打比方一下就通了。这个比方其实特别精准因为一次完整的钓鱼过程和一次完整的IO操作在结构上惊人地一致你要等鱼上钩等数据就绪再把鱼拉上来把数据从内核拷到用户空间最后把鱼放进鱼篓用户程序处理数据。中间等多久、谁来等、鱼上钩了怎么知道、拉鱼这步谁来干——这些问题的不同答案正好就是五种IO模型的分类依据。所以这篇想写的不是把UNIX网络编程的章节翻译一遍而是用钓鱼这个场景把五种IO模型从头到尾串起来。我会先讲清楚一次IO操作在底层到底分几步再挨个讲五种模型对应的钓鱼方式最后给出实际选型建议。不管你是刚接触后端开发的新人还是被高并发面试题折磨过的同学这套思路应该都能帮你把这块硬骨头啃下来。1.2 一次IO操作在底层其实分两步网上的IO模型资料很多但很多没讲清楚一个前提一次IO操作到底发生了什么事。其实可以拆成两个阶段。第一阶段叫等待数据就绪。你发起一个read操作数据可能还在网卡里、还在网络上传输、还在内核缓冲区里排队进程需要等着这些数据抵达内核缓冲区。第二阶段叫数据拷贝就是从内核缓冲区把数据搬到用户空间缓冲区搬完之后你的应用程序才能真正拿到这些数据。为什么这个拆分这么关键因为五种IO模型的本质差别其实就是“这两个阶段分别由谁阻塞等待、由谁动手搬运”的排列组合。从这个角度看所谓的IO模型并不是什么玄学它就是一套分工方案。网卡把数据收下来放到内核缓冲区这个过程谁也干预不了但接下来发生的事就有讲究了你的进程是傻傻地等还是先干别的数据到了你是被通知还是自己去查最后把数据从内核搬到用户空间是应用程序自己来还是内核代劳这几个问题的答案直接决定了你属于哪种模型。1.3 两个关键维度阻塞/非阻塞、同步/异步这里必须先掰扯清楚两个容易被混为一谈的维度阻塞与非阻塞说的是“发起IO操作时调用者会不会被卡住”同步与异步说的是“数据从内核空间拷贝到用户空间这一步到底由谁完成”。阻塞和非阻塞相对好理解。阻塞就是你去钓鱼往那一坐鱼不上钩就什么都不干非阻塞就是你过来看一眼发现没鱼扭头就走过会儿再来看。同步和异步要绕一点。不管阻塞还是非阻塞只要最后那一步“把数据从内核缓冲区拷到用户缓冲区”是应用程序自己动手完成的那就是同步的。只有这步也交给内核去做了、做完再通知你的才叫异步。很多人把epoll当成异步IO这是错的epoll只是帮你监控“哪个鱼竿有鱼了”鱼还是得你自己拉。这四个词排列组合一下你就会发现非阻塞不等于异步同步也可以不阻塞。接下来的五种模型每一个都能在这个框架里找到自己的位置。2. 阻塞IO只盯一根竿鱼不咬钩绝不走2.1 最原始也最直观的钓鱼方式阻塞IO的钓鱼场景是这样的你拿了一把鱼竿找个水边坐下然后就一直盯着浮漂。鱼不咬钩你就一直等天塌下来也不挪窝。直到浮漂一沉你马上提竿、遛鱼、抄网把鱼捞上来。对应到代码里就是最常见的recvfrom或者read函数。线程调用recvfrom之后如果内核缓冲区里没有数据系统调用就不会返回。注意是压根不返回这个线程就挂在这条系统调用上CPU时间片被调度走线程进入睡眠状态直到数据准备好内核把线程唤醒recvfrom才返回。这里有个容易被忽略的点阻塞IO阻塞在哪个阶段其实两个阶段都阻塞。第一阶段等数据进内核缓冲区recvfrom不返回第二阶段把数据从内核缓冲区拷到用户空间recvfrom也还没返回。数据整个拷贝完了recvfrom才把结果交给你。2.2 一对一盯梢的致命问题阻塞IO最大的问题就是一个人同时只能盯一根竿。你想同时钓两处鱼那就得两个人来。对应到服务端就是经典的BIOBlocking IO模型一个连接配一个线程。每个线程阻塞在自己的连接上谁有数据就处理谁。问题马上就来了。假设一台服务器开1000个线程每个线程平时都在睡着等数据真正干活的没几个。线程本身要占内存线程切换要消耗CPU1000个还好1万个呢10万个呢线程数量一多光是上下文切换就能把CPU吃干抹净。我见过不少用BIO写的旧项目连接数一上千CPU使用率就飙到离谱但具体到业务处理能力又低得可怜大量CPU时间都浪费在线程切换上了。那么有没有办法让一个人同时盯几根竿这正是后面几种模型要解决的问题。2.3 阻塞IO没被淘汰的原因尽管阻塞IO被各种批评它依然大量存活在日常开发中原因是它简单到没有犯错空间。你的程序逻辑就是从上往下跑的调用recvfrom等数据回来拿到数据接着处理完全不用考虑“数据没到怎么办”这种分支。实际场景里很多低并发工具就是用阻塞IO写的。比如一个内网运维脚本需要从一个服务拉个状态信息或者一个一次性的数据迁移程序又或者连接数只有几个的管理端口。这类场景下线程数量可控、逻辑透明、出现问题好排查阻塞IO反而是最稳妥的选择。还有一个典型场景是同步IO搭配多线程主线程负责accept新连接来了连接就扔给一个专门的工作线程去阻塞读写。Java早期的Socket编程、Tomcat的BIO模式都是这么干的。在连接数不多、每个连接都是长连接且需要实时响应的场景下这种模式实现成本极低项目组随便一个初级开发都能维护。3. 非阻塞IO看完一眼就撤过会儿再回来3.1 不傻等的钓鱼方式非阻塞IO的钓鱼画风完全不一样。你拿了根竿往岸边一插然后该干嘛干嘛。隔个几分钟过来看一眼浮漂没动静扭头就走。你再过几分钟回来看发现浮漂沉了赶紧提竿。注意一个细节你回来看的时候如果鱼正好上钩了你就能立刻把鱼拉上来如果还没动静你没损失什么接着干别的就行。对应到代码里就是给文件描述符加上O_NONBLOCK标志这时候调用recvfrom如果内核缓冲区没数据它不会傻等而是立刻返回一个错误码通常是EWOULDBLOCK或EAGAIN告诉你说“现在没货”。如果缓冲区有数据它就正常把数据拷贝回来。这个过程里第一阶段是不等的没数据就立刻给你反馈。但第二阶段呢如果确实有数据要拷贝拷贝过程还是要占着系统调用等它完成的只是这个阶段通常很快体感不明显。3.2 轮询模式的血泪教训非阻塞IO看起来很美但纯用非阻塞IO写服务端很快会撞上一个叫“忙轮询”的坑。逻辑很简单你每隔一段时间就去看一次鱼竿但问题是隔多久合适隔太久鱼上钩了可能已经吃完饵跑了隔太短你基本啥也干不了一整天都在跑去看鱼竿。对应到代码就是程序不停地在for循环里调用recvfrom每次都问“有数据没”没有接着问。这个循环在用户态和内核态之间反复切换CPU占用率能直接打满但实际处理的请求可能寥寥无几。我踩过这个坑。早期用非阻塞socket写过一个转发程序想着这样能“响应更快”结果一跑起来CPU一直100%吞吐量还不如人家阻塞式的。数据没到的时候while循环里的系统调用是一个接一个地空转纯粹在给CPU找活干。3.3 非阻塞IO的真实定位所以非阻塞IO在实际工程里很少单独作为核心模型出现。它更像是一个基础开关真正的价值是配合其他机制使用。典型组合是多路复用加非阻塞读写epoll告诉你哪个fd准备好了你再去read/write这个fd时把它设为非阻塞防止极端情况下再次卡死线程。另外在用户态编程里还有一个常见的“非阻塞加事件循环”思路把IO对象设成非阻塞再配合一个事件分发器在Netty和Node.js这类框架内部都能看到类似的影子。即便轮询看起来很浪费但在单线程加协程的模型里非阻塞让协程可以方便地主动让出CPU这也是它没有退出历史舞台的原因。4. IO多路复用一个人看一圈鱼竿谁动收谁4.1 起点是“如何同时看一堆竿”阻塞IO的问题是“一人一竿”想管多根竿就得加人。那么如果我只派一个人让他同时看着几十根竿呢他得有个策略。最笨的办法是从第一根竿走到最后一根竿挨个看一遍哪根有动静就处理哪根看完一遍再来一轮。这就是select干的事。select把一个文件描述符集合交给内核说“你帮我盯着这一堆其中任何一个有数据了就告诉我”。内核会遍历这个集合检查每个fd的状态然后返回“有几个fd就绪了”以及具体是哪些。注意这个“盯着”本身是阻塞的但在阻塞期间它等的是“这一堆里任意一个就绪”而不是某一个。用一个线程就同时覆盖了大量连接。钓鱼的对应关系就是一个人拿了50根竿每隔一小会儿从第一根走到第五十根挨个看浮漂。看到哪根动了就拉哪根。他不需要50个人但也确实跑断腿——每次都要把50根竿全看一遍。4.2 select和poll为什么“挨个检查”不够用select有一个很经典的痛点每次调用都要把整个fd集合从用户态拷贝到内核态返回后你还要把所有fd再遍历一遍才能知道到底哪些就绪了。连接数一多这个全量拷贝加全量遍历的开销就很可观。另一个痛点是fd数量有上限默认能监控的数量级在1024左右对高并发场景完全不够。poll解决了一部分问题它用链表结构突破了fd数量上限不再有1024的限制。但它的核心机制没有变仍然是调用时传入一堆fd内核挨个检查返回后你再挨个查状态。复杂度还是O(n)连接数变多时依然线性变慢。这就像那个看50根竿的人每次都要全部看一遍不管其中49根是不是一直没动静。4.3 epoll改成了“登记簿模式”epoll的思路完全不同。它把钓鱼的人从“挨个巡视”变成了“等通知”。调用epoll_create时你在河边立了个登记簿。然后调用epoll_ctl把每一根鱼竿登记上去同时挂上一个小机关这个竿的浮漂有动静了就在登记簿上自己记一笔。最后调用epoll_wait人就坐在登记簿旁边等着这个等待是可以设超时的。登记簿上有人新增了标记你就知道对应的鱼竿有鱼了直接去拉就行。这套机制的关键在于内核不再每次从头到尾遍历所有fd而是由数据到达时触发回调把就绪的fd主动加入一个就绪链表。所以epoll_wait返回的时候你拿到的就是真正有事件的fd集合处理效率不再是O(n)而更接近O(就绪数量)。连接再多空闲连接也不会给你添负担。4.4 为什么epoll成了高并发的事实标准我们看现在的技术选型就很清楚Nginx在Linux上用的是epollRedis的单线程事件循环用的是epollNetty的NIO在Linux底层也还是epoll。它们共同的特征是海量连接绝大多数时间连接是空闲的但随时可能有少量连接活跃。这类场景epoll几乎是碾压级的合适。这就是为什么面试里总喜欢聊“epoll和select有什么区别”。核心差异不在于谁更高级而是复杂度模型变了select/poll是每次全量扫描epoll是事件驱动、按需通知。一个在看管1万根鱼竿时依然要从第一根走到最后一根另一个只需要处理真正有鱼的那几根差距自然越拉越大。需要强调一点epoll本身仍然阻塞在epoll_wait上数据拷贝仍然要应用程序自己用read完成。也就是说它属于同步IO并不是很多人误以为的异步。这个误区我在后面专门说。5. 信号驱动IO挂个铃铛响了再去5.1 钓鱼升级给鱼竿挂铃铛第四种模型信号驱动IO换了一种通知方式你不再去挨个巡视鱼竿也不在登记簿旁边等而是给每根鱼竿挂一个铃铛。你找个地方躺着睡大觉铃铛一响你起身去看看是哪根竿的铃铛然后拉竿。对应到系统调用层面就是给某个fd注册一个SIGIO信号处理函数然后通过fcntl设置该fd可以产生信号。之后程序不用管这个fd了继续做自己的事情。当内核缓冲区有数据时内核向进程发送一个SIGIO信号你注册好的信号处理函数被调用在里面去执行read操作把数据取回来。这个模型在第一阶段几乎不阻塞你不需要轮询也不需要等在内核的多路复用接口上进程该干嘛干嘛。鱼上钩了铃铛会主动通知你。5.2 理想丰满现实骨感为什么它实际用得少你可能会问这听起来比epoll还好啊为什么不流行真相是信号处理这块水太深了工程上很容易翻车。首先是信号处理函数的安全限制。你注册的这个处理函数跑在信号上下文里能做的事情极其有限很多系统调用和库函数都不是“异步信号安全”的。你如果在信号处理函数里直接调用read一旦数据还没完全准备好或者碰到锁的问题就可能产生无法预料的行为。我再把话说直白点在信号处理函数里做复杂IO基本等于在雷区里跳舞。其次是信号可能被合并。如果短时间内来了好几个信号内核可能只派发一个你就可能错过某些fd的事件。在网络高并发场景下数据包是持续涌入的信号合并几乎必然发生漏事件就成了常态。这也是它像是理论课里的完美模型却很少出现在实战代码里的核心原因。目前它更多见于内核、嵌入式及极少数对性能极其敏感且能精确控制信号语义的场景。在通用服务端开发里我几乎没有见过业务代码直接基于SIGIO实现高并发服务器。5.3 信号驱动和异步IO的本质区别信号驱动IO经常被人和异步IO搞混因为都有“被通知”的感觉。但关键差别在于信号驱动IO只是告诉你“鱼咬钩了”把鱼拉上来这步还得你亲自干异步IO则是内核把鱼处理完甚至收拾好鱼获直接告诉你结果。套用前面IO两步走的框架来看信号驱动在第一阶段不阻塞鱼上钩会发信号但第二阶段依然是同步的你还是要自己调用read去拷贝数据。异步IO则把两个阶段全部托管出去了。判断标准还是那句话数据从内核缓冲区到用户缓冲区这一步到底是谁动手。6. 异步IO把竿全部交给内核处理完通知你6.1 终极形态雇个专属管家替你钓鱼异步IO的钓鱼画面已经不需要你在河边待着了。你雇了一个经验丰富的帮手把几十根鱼竿全部交给他告诉他“帮我盯着鱼上钩了直接处理收拾好了告诉我一声就行。”然后你回家睡觉去了。帮手在河边守着哪根竿有鱼就提起哪根把鱼摘下来、处理好最后叫你一声“今天收获两条鲤鱼就在鱼桶里。”对应到编程里就是你发起一个异步IO请求比如在Linux下使用io_uring或POSIX的aio接口把fd、缓冲区地址、数据长度一次性告诉内核。然后你的程序立刻返回继续干别的事。内核在数据就绪后会直接把数据从内核缓冲区拷贝到你指定的用户空间缓冲区。拷贝完成后内核通过事件通知你“操作完成了”。这时候你的缓冲区里已经是现成的数据直接拿来用就行。这里两种感受是完全不一样的在多路复用和信号驱动里你收到通知时鱼还在水里只是上钩了在异步IO里你收到通知时鱼已经洗完切好放盘子里了。6.2 经典实现IOCP和io_uring异步IO的知名实现一个是Windows平台的IOCP另一个是Linux平台从5.1内核开始引入的io_uring。IOCP在Windows服务端开发中非常成熟它和epoll不同后者是“数据就绪了告诉你”前者是“数据已经帮你读进缓冲区了告诉你”。用IOCP写服务器时你提交一个异步读请求系统在数据到达并完成拷贝之后把完成事件投递到完成端口你的工作线程从完成端口拿到的直接就是读好的数据。所以IOCP常常被划为Proactor模式的代表epoll则是Reactor模式的代表。io_uring是近几年Linux异步IO的重大突破。它通过两个环形队列让用户态和内核态之间通信可以显著减少系统调用次数配合大量用户态注册的buffer能实现很高吞吐。最适合文件IO、NVMe SSD等存储场景下追求极致性能的服务。数据库、存储引擎这类底层组件对io_uring的拥抱越来越积极。6.3 为什么异步IO的性能上限最高从资源利用的角度说异步IO把负责等数据、拷贝数据的两个阶段全部交给了内核去调度用户线程从头到尾不阻塞自然可以把CPU时片都花在处理业务上。理论上它可以用最小的线程数支撑最高的并发。同样一条连接上的IO阻塞模型里线程从发起到完成一直在占着多路复用里等数据的开销转给了epoll但read拷贝时线程还是得等异步IO里线程提交请求后立刻能去处理另一个连接。在连接规模很大的场景下这种“零等待”带来的吞吐收益非常可观。但也别把异步IO神化。它的天花板高不代表所有项目都能吃到红利。如果你的业务本身就是CPU密集型的瓶颈CPU早满了IO模型怎么换影响都不大。IO密集且连接量大的项目异步IO才是真正的加速器。6.4 异步IO的代价复杂度和调试地狱异步IO最大的敌人是编程复杂度。在阻塞模型里你的代码是从上往下写的一个请求的时序在代码里一目了然。在异步模型里你发出去一个read操作你的业务逻辑被活生生拆成了两截发起前一段完成回调后一段。如果业务本身有依赖关系、有状态流转这种拆分会让代码可读性迅速恶化。我见过一个项目把异步回调链写得太深一旦出问题日志能看晕。更麻烦的是异步链路上的错误处理、超时处理、取消操作每一个都比同步版本复杂一个量级。很多团队不敢碰异步IO不是因为它不够快而是怕线上问题排查不了。所以选不选异步IO是个性能和成本之间权衡的问题而不是简单的“谁快用谁”。7. 五种模型一次性对照钓鱼过程逐阶段拆解7.1 一张表格看完五种分工前面的内容不少这个表格建议保存它把五种模型按“谁在等鱼”“怎么知道鱼来了”“谁负责拉鱼”三个维度做了对照。模型等鱼的方式知道鱼上钩的方式拉鱼数据拷贝由谁做阻塞阶段阻塞IO一直盯着竿亲眼看到浮漂动自己第一阶段、第二阶段非阻塞IO隔一会来看一眼亲自检查浮漂自己第二阶段IO多路复用同时盯一堆竿挨个检查/事件通知自己第二阶段等待就绪由select/Epoll负责信号驱动IO不去看竿铃铛响信号通知自己第二阶段异步IO完全不管管家通知结果内核无这张表的最后一行是理解“异步”关键词的核心数据拷贝都由内核做了用户程序只等最终结果。7.2 一眼判断模型归属的小口诀实战中判断一个IO模型不用背定义问三个问题就行。第一问调用方发起IO后会不会一直等结果等就是阻塞模型的基础特征不等就是非阻塞/多路复用/信号驱动/异步的方向。第二问数据就绪这件事是自己查、还是别人通知自己查就是非阻塞轮询或select/poll的检查方式被通知就是epoll、信号驱动、异步的范畴。第三问数据从内核到用户空间的拷贝是自己做还是交给内核自己做就是同步家族内核做就是异步。把三个问题串起来任何IO接口你都能快速定位。比如看到epoll_wait第一问它阻塞吗阻塞。第二问怎么知道就绪事件通知。第三问谁拷贝自己。那不是异步是多路复用。7.3 常见误区epoll不是异步IO这个问题我每次讲都要单独强调因为网上错误说法太多了。很多人看到epoll“事件通知”这个特点想当然地把它归到异步IO。但前面已经讲过异步的标准是数据拷贝由内核完成。epoll只是告诉你“fd可读了”数据还躺在内核缓冲区里你必须自己去调用read把它读出来。从钓鱼角度说epoll就相当于那个登记簿他告诉你“第3号竿和第7号竿有鱼咬了”但把鱼拉上来、遛一下、放进鱼篓这些事还是你自己干。异步IO呢是帮手把这些全干完了才来叫你。一个是一揽子全包一个只是情报通知性质完全不同。这个区分在面试里几乎是必考题背答案是没用的用钓鱼这个场景想一遍基本就不会忘了。8. 实战选型我的服务器到底该选哪种8.1 按连接规模和IO密集度选型如果是小型工具、脚本、内网管理端连接数撑死几十个直接用阻塞IO简单可靠出了问题也容易排查。这时候上异步只会增加混乱没有任何收益。如果服务只是几个外部客户端的长连接但业务比较简单也可以继续保持阻塞IO加多线程。Spring Boot默认的Tomcat在低并发时表现一直很稳定说明阻塞模型并不丢人看场景罢了。一旦连接数上到几千上万或者连接活跃度波动很大就要进入多路复用的领域了。Linux平台首选epoll这也是Nginx、Redis、Netty这些项目验证过的路线。Java里用NettyGo里用标准库的netpoller底层就是epollC/C里直接用epoll封装自己的事件循环。如果追求极致吞吐并且你的团队有能力驾驭异步编程那可以继续看io_uring。但在存储和高性能网关这类血海里卷之前先想清楚自己的业务是不是真有那么大的IO压力。8.2 选型真正要考虑的并不只是性能我在实际项目里见过不少团队掉进“性能就绪陷阱”明明业务量也就几千连接非要上异步IO结果开发周期拉长了好几倍线上问题要用性能工具细细排查。所以选型时我的建议是先问三个问题连接量级多大、IO活跃度多大、团队能否支付复杂度的成本。连接量级小阻塞IO就够了连接量级大但项目周期紧多路复用加非阻塞读写是折中的最佳方案连接量级大、IO频繁、团队也能扛住回调复杂度异步IO才值得下注。用Redis的例子来类比它用单线程加epoll就支撑了几十万级连接因为它把每个请求处理得极快IO等待的损耗被epoll压到了最低根本不需要上异步来榨取那点性能。8.3 我自己的经验从BIO到epoll再到异步IO的转变我之前维护过一个遗留服务用阻塞IO加线程池写的平时在线几十人没什么感觉。后来业务量上来了短连接一个接一个线程池被占满队列开始积压响应时间直线上升。当时第一反应是调大线程池调大后好了一会儿接着内存又不够了。后来改成epoll加非阻塞读写压测数据立刻好看了很多。这个经验告诉我IO模型选错了再怎么调参数都是隔靴搔痒。而异步IO这边我反而比较谨慎。有一个数据采集服务峰值和空闲差距巨大用异步IO确实把单机吞吐拔高了一截但代价是排查问题的时间明显变多。如果你不是特别需要压那最后一公里的性能我很建议先在阻塞和多路复用之间做选择多数业务到这里已经能解决问题了。异步IO是给那些真正活在性能高压线上的人准备的武器普通项目不必强上。最后分享一个实际建议不管选哪种模型先把“非阻塞多路复用”这条路走上两遍。把epoll搞透把NIO的Reactor模式整明白再去碰异步也不迟。很多看起来复杂的并发问题在“单线程多路复用状态机”的思路下其实都解得开。打好这个底子你再去看IOCP和io_uring时会发现它们的思路不过是在此之上更进一步而已。