深入理解五种IO模型:同步、异步、阻塞与非阻塞全解析
发布时间:2026/10/19 10:19:15 锦皓数字建站

讲实话写了这么多年后端见过太多同学拿着IO模型对比表翻来覆去地背结果一遇到线上问题照样抓瞎。阻塞、非阻塞、同步、异步这四对术语就像四个长得差不多的兄弟搞不清楚的时候能把你绕到怀疑人生。但IO模型这个东西恰恰是理解网络编程、事件驱动、乃至整个高并发服务端架构的基石。这篇文章就用我自己的理解把五种IO模型从原理到代码再到实际选型彻底拆开揉碎讲一遍。内容主要围绕网络IO场景展开因为这是大家日常接触最多的入口每个模型都配了线程视角和内核视角的对照最后还会聊一个我在实际项目里遇到的排查案例帮你看清楚不同模型在真实系统中的表现差异。1. 先回答那个反直觉的问题同步不一定阻塞异步也不一定非阻塞我接触到IO模型这个概念时第一个困惑就是同步和阻塞到底有什么区别不止一个同事跟我说过“同步就是阻塞异步就是非阻塞这不是一回事吗”——还真不是。这里的核心差异在于这两组术语回答的是两个完全不同的问题。“同步/异步”回答的是发起请求的一方要不要亲自等待这次IO操作彻底完成。如果调用方发起IO后必须一直守着直到数据从内核搬到用户空间才算完这就是同步如果调用方发起之后就撒手不管由系统或另一个执行体去搬数据搬完再告诉调用方“完事了”这就是异步。“阻塞/非阻塞”回答的是条件不满足的时候线程要不要原地等。比如说你调用read()去读socket但内核缓冲区里目前一个字节都没有这时候阻塞模式会让你挂起非阻塞模式则直接返回一个“现在没数据”的错误码线程可以继续做别的事之后再回来轮询。这两个维度一组合其实能得出四种组合再加上IO多路复用就构成了最常见的五种IO模型分类模型同步还是异步阻塞还是非阻塞核心特征阻塞IO同步阻塞线程挂起等数据数据来了才返回非阻塞IO同步非阻塞立即返回EAGAIN/EWOULDBLOCK靠轮询拿数据IO多路复用同步阻塞等待事件单线程批量等待多个fd就绪就绪后再逐一遍历信号驱动IO同步非阻塞内核通过SIGIO信号通知数据就绪仍需应用主动读异步IO异步非阻塞内核把数据拷贝到用户缓冲区后才通知全程无需等待我这么一说你会发现“同步IO”其实占了四种包括我们常常用来做高并发的IO多路复用本质上也属于同步IO的范畴——因为它虽然能一次监控大量连接但真正调用read()把数据从内核搬到用户态的那一下还是当前线程自己完成的。这也解释了一个很多人死活想不通的问题为什么Netty快、Redis快他们不是异步吗——它们严格来说属于同步非阻塞模型基于多路复用真正意义上异步IO在主流系统里的工程化落地远比想象中复杂后面我会详细说。2. 五种IO模型的内核视角一次网络读请求到底经历了什么只看API层面的行为很多问题还是糊的。要真正理解五种模型的差异必须从内核的角度看一次read()从调用到返回期间到底发生了什么。2.1 read()调用的两个阶段我把一次网络IO的完整过程简化成两个阶段阶段一等待数据准备就绪。对于socket而言就是等待对端的数据包到达本机协议栈经过TCP重组之后放进socket接收缓冲区。阶段二数据从内核空间拷贝到用户空间。应用程序的缓冲区拿到真正的数据这一段的开销是内存拷贝也是同步IO中那个“同步”二字的物理来源。这两种模型之间的差异其实就看在这两个阶段里面应用程序和线程各处于什么状态。2.2 阻塞IO两个阶段全程挂起阻塞IO是我们最早接触、也最直觉化的模型调用read()之后如果对端还没发数据过来线程就睡着等内核把数据包收下来放进缓冲区再把缓冲区里的内容拷贝到我们传入的buffer拷贝完成后read()才返回。这里有个很关键的性能事实两个阶段的耗时真正耗费几微秒的还是第二次阶段内存拷贝而整个等待过程系统资源完全空闲线程空等不能干任何实事。如果这个线程是连接池里负责处理请求的工作线程等待期间整条链路都在空转。2.3 非阻塞IO阶段一主动轮询阶段二仍要等非阻塞模式下read()调用会立刻返回返回值可能是“数据还没准备好”。于是应用程序只能不断重试。这种方式把阶段一的等待成本交给了用户态去轮询每次向内核问“好了没”都有系统调用开销但有一个很重要的优点线程可以在等待数据期间去处理其他事。不过如果轮询频率太高用户态和内核态频繁切换CPU浪费会比阻塞IO更严重。如果轮询频率太低数据就绪后又有较长的延迟才被察觉到。2.4 IO多路复用内核代你轮询一次等待多个fdIO多路复用的思路是与其让应用一个连接一个连接地轮询不如把全部允许等待的fd交给内核让内核一次性告诉你“其中哪些已经就绪了”。select、poll、epoll都是这么个思路区别只是它们管理fd集合的方式和内核通知机制。这里的“阻塞”发生在select/poll/epoll_wait上——线程需要等内核返回一个“有fd就绪”的通知但等的是集合里的任意一个就绪而不是某个单独的连接。网络高并发的核心就在这一步线程数不随连接数增长而是取决于同时活跃的连接数。2.5 信号驱动IO内核通知你“可以读了”但还要自己动手信号驱动IO是相对小众的模型先给socket注册一个信号回调比如SIGIO当数据到达时内核向进程发信号进程在信号处理程序里调用read()把数据拷到用户态。它比多路复用更“异步”一点的地方在于应用程序完全不用主动查询而是等内核来通知。但要注意通知的只是在阶段一完成了也就是“数据已经就绪”阶段二的数据拷贝仍然要应用程序自己调read()去做所以说到底还是同步IO。2.6 异步IO两个阶段都由内核包办异步IO的终极目标就是你发起aio_read()之后什么都别管内核在数据到达并拷贝到你的用户缓冲区之后才通过回调或信号通知“全搞定了”。阶段一和阶段二都不消耗应用程序线程。工程上和理论上的差异经常在这里让人困惑在Linux上基于io_uring或传统libaio的磁盘异步IO是真正的异步IO但绝大多数网络IO场景里我们说的“异步”其实是用IO多路复用模拟出来的异步效果。2.7 一张图复盘五种模型下的“等待状态”模型阶段一数据就绪的等待主体阶段二内核态→用户态拷贝由谁执行线程是否可以被其他工作占用阻塞IO应用线程阻塞等待应用线程参与等待拷贝完成否非阻塞IO应用线程轮询非阻塞应用线程调用read()参与是轮询间隙IO多路复用阻塞在select/epoll_wait等事件应用线程调用read()参与否等待事件时阻塞信号驱动IO内核发信号通知应用不等待应用线程在信号处理器中read()是等信号期间异步IO内核在后台等待数据内核完成拷贝后通知是全程不参与我第一次这么对照完瞬间就明白为什么说“同步IO”和“异步IO”的分界线是阶段二只要阶段二需要应用线程参与哪怕等待过程完全不阻塞它依然属于同步IO。3. 结合代码场景拆解五种模型的实际形态与选型逻辑说完了理论必须落到代码上。这部分我会用伪代码和实际场景一一展开保证你看完之后能直接对应到工作里的具体实现。3.1 阻塞IO最直观的代码最粗暴的资源占用// 阻塞IO示例同步阻塞读取 int sockfd socket(AF_INET, SOCK_STREAM, 0); // 服务端、bind、listen省略 int connfd accept(sockfd, NULL, NULL); char buf[1024]; ssize_t n read(connfd, buf, sizeof(buf)); // 若对端一直不发数据线程挂在这 // n 0 时代表数据已经拷到了用户态 process(buf, n);在上面这段代码里read()这一行的代价很隐蔽它不会设置超时的。如果对端是个慢客户端发了TCP握手但迟迟不传业务数据这个线程就会一直“睡”在这里。遇到连接数上来每个连接卡一个线程线程资源几分钟就打满了。我参与过早期一个推送网关项目最初就是阻塞IO加线程池一个连接一个线程。当时瓶颈很明显连接数一千左右的时候线程数量还能撑住到五千以后线程上下文切换已经成为主要开销CPU大量浪费在调度上而不是处理数据上。那是我第一次意识到“阻塞IO 实现简单”但“简单”的代价是海量线程。阻塞IO的适用场景其实没有一些人想得那么不堪连接数很少几十级、每个连接数据吞吐又很高的时候比如某些内部管理工具、数据库连接池里的网络交互简单直接反而是优点。维护成本低不容易出bug。3.2 非阻塞IO把“等”变成“问”但别瞎问非阻塞IO的标准写法是这样的// 非阻塞IO示例主动轮询读取 int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); char buf[1024]; while (1) { ssize_t n read(sockfd, buf, sizeof(buf)); if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据未就绪稍后重试 sleep(0.01); // 实际开发里普遍加一个极小的时间片 continue; } // 真正的错误处理 } else if (n 0) { // 对端关闭 break; } else { process(buf, n); break; } }这段代码看起来让线程“有空干别的了”但如果你真的就这么干问题马上来那个sleep(0.01)是魔鬼。你sleep 10毫秒就要忍受最多10毫秒的延迟你不sleepcpu空转轮询浪费大量系统调用。非阻塞IO通常不会单独拿来构建高并发服务它更适合用在某些延迟敏感、单连接的异步流程控制中。比如你已经决定自己维护一套事件循环又没有选用select/epoll而是想凭用户态数据结构管理多个socket的情况下非阻塞模式就是基础设施。不过如果应用场景对“实用”没有执念只是学习理解的话非阻塞IO是理解多路复用内核机制必经的一站多路复用本质上是在非阻塞fd的基础上增加一个“谁来负责检查就绪”的专职角色——内核查。3.3 IO多路复用高并发服务端的真正主力// IO多路复用示例epoll下的同步非阻塞读取 // 创建epoll实例 int epfd epoll_create(1024); struct epoll_event ev, events[1024]; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); while (1) { int nfds epoll_wait(epfd, events, 1024, -1); // 阻塞等待就绪事件 for (int i 0; i nfds; i) { if (events[i].events EPOLLIN) { int new_fd events[i].data.fd; read(new_fd, buf, sizeof(buf)); // 此时数据已就绪不会被阻塞 process(buf); } } }核心逻辑和便宜方案的区别非常大epoll_wait这一行内核同时监听了所有注册到epoll实例的fd当任一fd可读时返回。这是一个巨大的模型级优化线程进入阻塞等待但等的不是某个单一的连接而是聚集在一起的一批连接中的任何一个。所以连接数可以很高几十万而活跃的业务线程数量只和活跃连接数正相关通常要压得很低。这里有一个很多人忽略的重点epoll_wait返回之后你拿到的是一批“就绪”的fd但你要逐个调用read()去消费数据。这个read()动作依旧是当前线程自己做。虽然理论上数据已经就绪了read()不会阻塞但这个“把数据从内核缓冲区搬到用户缓冲区”的动作依然占用当前业务线程的时间。所以IO多路复用本质还是同步IO——延迟阶段一被交给内核来观察但资源搬运阶段二仍然需要线程亲自动手。Netty的Reactor模型、Redis的单线程事件循环、Nginx的事件驱动架构核心骨架全是这一套。它们在多路复用的基础上加了用户态的事件分发机制所以看起来像“异步框架”实际上底层还是在同步地做read()和write()。只是这个同步操作的等待时间被管理得很好线程永远不会傻等。3.4 信号驱动IO好想法但工程上水土不服// 信号驱动IO示例注册SIGIO信号 // 伪代码重点看流程而非API细节 signal(SIGIO, sigio_handler); fcntl(sockfd, F_SETOWN, getpid()); fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL) | O_ASYNC | O_NONBLOCK); void sigio_handler(int signo) { // 此时只是“数据就绪”还要自己read() read(sockfd, buf, sizeof(buf)); process(buf); }信号驱动的执行路径是内核告知“socket可读” - 内核发送SIGIO信号 - 进程的处理函数被调用 - 处理函数执行read()。它相比阻塞IO的优点是进程不用卡在read()处相对多路复用它省略了select/epoll_wait这个步骤但引入了另一个问题信号处理的执行上下文非常受限。在信号处理函数里你没法安全地调用很多复杂逻辑。SIGIO每来一个信号就要打断进程当前的任何工作信号风暴和上下文打断在长连接高并发场景里很容易造成性能抖动。于是它成了一个退居二线的模型虽然理论上很优雅但是实际服务器框架里用得极少。如果你想拿它做学习案例我可以推荐但如果你想拿它上生产我建议你再认真评估。3.5 异步IO从磁盘到网络从理论到现实// 异步IO示例基于Linux AIO接口的伪代码 struct aiocb cb; cb.aio_fildes fd; cb.aio_buf buf; cb.aio_nbytes sizeof(buf); cb.aio_offset 0; aio_read(cb); // 发起后立即返回不阻塞 // 稍后检查结果或者注册回调 aio_suspend(cb, 1, NULL); if (aio_error(cb) 0) { ssize_t n aio_return(cb); // 数据已经被内核拷贝到buf里 process(buf, n); }异步IO里阶段一的等待和阶段二的拷贝都是内核和内核管理的上下文在后台默默做掉的主线程发起请求后马上可以去处理别的业务。真正意义上的异步IO在Linux世界分为两条路线传统AIOlibaio最开始主要是为高性能磁盘IO而设计的。io_uring新一代异步IO框架不仅支持文件和网络还能把多个操作组合提交性能上限更高。但工程现实是网络通信领域因为成熟的用户态事件驱动框架和各种网络栈已经高度优化直接使用异步IO的收益往往不如改造同步非阻塞模型来得明显——异步IO带来的内存管理复杂度、错误处理复杂度、与现有线程模型的对齐成本都很高。经典书籍对IO模型的讨论本身就处在一个非常理论化、理想化的语境里实际系统里绝大多数“异步服务”仍然基于多路复用。3.6 五种模型选型逻辑总结表应用场景连接规模推荐模型原因内部工具、管理后台几十以内阻塞IO简单直接维护成本低自研简单协议、需要精确控制极低并发非阻塞IO自主轮询避免线程堆积高并发网关、消息推送上万到数十万IO多路复用epoll线程数量稳定、等待被内核管理实时性要求极高且连接不多少见信号驱动IO理论延迟最低但工程风险高高吞吐磁盘IO、高性能网络框架海量异步IO彻底解放业务线程绕过同步拷贝瓶颈4. 一个真实排查案例同步非阻塞服务CPU跑满的完整复盘理论讲多了容易飘我拿一个亲身踩过的坑来对照一下。4.1 现象连接数不多CPU却烧到了95%某内部日志代理服务逻辑并不复杂从多个数据源接收日志条目聚合后转发到上层存储集群。连接数大概在两千上下并不算高但某次压测之后一个代理节点CPU直接冲到95%以上吞吐却不升反降整体请求耗时抖动明显。当时第一反应是网络带宽或磁盘瓶颈但监控显示磁盘IO很空闲带宽也未打满。CPU高而吞吐不高一看就是典型的“空转”症状。4.2 排查链路从业务线程到轮询逻辑先抓一下进程状态发现大量线程处于R状态可运行/运行中而不是S状态睡眠。再看锁竞争没有明显互斥锁热点。最后把排查重点落在了IO模型上。翻代码后发现这个代理模块用的是“非阻塞IO 高频率轮询自选消息队列”的方式来读取上游数据包的。问题就出在轮询策略上——收包循环里没有设置合理的等待时间基本就是for循环不停执行非阻塞read()直到所有fd都返回EAGAIN才算一把轮完然后立刻进入下一轮。在没有数据的时间段线程看起来在“工作”实际上是在反复进行read()系统调用然后收到“没数据”的错误码。CPU的全部时间都花在了系统调用和用户态切换上。我算了一笔账每个循环批次哪怕只有10毫秒的空窗一秒内就会产生上百次无谓的read()调用一次read()可能要几百纳秒到几微秒乘以两千个fd再乘以线程数CPU直接被白白烧掉。4.3 修复把“主动轮询”换成“事件驱动”修复方案并不复杂把这一段的非阻塞轮询改造为IO多路复用方式。具体来说是引入epoll实例把上游所有需要读取的fd注册进去业务线程阻塞在epoll_wait()上。只有fd可读时epoll_wait才会返回read()数据即可拿到。没有数据时线程睡眠不消耗CPU。改造后CPU占用降到20%以下吞吐和延迟都恢复到了正常水平。整个过程最值得记录的一点是那段时间大小和方向都堵对了但上游日志堆积的数据还是差点把内存打满好在我们顺手加了反压机制才拦住。4.4 这个案例教给我的三件事第一非阻塞IO不是免费午餐。它能让线程不被单个连接卡死但如果你没有配套的事件通知机制轮询这一层本身就可能成为性能黑洞。第二IO多路复用的价值不是万能而是把“等待”这种脏活还给了内核让应用线程只在真正有事可做时被唤醒。第三任何对IO模型的口头讨论最终都要落地到这个维度每个单位时间里多少CPU时间是被业务计算消耗的多少是被等待消耗的。5. 面试几个高频追问五个模型到底怎么串起来讲如果你是在准备面试或者梳理知识体系这几个追问基本覆盖了所有可能被问到的高频点。我直接给出回答思路不绕弯子。5.1 为什么异步IO在网络编程里很少直接使用而同步多路复用反而是主流直接原因在于网络IO两个阶段中的阶段一等待数据就绪多路复用已经把等待成本降得很低阶段二的用户态拷贝一次内存拷贝的成本大约在微秒级别相比网络传输的毫秒级延迟来说可以说是相当小。异步IO真正带来革命性收益的地方在于磁盘IO这种“就绪事件难判断、内核需要很深度的参与”的场景。网络编程里你把模型替换成异步IO可能收益很小复杂度却成倍增长。退一步说成熟的高性能框架早已完成了“物理模型同步业务模型异步”的包装通过真正的异步编程模式和事件循环把多路复用的同步内核掩盖在了灵活的API之下绝大多数程序员根本感知不到底层同步气息。5.2 阻塞、非阻塞、同步、异步这四个词到底怎么记最牢固我自己的记忆锚点是两句话阻塞和非阻塞说的是“你等不等”如果数据没来线程手足无措地等着就是阻塞线程可以走开去干别的只是要不时回来查看就是非阻塞。同步和异步说的是“谁干到完”把数据搬到你内存里的最后一步也得你自己干哪怕有内核通知就是同步整件事从“数据出现在内核”到“数据躺进你的buf”都由一个第三方干完后丢给你才是异步。把这两张轴叠在一起五种模型的相对位置立刻清晰了。5.3 select、poll、epoll该不该单独算一种IO模型可以算也可以不算。经典分类里它们通常被理解为IO多路复用模型下的具体内核工具。IO多路复用虽然名字里带“多路”但它依然属于同步IO的范畴因为read()的调用者依然是应用线程自己。很多人面试时脱口而出“epoll是异步的”我可以明确地说这个判断不准确。epoll是“等待过程高效化的同步IO工具”不是异步。5.4 是不是异步一定优于同步、非阻塞一定优于阻塞不是。IO模型没有绝对的优劣之分只有面对特定负载的合适程度。阻塞IO在处理小量高吞吐连接时最简单可靠异步IO在极高吞吐和大量并发时优势明显但代价是编程模型复杂、内存管理与生命周期控制要求高。我用多路复用解决过问题也用阻塞IO写过非常稳定的工具。真正决定一个系统的IO性能的往往是它的流量特征、扩展方案和资源管理策略而不是套一个“最先进的IO模型”就万事大吉。6. 基于经验的两条工程结论怎么把IO模型的认知用到实处说了半天理论落回实际操作层面。我对IO模型的认知最后转化成了两条工程结论用来指导我自己设计服务时的选型思路。第一条默认优先用事件循环 多路复用但不要排斥阻塞IO。如果你在做的是一个小型内部服务量级撑死几百个连接用阻塞IO搭配线程池代码直接明了调试起来也舒服。强行上异步模型反而浪费时间。如果你的服务可能会涨到上万连接那就趁早在入口设计上采用多路复用而不是等连接数追着线程数跑了再回头改。第二条真正高性能的服务最终要比拼的是“减少无谓系统调用”和“数据拷贝压缩”。IO模型的变化改变了“线程等待数据的方式”但没有改变等待期间系统调用的数量。即使在完美的异步模型里频繁的小包传输依然会让内核不停被呼叫。所以在做设计时不能只看协程、异步IO这些字眼更要关注批处理、内核旁路、共享内存、用户态协议栈这类真正减少数据搬运次数的方案。我处理过一个诡异的问题某个服务从阻塞模型换成了协程异步模型后吞吐确实涨了但拿perf一看系统调用占比依然高达70%。进一步定位才发现业务代码里每收一个小请求就会同步刷一次磁盘日志。这不是哪个IO模型能救得回来的问题纯粹是刷盘太频繁导致的内核上下文过载。查出根因后把日志做了合并批量写性能立刻又上了一个台阶。这也算我给所有想把性能做好的人的一句收尾建议先查系统调用和拷贝次数再回到IO模型表里找答案。最后说一个细节很多人用多路复用一段时间后有个共同疑问epoll_wait()返回的就绪集合里每次都要把所有fd扫一遍吗如果用epoll的EPOLLONESHOT或者配合用户态分片你完全可以把处理逻辑从“每次拿到所有活跃fd”改成“抢到连接后就由独立线程在接下来一段事件循环中持续负责这个fd”。这就是Reactor和Proactor模型后续要讨论的内容了。而这一大块内容的地基就是今天我们拆开的这五种IO模型。每个服务都在某个IO模型上运转着区别只是你知不知道它站在哪张对比表里。搞懂了这张表很多性能问题一眼就能看出来结症在哪。这也是我为什么坚持把这篇文章的口径定在“工程理解”而非“背概念”上——因为只有能落地到排查、压测和选型上的认知才算真正读懂了这五个词。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。