新闻详情

新闻详情

首页 / 资讯中心 / 详情

11、五种 IO 模型与阻塞 IO

发布时间:2026/6/7 2:29:10
11、五种 IO 模型与阻塞 IO
目录五种 IO 模型阻塞 IO非阻塞 IO信号驱动 IOIO 多路转接异步 IO小结高级 IO 重要概念同步通信 vs 异步通信(synchronous communication/ asynchronous communication)阻塞 vs 非阻塞四个概念的关系其他高级 IO非阻塞 IO实现函数 SetNoBlock轮询方式读取标准输入五种 IO 模型阻塞 IO在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认 都是阻塞方式阻塞 IO 是最常见的 IO 模型。1. 发起请求与阻塞应用进程 - 内核动作应用进程发起recvfrom系统调用请求读取数据。状态由于内核此时“无数据报准备好”无法立即返回结果内核会将该应用进程挂起阻塞。进程阻塞于 recvfrom 的调用。此时 CPU 会切换到其他就绪任务不会浪费在空等数据上。2. 内核等待与拷贝内核内部处理这一阶段是耗时的主要部分等待数据内核通过网络接口持续监听直到数据报到达并被处理为“准备好”的状态。拷贝数据内核将准备好的数据从内核空间Kernel Space复制到操作系统的内核缓冲区中。3. 数据交付与唤醒内核 - 应用进程动作内核完成数据在内核缓冲区的准备后将数据从内核空间拷贝到用户空间User Space的应用进程内存中。结果拷贝完成后内核向应用进程发送“返回成功指示”。后续进程被唤醒解除阻塞状态开始执行下一行代码“处理数据报”。这种模型的特点是简单直观但缺点是应用进程在数据准备和拷贝期间是完全停滞的无法执行其他任务阻塞到底“阻塞”在哪里进程被挂起CPU 会去执行其他进程 —— 所以阻塞 I/O 本身不浪费 CPU但会降低单个线程的吞吐量因为线程闲着等数据。阻塞 I/O 的适用场景并发很低但每个连接需要大量计算比如数据库连接池每个线程处理一个客户端逻辑复杂。代码简单没有复杂的状态机适合快速开发。阻塞 I/O 的致命缺点如果要同时处理多个连接必须用多线程/多进程每个连接一个线程。线程数过多会导致内存开销大、上下文切换频繁、CPU cache 命中率下降。C10K 问题正是阻塞 I/O 一连接一线程无法解决的根源。默认所有的套接字都是阻塞的listen的监听 socket 阻塞accept阻塞recv/send阻塞。但阻塞行为可以在不同函数上单独控制例如accept阻塞等待新连接但可以用select先检查再调用accept保证立即返回。阻塞 I/O 在内核中的大致实现调用recvfrom→ 系统调用 → 内核检查 socket 接收队列 → 无数据 → 将当前进程加入该 socket 的等待队列 → 调度其他进程运行 → 数据到达唤醒该进程 → 拷贝数据 → 返回。补充一阻塞 I/O 的多线程模型与 C10K 问题的关联阻塞 I/O 虽然编程简单但当需要同时处理大量客户端连接时传统的“一连接一线程”模型会迅速暴露严重的扩展性问题。这就是著名的 C10K 问题——如何在一台物理服务器上同时处理 10 000 个网络连接。在阻塞 I/O 下常见的服务端实现如下while (1) { int client_fd accept(listen_fd, ...); // 阻塞获取新连接 pthread_create(tid, NULL, handler, client_fd); // 创建线程处理 }每个线程负责一个连接在recv或read上阻塞等待数据。当连接数增长到 10 000 时内存开销每个线程默认栈大小 8 MB可通过ulimit -s调整但仍不小10 000 个线程仅栈空间就需要约80 GB内存远超普通服务器配置。调度开销操作系统调度器需要管理上万个可运行/阻塞线程频繁的上下文切换导致 CPU 有效利用率急剧下降系统 load 飙升。资源限制/proc/sys/kernel/threads-max和pid_max限制了最大线程/进程数即便修改上限内核的task_struct和内核栈开销也会耗尽内存。因此单纯的阻塞 I/O 多线程模型无法支撑 C10K 场景。这也正是 I/O 多路复用select/poll/epoll诞生的直接原因——用一个线程管理成千上万个连接彻底解决线程爆炸的问题。补充二阻塞 I/O 多线程 vs epoll 对比表对比维度阻塞 I/O 多线程epollI/O 多路复用非阻塞 I/O并发模型一个连接一个线程一个线程管理多个连接等待数据时线程状态每个线程阻塞在recv上线程被挂起主线程阻塞在epoll_wait其他工作线程可处理业务连接空闲开销每条连接占用完整线程栈MB 级 TCB仅占用一个文件描述符 应用层少量结构通常几十到几百字节连接数上限受限于线程数通常几千理论上受限于系统文件描述符上限可配置数十万CPU 使用空闲连接不占 CPU阻塞但大量线程切换增加开销就绪事件驱动无无效轮询CPU 利用率高编程复杂度低但需处理线程安全如锁、条件变量较高需要状态机、非阻塞、缓冲区管理、粘包处理典型应用数据库连接池、少量长连接几十到几百高并发 Web 服务器Nginx、聊天服务器、游戏网关补充三阻塞 I/O 不浪费 CPU但浪费内存和调度开销很多初学者认为“阻塞 I/O 一直卡在那里肯定很消耗 CPU”。实际上阻塞 I/O 在等待数据时线程被操作系统挂起CPU 会去执行其他进程或线程。也就是说阻塞本身并不导致 CPU 空转或繁忙。但它的代价隐藏在其他地方内存浪费每个线程都有独立的用户态栈默认 8 MB和内核态栈通常 8~16 KB。即使该连接的线程什么都不做只是阻塞在recv上这些内存依然被独占。对于 10 000 个连接仅用户栈就是 80 GB这还不包括线程局部存储、pthread控制块等。调度开销操作系统调度器需要管理大量线程。即使线程是阻塞态调度器在每次时间片中断或系统调用返回时仍需扫描运行队列和等待队列。当线程数量从 100 增加到 10 000 时调度延迟和缓存失效的开销呈非线性增长。上下文切换成本当数据到达时内核需要将阻塞的线程从等待队列移到运行队列并触发调度。频繁的线程唤醒、抢占、切换会污染 CPU 的 L1/L2 缓存降低实际业务指令的执行效率。因此阻塞 I/O 的“零 CPU 等待”是假象——它用内存和调度开销换取了编程简单性而这两种开销在高并发下比 CPU 空转更致命。这也是为什么高性能网络编程几乎总是选择非阻塞 I/O I/O 多路复用epoll的原因。非阻塞 IO如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回 EWOULDBLOCK 错误码非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这 对 CPU 来说是较大的浪费, 一般只有特定场景下才使用1. 核心机制轮询Polling与阻塞 I/O 不同在非阻塞 I/O 模型中当应用进程调用recvfrom请求数据时如果内核“无数据报准备好”内核不会将该进程挂起阻塞。相反它会立即返回一个错误码在 Unix/Linux 系统中通常是EWOULDBLOCK意思是“现在没数据你稍后再试”。2. 流程步骤拆解反复调用与快速失败应用进程发起系统调用后因为没数据内核立即返回EWOULDBLOCK。进程反复调用 recvfrom 等待返回成功指示轮询。这意味着应用程序需要自己写循环逻辑不断地去询问内核“数据好了吗”持续占用 CPU这是该模型最大的特点。在真正的数据到达之前CPU 时间被消耗在不断重复的“检查-返回-再检查”的空转循环中。这通常被称为“忙等待Busy Waiting”。成功获取数据直到某一次调用图中的第四次recvfrom数据终于到达内核。内核将其准备好后应用进程再次发起调用这次内核成功将数据拷贝到用户空间并返回“成功指示”。处理数据进程拿到数据后跳出循环开始“处理数据报”。3. 优缺点对比优点进程在等待数据的过程中保持活跃不会被操作系统挂起。它可以在轮询的间隙去执行其他不依赖网络数据的轻量级任务响应性比阻塞模型好。缺点极度消耗 CPU 资源。就像一个人不停地看表等待公交车一样如果没有数据程序会 100% 占用一个 CPU 核心做无用功效率非常低。信号驱动 IO内核将数据准备好的时候, 使用 SIGIO 信号通知应用程序进行 IO 操作1. 建立监听第一阶段动作应用进程启动时首先通过系统调用如sigaction向内核注册一个信号处理程序并告诉内核“如果我的数据准备好了请给我发一个SIGIO信号”。状态完成注册后系统调用立即返回。此时应用进程完全处于自由状态内核则进入后台等待数据。进程继续执行这意味着在此期间程序可以做任何不涉及网络读取的事情甚至可以去睡眠完全不会被操作系统挂起。2. 内核准备数据第二阶段动作内核在网络接口监听数据。状态这个阶段对应右侧的大括号“等待数据”。数据从网络到达网卡经过内核协议栈处理直到“数据报准备好”。关键点在这个阶段应用程序没有任何参与也没有消耗任何 CPU 资源。3. 信号通知与数据读取第三阶段动作一旦数据准备就绪内核不再默默等待而是主动向应用进程投递递交一个SIGIO信号。响应应用进程接收到信号后会中断当前正在执行的代码跳转到预先写好的信号处理程序中。读取数据在信号处理程序内部应用进程发起真正的recvfrom系统调用。此时因为数据已在内核中备好内核直接将其拷贝到用户空间。数据拷贝到应用缓冲区期间进程阻塞这是因为recvfrom系统调用本身是同步的在数据从内核空间复制到用户空间的过程中进程必须暂停等待拷贝完成。4. 结果返回动作拷贝完成后内核返回成功指示。后续进程被唤醒跳出信号处理程序继续处理接收到的数据报。核心优势信号驱动 I/O 最大的改进在于解除了应用程序对数据的“轮询”责任。在没有数据期间程序完全解放只有在真正有数据时才会被打断并执行读取操作从而避免了像非阻塞 I/O 那样 100% 占用 CPU 进行空转的浪费。IO 多路转接虽然从流程图上看起来和阻塞 IO 类似. 实际上最核心在于 IO 多 路转接能够同时等待多个文件描述符的就绪状态1. 核心概念由“单路”变“多路”阻塞 I/O一个线程只能盯着一个网络连接Socket等数据如果这个连接没数据线程就一直死等。I/O 多路复用引入了一个中间人系统调用如select、poll、epoll应用进程把成百上千个网络连接的句柄交给内核告诉内核“这堆连接里只要有任何一个准备好了就叫我一声”。2. 流程步骤拆解阶段一发起“总览”请求应用 - 内核应用进程调用select系统调用。注意select的作用不是直接读取数据而是让内核去扫描一遍应用进程关心的所有连接。此时内核开始检查这些连接发现“无数据报准备好”。状态应用进程进入阻塞状态等待select返回。阶段二等待任一条件满足内核内部内核在后台持续监听。一旦众多连接中的任意一个或者多个有了数据内核就会停止等待。关键点在图中表现为select调用返回并告知应用进程“有数据可读了具体是哪个你自己去看”。阶段三精准读取应用 - 内核select返回后应用进程解除阻塞知道自己关注的连接中有活儿干了。应用进程随后发起具体的recvfrom调用去读取那个真正有数据的连接。在数据拷贝期间进程再次短暂阻塞直到拷贝完成最后处理数据报。3. 优缺点分析优点极高的并发能力这是该模型最大的价值。使用select/epoll等函数单线程就可以同时监控数千个网络连接。只要有一个连接有数据到达就能及时处理。这就解决了传统“一个连接一个线程”模型中线程创建过多导致系统资源耗尽的问题。缺点数据拷贝阶段依然阻塞如图所示当select通知你可以读了你调用recvfrom进行实际拷贝时进程依然是阻塞的。这意味着在数据从内核空间搬运到用户空间的那一小段时间内这个线程还是干不了别的。这也是为什么后来又发展出了真正的异步 I/OAIO来彻底解决这个问题。异步 IO异步 IOAsynchronous I/O的核心在于应用程序发出读取请求后无需阻塞等待而是可以立即返回继续执行其他任务。内核会在数据完全拷贝到用户空间后主动通知应用程序进行处理。1. 发起请求与即刻返回应用进程执行aio_read系统调用向内核请求读取数据。此时内核会立即返回响应。图中标注为“无数据报准备好”意味着请求被接受但尚未完成。最关键的是应用进程在此处不会阻塞而是进入“进程继续执行”的状态可以去处理其他业务逻辑。2. 内核后台处理等待与拷贝等待数据内核首先进行“等待数据”的操作直到数据从外部如磁盘、网络准备就绪。拷贝数据报数据准备好后内核将其“拷贝数据报”到用户空间缓冲区。图中右侧标注了这一阶段为“将数据从内核拷贝到用户空间”。3. 拷贝完成与信号通知内核通知当数据拷贝彻底完成后内核会发出通知。图中显示这是一个“递交在 aio_read 中指定的信号”的过程。信号处理应用进程接收到信号后触发“信号处理程序处理数据报”。此时应用进程才真正拿到了数据并开始处理。核心区别异步 IO 与“信号驱动 IO”的区别信号驱动 IO内核通知应用程序“何时可以开始拷贝数据”此时数据刚准备好还没拷进用户空间。异步 IO内核通知应用程序“拷贝完成”数据已经在用户空间里了。小结• 任何 IO 过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用 场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让 IO 更高效, 最核心的办法就 是让等待的时间尽量少.高级 IO 重要概念同步通信 vs 异步通信(synchronous communication/ asynchronous communication)同步和异步关注的是消息通信机制以及调用方如何获取任务的结果即结果的返回方式• 所谓同步就是在发出一个调用时在没有得到结果之前该调用就不返回. 但是一旦调用返回就得到返回值了; 换句话说就是由调用者主动等待这个调用 的结果;• 异步则是相反调用在发出之后这个调用就直接返回了所以没有返回结 果; 换句话说当一个异步过程调用发出后调用者不会立刻得到结果; 而是在调用 发出后被调用者通过状态、通知来通知调用者或通过回调函数处理这个调用• 进程/线程同步也是进程/线程之间直接的制约关系• 是为完成某种任务而建立的两个或多个线程这个线程需要在某些位置上协调 他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候在看到 同步 这个词, 一定要先搞清楚大背景是什么. 这个同步, 是同步通信异步通信的同步, 还是同步与互斥的同步.阻塞 vs 非阻塞阻塞和非阻塞关注的是程序在等待调用结果消息返回值时的状态即当前线程/进程是否被挂起•阻塞调用是指调用结果返回之前当前线程会被挂起. 调用线程只有在得到结 果之后才会返回.•非阻塞调用指在调用结果返回之前当前线程不会被挂起可以继续执行其他任务通常通过轮询或检查返回值来判断是否完成。四个概念的关系同步/异步描述的是结果返回的机制。阻塞/非阻塞描述的是等待过程中的状态。它们可以组合出四种常见的模型组合含义典型例子同步阻塞发起调用后线程挂起直到任务完成并返回结果传统的read()系统调用默认阻塞模式同步非阻塞发起调用后立即返回如果未完成则返回错误线程继续执行需要反复轮询检查是否完成设置O_NONBLOCK的read()配合select/poll异步阻塞这种情况实际中几乎不存在异步本来就是不等待阻塞就没意义—异步非阻塞发起调用后立即返回线程完全不被阻塞任务完成后通过回调或信号通知结果现代高性能网络库如 Linux 的io_uring、Windows 的 IOCP、Node.js 的异步 I/O异步阻塞如果异步操作内部是用阻塞方式等待事件例如 epoll 阻塞等待但对外表现为异步严格来说属于异步阻塞。不过一般讨论中常把“异步”默认与非阻塞搭配使用。其他高级 IO非阻塞 IO纪录锁系统 V 流机制I/O 多路转接也叫 I/O 多路复用,readv 和 writev 函数以及存储映射 IOmmap这些统称为高级 IO.非阻塞 IOfcntl 一个文件描述符, 默认都是阻塞 IO函数原型如下#include unistd.h #include fcntl.h int fcntl(int fd, int cmd, ... /* arg */ );传入的 cmd 的值不同, 后面追加的参数也不相同.fcntl 函数有 5 种功能:• 复制一个现有的描述符cmdF_DUPFD.• 获得/设置文件描述符标记(cmdF_GETFD 或 F_SETFD).• 获得/设置文件状态标记(cmdF_GETFL 或 F_SETFL).• 获得/设置异步 I/O 所有权(cmdF_GETOWN 或 F_SETOWN).• 获得/设置记录锁(cmdF_GETLK,F_SETLK 或 F_SETLKW第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为 非阻塞.实现函数 SetNoBlock基于 fcntl, 我们实现一个 SetNoBlock 函数, 将文件描述符设置为非阻塞void SetNoBlock(int fd) { int fl fcntl(fd, F_GETFL); if (fl 0) { perror(fcntl); return; } fcntl(fd, F_SETFL, fl | O_NONBLOCK); }• 使用 F_GETFL 将当前的文件描述符的属性取出来(这是一个位图).• 然后再使用 F_SETFL 将文件描述符设置回去. 设置回去的同时, 加上一个 O_NONBLOCK 参数轮询方式读取标准输入#include stdio.h #include unistd.h #include fcntl.h // 将文件描述符 fd 设置为非阻塞模式 void SetNoBlock(int fd) { // 先获取当前文件状态标志 int fl fcntl(fd, F_GETFL); if (fl 0) { perror(fcntl); return; } // 在原有标志上增加 O_NONBLOCK实现非阻塞 fcntl(fd, F_SETFL, fl | O_NONBLOCK); } int main() { // 将标准输入文件描述符 0设为非阻塞 SetNoBlock(0); // 轮询反复尝试读取无数据时不会阻塞进程 while (1) { char buf[1024] {0}; ssize_t read_size read(0, buf, sizeof(buf) - 1); if (read_size 0) { // 非阻塞模式下无数据可读时read 通常返回 -1 并设置 errno 为 EAGAIN 或 EWOULDBLOCK perror(read); sleep(1); // 轮询间隔 1 秒避免 CPU 空转 continue; } // 成功读到数据打印出来 printf(input:%s\n, buf); } return 0; }程序通过fcntl将标准输入设为非阻塞模式。在while(1)循环中不断调用read如果当前没有输入数据read会立即返回-1错误程序打印错误信息后sleep(1)再继续尝试。这种反复主动检查是否有输入的方式就是轮询polling与阻塞式读取没有数据时进程挂起不同。缺点即使没有输入CPU 也会频繁执行循环和系统调用示例中用sleep(1)降低了轮询频率。
网站建设 高端定制 企业官网