五种 IO 模型与阻塞 IO

发布时间:2026/8/7 19:22:48
五种 IO 模型与阻塞 IO
从理论到编程IO效率问题的引入在掌握了socket套接字编程和网络原理之后我们接下来将目光投向一个实际工程中的核心挑战——高并发场景下的IO效率问题。IO 的理解IO为什么慢——物理层面的根源在计算机系统中IO操作往往被认为是相对较慢的这并非主观感受而是有其物理层面的根源磁盘IO磁盘是文件系统中为数不多的机械部件。读写磁盘时盘片旋转和磁头移动需要物理时间同时数据需从内存拷贝到外设整个过程的访问速度远低于内存操作。键盘等外设IO需要等待用户的物理输入其时间尺度对计算机而言极为漫长。然而从用户视角来看我们平时读写文件或操作界面时感觉挺快这是因为操作系统和硬件在多数情况下已经做了大量优化。但从系统层面审视IO的慢是客观存在的尤其在网络通信场景中这一感知更为明显。网络IO的本质操作系统内核与外设的数据交换网络IO的本质是进程通过操作系统与网卡这一外设进行数据交互。数据从网卡拷贝到内核的接收缓冲区再经由协议栈解包、分用最终拷贝到用户态的应用层缓冲区——整个过程涉及多次数据拷贝和上下文切换。以TCP通信为例客户端与服务器建立连接后服务端在应用层获得一个文件描述符socket fd随后便可通过该文件描述符进行读写操作。IO 等待 拷贝揭开慢的本质当我们调用read()函数时表面上是从内核读取数据但实际底层操作包含两个阶段等待Wait等待数据到达内核的TCP接收缓冲区。拷贝Copy将数据从内核缓冲区拷贝到用户态应用层缓冲区。核心洞察IO中的慢绝大多数时间消耗在等待阶段而非拷贝阶段。具体而言若对方尚未发送数据read()调用会在内核态阻塞直到数据到达或超时。若发送缓冲区已满或受流量控制限制write()调用同样会陷入等待。真正拖慢IO效率的不是拷贝数据的那一点点CPU开销而是漫长且不可控的等待时间。什么是高效的IO——减少等待的比重所谓高效IO并不是指单位时间内拷贝的数据量更大——这只是一个结果性的表象。其本质定义应当是在单位时间内尽可能降低IO过程中等待所占的比重。换言之IO 等待 拷贝高效IO的核心思路就是让等待的时间尽可能短、尽可能少地占用整体IO时间。这可以通过多种策略实现策略思路非阻塞IO调用立即返回无需等待数据就绪但需要轮询检查状态IO多路复用同时监控多个文件描述符只处理那些已就绪的IO事件避免空等信号驱动IO数据就绪时内核主动通知进程进程无需主动等待异步IO内核完成全部等待拷贝操作后通知进程进程完全不参与等待网络IO场景下的特殊挑战在网络通信中外设网卡位于远端数据的到达受网络延迟、拥塞、对端行为等多种不可控因素影响使得等待的时间更加难以预测。当连接建立后获取到文件描述符若对方暂未发送数据或数据仍在传输途中read()调用便注定要等待——这一等待在网络环境下尤为显著也让高效IO在高并发场景中成为决定系统性能的关键瓶颈。核心结论IO的效率高低本质上取决于等待时间在总IO耗时中的占比。高效的IO设计就是在保证正确性的前提下尽可能压缩等待时间让CPU的宝贵时间更多地用于数据处理而非被动等待外设就绪。这正是后续我们将深入探讨的多路复用、Reactor模型等高性能网络编程技术所要解决的根本问题。五种 IO 模型 --- 具体的提高 IO 效率的方案有了上面高效 IO 的理解之后我们下面的五种 IO 模型也都是在围绕着如何在单位时间之内减少 IO 等待的比重我们来将一个钓鱼佬的例子来讲这五种 IO 简单认识一下1. 阻塞 IO 模型张三去河边钓鱼他把鱼饵挂在鱼钩上然后把鱼竿甩进河里。张三坐在河边静静地看着鱼漂等待鱼上钩。在等待期间张三什么也不能做只能一直盯着鱼漂。一旦鱼漂动了张三迅速拉起鱼竿把鱼从河里钓到自己的桶里。这种钓鱼方式张三必须一直等待鱼上钩不能做其他事情这就是阻塞 IO。阻塞等待网络数据时休眠线程会挂载在网卡struct device驱动维护的等待队列阻塞 IO 是最常见的 IO 模型2. 非阻塞 IO 模型李四也来到河边钓鱼。他把鱼饵挂在鱼钩上把鱼竿甩进河里。但李四不想像张三那样一直等在那里他觉得太无聊了。所以李四把鱼竿插在河边自己掏出一本书看起来。每隔一会儿李四就放下书看看鱼漂有没有动静。如果没有鱼上钩李四就继续看书。这样李四可以在等待鱼上钩的时候做其他事情这就是非阻塞 IO。【需要反反复复检查鱼钩状态】原生死循环非阻塞轮询 IO线程始终留在就绪队列争抢 CPU 时间片高频系统调用带来大量上下文切换开销CPU 持续高占用效率极低。非阻塞 IO 往往需要我们加上循环来实现非阻塞轮询。如上图在两次 recvfrom 之间可以去做其他事情3. 信号驱动 IO 模型王五来到河边他是个聪明人。王五把鱼饵挂在鱼钩上把鱼竿甩进河里。然后王五从口袋里拿出一个铃铛把铃铛挂在鱼竿的顶部。王五想“谁钓鱼还自己一直盯着鱼漂啊让鱼上钩后自己来通知我就好了。”于是王五坐在河边掏出一本书看起来。当有鱼上钩时铃铛就会响起来。王五听到铃铛响头也不抬继续看书直接把鱼竿拉起来一条鱼就上钩了。王五把鱼放进桶里继续钓鱼。这种钓鱼方式鱼上钩后会主动通知王五这就是信号驱动 IO。【不需要反反复复检查鱼钩状态】一般关于 IO 方面的信号的策略机制是需要用户自己开启相关的选项的我们一般有 IO 到来的时候操作系统往往也会给进程发送 SIGIO 信号但是这个信号默认是被关闭的需要我们打开。我们知道我们可以通过调用 sigaction 来捕捉自定义动作 - SIGIO 然后进程就继续忙自己的其他事情了。操作系统怎么知道网卡中有数据了一个外设当条件就绪了就会向 CPU 触发硬件中断CPU 就会去执行中断向量表中的方法一般操作系统就会将外设的数据拷贝到内存当中这是之前的知识了在当今的计算机系统中随着硬件技术的不断发展出现了许多新型设备例如DMADirect Memory Access控制器。DMA是一种特殊的芯片它能够高效地将网卡接收的数据或外设产生的数据直接搬运到内存中。当DMA完成数据搬运并触发中断信号时操作系统会响应中断主动执行将外设数据拷贝到内存的操作。一旦数据成功拷贝到内存操作系统便知晓内存中有新的数据可供处理。此时内核会向目标进程发送SIGIO信号告知进程数据已经准备就绪。目标进程收到SIGIO信号后会调用其自定义的信号处理机制进而对数据进行后续的处理操作。【不足】信号驱动 IO 确实应用较少主要原因在于它存在一些明显的短板同时也有更优的替代方案。信号驱动 IO 的核心机制是通过信号来通知进程数据已经准备好。然而信号本身是通过位图bit map来表示的这意味着在多个IO操作同时完成时信号机制可能无法准确区分到底是哪一个IO操作完成了。例如当有多个网络连接同时准备好数据时信号驱动 IO 可能只会记录一次信号而无法区分是哪一个连接的数据已经准备好。这种机制的局限性使得它在处理复杂的多 IO 场景时不够高效和准确。相比之下其他 IO 模型如 epoll 等能够更精准地处理多个 IO 操作。epoll 通过维护一个事件列表可以精确地知道是哪一个文件描述符file descriptor上的IO操作已经完成从而避免了信号驱动 IO 中信号混淆的问题。因此在现代的网络编程和系统编程中epoll 等更先进的IO模型被广泛采用而信号驱动 IO 则较少被使用。后面会讲的啦4. 多路复用 IO 模型赵六开着车来到河边车里装了100根鱼竿。赵六把鱼饵挂在每根鱼竿上然后把鱼竿都甩进河里。赵六坐在河边开始观察这些鱼竿。赵六不需要一直盯着每根鱼竿他可以通过一种特殊的方式比如用一个助手帮忙观察来同时管理这么多鱼竿。当有鱼上钩时赵六的助手会告诉他哪根鱼竿有鱼。赵六拿起那根鱼竿把鱼钓起来放进桶里。这种钓鱼方式赵六可以同时管理多个鱼竿这就是多路复用 IO。IO 等 拷贝对于传统的接口将等 拷贝当一件事做了read / write / recvfrom /sendto ...而系统专门为多路转接提供了多路转接的系统调用接口 ---select由这种 select 函数只负责等一旦他等到特定的文件描述符上有数据就绪了那么就会通知对应的用户让用户再调用拷贝read / write / recvfrom /sendto ...select 接口是单独设计的和传统的read / write 等接口无关这些传统的接口一个只能难道一个文件描述符而 select 这种系统调用允许我们一次等待传入多个 fd。根究就绪的 fd在分门别类的调用传统接口epoll_wait本身确实是阻塞调用但它只阻塞一条线程等待成千上万个 fd传统阻塞 IO 是一个线程卡死一条 fd。二者阻塞的对象、资源开销完全不在一个量级这就是 epoll 高效的根本原因。在当前的高性能服务器领域这种方式几乎是必备选项5. 异步 IO 模型田七是个大老板他来到河边但不想自己钓鱼。田七叫来了他的小弟小王。田七对小王说“你去帮我钓鱼等桶里装满了鱼给我打电话。”田七把鱼竿和鱼饵都交给小王然后自己去开会了。小王开始钓鱼当桶里装满了鱼小王给田七打电话。田七接到电话后回来把鱼拿走。这种钓鱼方式田七只需要发起钓鱼的请求然后就可以去做其他事情小王负责完成钓鱼的任务这就是异步 IO。异步IO的本质应用进程只发起IO请求全程不参与等待和拷贝两个阶段。以aio_read以及更先进的io_uring为例发起请求应用进程调用异步IO接口向内核提交IO任务同时传入文件描述符、用户态缓冲区地址等信息。内核后台处理内核在后台独立完成全部IO工作——等待数据就绪并将数据从内核缓冲区拷贝到用户指定的缓冲区中。完成通知一切IO工作完成后内核通过完成队列CQ通知应用进程数据已就绪请处理。使用钓鱼比喻来理解田七应用进程把自己的鱼竿文件描述符、水桶缓冲区、电话通知机制交给小王内核让小王去帮自己钓鱼。小王全程负责盯鱼竿、收线、抓鱼、装桶。等鱼装满了小王打电话通知田七鱼已备好来拿吧。田七从始至终没有碰过鱼竿也没有等鱼上钩他只管处理已经准备好的鱼。关键结论异步IO中应用进程只发起了IO请求并未参与等待和拷贝中的任何一个环节。这与同步IO中线程阻塞等待数据就绪并执行拷贝有着本质区别。类型定义线程状态同步IO发起IO操作时当前线程执行流必须停下直至本次IO的数据传输彻底结束才能继续执行后续代码。阻塞异步IO提交IO任务后线程立即返回继续运行IO的等待、数据拷贝全部在内核后台独立完成与用户线程执行流完全解耦。不阻塞epoll虽然在高并发场景下表现出色但在操作系统IO分类的严格定义中它不属于异步IO而是同步非阻塞IO多路复用。为什么epoll仍然是同步的拆开完整流程来看步骤描述线程状态①线程阻塞在epoll_wait休眠等待文件描述符就绪阻塞②内核通知某个socket有数据就绪—③线程必须手动调用read()陷入内核执行内核缓冲区→用户缓冲区的数据拷贝本次read()执行期间线程阻塞④拷贝完成代码继续往下走恢复执行核心问题所在真正的数据搬运收尾工作——即数据拷贝——依然要由用户线程亲自入场完成。拷贝期间线程被阻塞无法干别的活儿。内核只帮用户做了等待数据就绪这一件事但最关键的IO收尾拷贝必须由用户线程亲自执行。这正是同步的本质——用户线程必须亲身参与IO数据搬运的收尾工作。io_uring是Linux内核中新一代异步IO接口与epoll有着本质区别维度epoll同步多路复用io_uring内核级异步IO提交任务需要等待事件就绪后调用read()提交IORING_OP_READ后立即返回等待数据内核等待数据就绪内核等待数据就绪数据拷贝用户线程调用read()同步拷贝内核后台主动拷贝到用户缓冲区线程状态拷贝期间线程阻塞全程线程不阻塞完成通知epoll_wait返回就绪事件数据已在用户缓冲区CQ中仅塞入完成标记io_uring的完整异步流程提交读任务IORING_OP_READ后用户线程立即返回继续执行业务逻辑。内核在后台完成全部工作等待网卡收包 → DMA写入内核缓冲区 →主动将数据拷贝到用户提前指定的内存中。全部工作完成后内核仅向CQ完成队列中塞入一条完成标记。用户线程拿到CQE时数据已经乖乖躺在自己的内存中无需再调用任何read()/write()系统调用。全程IO整套流程完全脱离用户线程用户线程全程不参与等待和拷贝——这才是标准定义的异步IO。那么问题来了问题1阻塞 VS 非阻塞阻塞会因为 IO 条件不具备导致阻塞卡住直到条件就绪。所以阻塞的本质就是什么都不做就是要等到条件满足对应在系统层面上就意味着这个进程在 read 的时候一旦 read 条件不具备操作系统不再调度这个进程了这个进程就从运行队列转到阻塞队列。非阻塞检测到 IO 条件不具备他会返回以某种形式出错返回等待下一次就绪【循环检测】所以阻塞和非阻塞的不同点是等待方式不同。但是钓鱼效率一样非阻塞 IO 效率高这是因为李四还看了书抖音但是这种 非阻塞 IO 效率高 说法是有误导性的阻塞和非阻塞在拿一只鱼杆钓鱼上钩其实是没有区别的鱼咬不咬钩和你看不看书抖音没有关系的阻塞和非阻塞在钓鱼效率上是没有区别的只是非阻塞做了更多的其他事情除非其他的事情是包含了其他的 IO 操作的张三李四....这些人就是进程鱼就是数据钓鱼就是在做 IO每一个鱼竿就是一个文件描述符河就是操作系统桶就是用户缓存区问题2谁的钓鱼效率最高毫无疑问肯定是赵六的钓鱼效率最高了站在鱼的角度抬头看有104个鱼饵我们咬赵六的杆子的概率是最高的这在单位时间内赵六等的比重就会很低问题3王五有没有等呢王五这个人通过鱼上钩来反向通知王五没有等待吧但是王五为什么还坐在河边呢王五只是不需要检测鱼竿所以王五在一定程度上也算是要等因为王五不负责检查但是要参与钓鱼的过程还需要参与钓的过程把鱼拉上来。结论4我们将阻塞非阻塞信号驱动多路复用 --- 这四种我们称为同步 IO自己都要参与 IO田七这种叫做异步 IO所以只要有人参与了 IO 不管是等待还是拷贝都算是同步 IO问题5同步 IO VS 异步 IO因为 IO 等 拷贝的所以凡是参与 IO 的等待或者拷贝的任意一个或多个阶段我们都称为同步 IO否则发起 IO 或者 IO 工作流和你的工作流无关就是异步 IO我们之前提到同步的时候是在讲线程同步线程同步是根据条件变量实现的让多个线程执行时有一定的顺序性但是以前谈的线程/进程同步和今天的同步 IO 是没有任何关系的线程/进程执行流同步是先后顺序的问题IO 同步是事后参与的问题两个同步概念双方差别巨大附录aio_read基于信号通知的用户态实现aio_read是 POSIX 标准定义的异步 I/O 接口在 Linux 上的实现glibc 的 POSIX AIO 库确实如您所说其底层机制并非纯粹的内核原生异步支持。它的工作模式通常依赖内核的信号Signal机制进行通知应用进程调用aio_read向内核提交一个异步读请求该调用会立即返回。内核或 glibc 的用户态线程池会接手这个请求等待数据就绪并执行读操作。当 I/O 操作完成包括数据拷贝到用户空间指定的缓冲区后内核会向应用进程发送一个信号Signal如SIGIO来通知其完成。您提到的“类似多进程信号通知”指的正是这种依赖用户态线程池处理请求最终通过信号回调通知应用的方式。这种方式虽然实现了“异步”但由于依赖信号和用户态的线程池在大规模、高并发的场景下其性能和扩展性存在瓶颈这正是您提到的它“不够纯粹”的地方。io_uring内核级的原生异步与共享内存io_uring则是 Linux 内核原生的新型异步 I/O 接口它从根本上改变了实现方式效率更高。它的“内核级”体现为以下几点内核原生支持io_uring的整个框架包括提交队列、完成队列等都在 Linux 内核内部实现和维护由内核直接管理 I/O 请求的生命周期无需依赖用户态的辅助线程。共享内存通信io_uring通过mmap系统调用在用户态和内核态之间建立共享的环形缓冲区Ring Buffer作为主要的通信方式而非反复调用系统调用传递数据。批量处理与轮询模式支持一次系统调用提交或收割多个 I/O 请求甚至可以通过内核轮询线程SQ Polling模式在特定场景下完全避免io_uring_enter系统调用开销。简单来说aio_read是“用户态发起请求通过信号获得通知”而io_uring是“用户态和内核态通过共享内存高效地传递任务与结果内核原生异步处理”。后者正是因为解决了前者等一系列痛点才成为当今 Linux 高并发场景下的主流异步 I/O 方案。下面我们就要开始深入谈论相关的 IO 的模型了重点在于非阻塞 IO 和多路复用/多路转接 IO