11、五种 IO 模型与阻塞 IO

目录

[五种 IO 模型](#五种 IO 模型)

[阻塞 IO](#阻塞 IO)

[非阻塞 IO](#非阻塞 IO)

[信号驱动 IO](#信号驱动 IO)

[IO 多路转接](#IO 多路转接)

[异步 IO](#异步 IO)

小结

[高级 IO 重要概念](#高级 IO 重要概念)

[同步通信 vs 异步通信(synchronous communication/ asynchronous communication)](#同步通信 vs 异步通信(synchronous communication/ asynchronous communication))

[阻塞 vs 非阻塞](#阻塞 vs 非阻塞)

四个概念的关系

[其他高级 IO](#其他高级 IO)

[非阻塞 IO](#非阻塞 IO)

[实现函数 SetNoBlock](#实现函数 SetNoBlock)

轮询方式读取标准输入


五种 IO 模型

阻塞 IO

在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认 都是阻塞方式

阻塞 IO 是最常见的 IO 模型。

  1. 发起请求与阻塞(应用进程 -> 内核)
  • 动作 :应用进程发起 recvfrom系统调用,请求读取数据。

  • 状态 :由于内核此时"无数据报准备好",无法立即返回结果,内核会将该应用进程挂起(阻塞)

  • 进程阻塞于 recvfrom 的调用。此时 CPU 会切换到其他就绪任务,不会浪费在空等数据上。

  1. 内核等待与拷贝(内核内部处理)

这一阶段是耗时的主要部分:

  • 等待数据:内核通过网络接口持续监听,直到数据报到达并被处理为"准备好"的状态。

  • 拷贝数据 :内核将准备好的数据从内核空间(Kernel Space)复制到操作系统的内核缓冲区中。

  1. 数据交付与唤醒(内核 -> 应用进程)
  • 动作 :内核完成数据在内核缓冲区的准备后,将数据从内核空间拷贝到用户空间(User Space)的应用进程内存中。

  • 结果:拷贝完成后,内核向应用进程发送"返回成功指示"。

  • 后续 :进程被唤醒,解除阻塞状态,开始执行下一行代码"处理数据报"。

这种模型的特点是简单直观,但缺点是应用进程在数据准备和拷贝期间是完全停滞的,无法执行其他任务

  1. 阻塞到底"阻塞"在哪里?

    进程被挂起,CPU 会去执行其他进程 ------ 所以阻塞 I/O 本身不浪费 CPU ,但会降低单个线程的吞吐量(因为线程闲着等数据)。

  2. 阻塞 I/O 的适用场景

    • 并发很低,但每个连接需要大量计算(比如数据库连接池,每个线程处理一个客户端,逻辑复杂)。

    • 代码简单,没有复杂的状态机,适合快速开发。

  3. 阻塞 I/O 的致命缺点

    • 如果要同时处理多个连接,必须用多线程/多进程,每个连接一个线程。

    • 线程数过多会导致:内存开销大、上下文切换频繁、CPU cache 命中率下降。

    • C10K 问题正是阻塞 I/O + 一连接一线程无法解决的根源。

  4. 默认所有的套接字都是阻塞的

    listen 的监听 socket 阻塞,accept 阻塞,recv/send 阻塞。

    但阻塞行为可以在不同函数上单独控制:例如 accept 阻塞等待新连接,但可以用 select 先检查,再调用 accept 保证立即返回。

  5. 阻塞 I/O 在内核中的大致实现

    调用 recvfrom → 系统调用 → 内核检查 socket 接收队列 → 无数据 → 将当前进程加入该 socket 的等待队列 → 调度其他进程运行 → 数据到达,唤醒该进程 → 拷贝数据 → 返回。

补充一:阻塞 I/O 的多线程模型与 C10K 问题的关联

阻塞 I/O 虽然编程简单,但当需要同时处理大量客户端连接时,传统的"一连接一线程"模型会迅速暴露严重的扩展性问题。这就是著名的 C10K 问题------如何在一台物理服务器上同时处理 10 000 个网络连接。

在阻塞 I/O 下,常见的服务端实现如下:

cpp 复制代码
while (1) {
    int client_fd = accept(listen_fd, ...);   // 阻塞获取新连接
    pthread_create(&tid, NULL, handler, &client_fd);  // 创建线程处理
}

每个线程负责一个连接,在 recvread 上阻塞等待数据。当连接数增长到 10 000 时:

  • 内存开销 :每个线程默认栈大小 8 MB(可通过 ulimit -s 调整,但仍不小),10 000 个线程仅栈空间就需要约 80 GB 内存,远超普通服务器配置。

  • 调度开销:操作系统调度器需要管理上万个可运行/阻塞线程,频繁的上下文切换导致 CPU 有效利用率急剧下降,系统 load 飙升。

  • 资源限制/proc/sys/kernel/threads-maxpid_max 限制了最大线程/进程数,即便修改上限,内核的 task_struct 和内核栈开销也会耗尽内存。

因此,单纯的阻塞 I/O + 多线程模型无法支撑 C10K 场景 。这也正是 I/O 多路复用(select/poll/epoll)诞生的直接原因------用一个线程管理成千上万个连接,彻底解决线程爆炸的问题。


补充二:阻塞 I/O + 多线程 vs epoll 对比表

对比维度 阻塞 I/O + 多线程 epoll(I/O 多路复用,非阻塞 I/O)
并发模型 一个连接一个线程 一个线程管理多个连接
等待数据时线程状态 每个线程阻塞在 recv 上,线程被挂起 主线程阻塞在 epoll_wait,其他工作线程可处理业务
连接空闲开销 每条连接占用完整线程栈(MB 级)+ TCB 仅占用一个文件描述符 + 应用层少量结构(通常几十到几百字节)
连接数上限 受限于线程数(通常几千) 理论上受限于系统文件描述符上限(可配置数十万)
CPU 使用 空闲连接不占 CPU(阻塞),但大量线程切换增加开销 就绪事件驱动,无无效轮询,CPU 利用率高
编程复杂度 低(但需处理线程安全,如锁、条件变量) 较高(需要状态机、非阻塞、缓冲区管理、粘包处理)
典型应用 数据库连接池、少量长连接(几十到几百) 高并发 Web 服务器(Nginx)、聊天服务器、游戏网关

补充三:阻塞 I/O 不浪费 CPU,但浪费内存和调度开销

很多初学者认为"阻塞 I/O 一直卡在那里,肯定很消耗 CPU"。实际上,阻塞 I/O 在等待数据时,线程被操作系统挂起,CPU 会去执行其他进程或线程。也就是说,阻塞本身并不导致 CPU 空转或繁忙。

但它的代价隐藏在其他地方:

  1. 内存浪费

    每个线程都有独立的用户态栈(默认 8 MB)和内核态栈(通常 8~16 KB)。即使该连接的线程什么都不做,只是阻塞在 recv 上,这些内存依然被独占。对于 10 000 个连接,仅用户栈就是 80 GB,这还不包括线程局部存储、pthread 控制块等。

  2. 调度开销

    操作系统调度器需要管理大量线程。即使线程是阻塞态,调度器在每次时间片中断或系统调用返回时仍需扫描运行队列和等待队列。当线程数量从 100 增加到 10 000 时,调度延迟和缓存失效的开销呈非线性增长。

  3. 上下文切换成本

    当数据到达时,内核需要将阻塞的线程从等待队列移到运行队列,并触发调度。频繁的线程唤醒、抢占、切换会污染 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 多路复用 :引入了一个中间人(系统调用如 selectpollepoll),应用进程把成百上千个 网络连接的句柄交给内核,告诉内核:"这堆连接里,只要有任何一个准备好了,就叫我一声"。

2. 流程步骤拆解

  • 阶段一:发起"总览"请求(应用 -> 内核)

    • 应用进程调用 select系统调用。注意,select的作用不是直接读取数据,而是让内核去扫描一遍应用进程关心的所有连接。

    • 此时,内核开始检查这些连接,发现"无数据报准备好"。

    • 状态 :应用进程进入阻塞状态,等待 select返回。

  • 阶段二:等待任一条件满足(内核内部)

    • 内核在后台持续监听。一旦众多连接中的任意一个(或者多个)有了数据,内核就会停止等待。

    • 关键点 :在图中表现为 select调用返回,并告知应用进程:"有数据可读了,具体是哪个,你自己去看"。

  • 阶段三:精准读取(应用 -> 内核)

    • select返回后,应用进程解除阻塞,知道自己关注的连接中有活儿干了。

    • 应用进程随后发起具体的 recvfrom调用,去读取那个真正有数据的连接。

    • 在数据拷贝期间,进程再次短暂阻塞,直到拷贝完成,最后处理数据报。

3. 优缺点分析

优点:极高的并发能力

这是该模型最大的价值。使用 select/epoll等函数,单线程就可以同时监控数千个网络连接。只要有一个连接有数据到达,就能及时处理。这就解决了传统"一个连接一个线程"模型中,线程创建过多导致系统资源耗尽的问题。

缺点:数据拷贝阶段依然阻塞

如图所示,当 select通知你可以读了,你调用 recvfrom进行实际拷贝时,进程依然是阻塞的。这意味着在数据从内核空间搬运到用户空间的那一小段时间内,这个线程还是干不了别的。这也是为什么后来又发展出了真正的异步 I/O(AIO)来彻底解决这个问题。

异步 IO

异步 IO(Asynchronous I/O)的核心在于应用程序发出读取请求后,无需阻塞等待,而是可以立即返回继续执行其他任务。内核会在数据完全拷贝到用户空间后,主动通知应用程序进行处理。

1. 发起请求与即刻返回

  • 应用进程 执行 aio_read系统调用,向内核请求读取数据。

  • 此时,内核会立即返回响应。图中标注为"无数据报准备好",意味着请求被接受,但尚未完成。最关键的是,应用进程在此处不会阻塞,而是进入"进程继续执行"的状态,可以去处理其他业务逻辑。

2. 内核后台处理(等待与拷贝)

  • 等待数据:内核首先进行"等待数据"的操作,直到数据从外部(如磁盘、网络)准备就绪。

  • 拷贝数据报:数据准备好后,内核将其"拷贝数据报"到用户空间缓冲区。图中右侧标注了这一阶段为"将数据从内核拷贝到用户空间"。

3. 拷贝完成与信号通知

  • 内核通知:当数据拷贝彻底完成后,内核会发出通知。图中显示,这是一个"递交在 aio_read 中指定的信号"的过程。

  • 信号处理应用进程接收到信号后,触发"信号处理程序处理数据报"。此时,应用进程才真正拿到了数据并开始处理。

核心区别

异步 IO 与"信号驱动 IO"的区别:

  • 信号驱动 IO:内核通知应用程序"何时可以开始拷贝数据"(此时数据刚准备好,还没拷进用户空间)。

  • 异步 IO:内核通知应用程序"拷贝完成"(数据已经在用户空间里了)。

小结

• 任何 IO 过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用 场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让 IO 更高效, 最核心的办法就 是让等待的时间尽量少.

高级 IO 重要概念

同步通信 vs 异步通信(synchronous communication/ asynchronous communication)

同步和异步关注的是消息通信机制以及调用方如何获取任务的结果(即结果的返回方式)

• 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用 的结果;

• 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结 果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用 发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用

• 进程/线程同步也是进程/线程之间直接的制约关系

• 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调 他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候

在看到 "同步" 这个词, 一定要先搞清楚大背景是什么. 这个同步, 是同步通信异步通信的同步, 还是同步与互斥的同步.

阻塞 vs 非阻塞

阻塞和非阻塞关注的是**程序在等待调用结果(消息,返回值)时的状态(**即当前线程/进程是否被挂起)

阻塞 调用是指调用结果返回之前,当前线程会被挂起 . 调用线程只有在得到结 果之后才会返回.

非阻塞调用指在调用结果返回之前,当前线程不会被挂起,可以继续执行其他任务(通常通过轮询或检查返回值来判断是否完成)。

四个概念的关系

同步/异步 描述的是 结果返回的机制

阻塞/非阻塞 描述的是 等待过程中的状态

它们可以组合出四种常见的模型:

组合 含义 典型例子
同步阻塞 发起调用后,线程挂起,直到任务完成并返回结果 传统的 read() 系统调用(默认阻塞模式)
同步非阻塞 发起调用后立即返回(如果未完成则返回错误),线程继续执行;需要反复轮询检查是否完成 设置 O_NONBLOCKread(),配合 select/poll
异步阻塞 这种情况实际中几乎不存在(异步本来就是不等待,阻塞就没意义) ---
异步非阻塞 发起调用后立即返回,线程完全不被阻塞;任务完成后通过回调或信号通知结果 现代高性能网络库(如 Linux 的 io_uring、Windows 的 IOCP、Node.js 的异步 I/O)

异步阻塞 :如果异步操作内部是用阻塞方式等待事件(例如 epoll 阻塞等待),但对外表现为异步,严格来说属于异步阻塞。不过一般讨论中,常把"异步"默认与非阻塞搭配使用。

其他高级 IO

非阻塞 IO,纪录锁,系统 V 流机制,I/O 多路转接(也叫 I/O 多路复用),readv 和 writev 函数以及存储映射 IO(mmap),这些统称为高级 IO.

非阻塞 IO

fcntl 一个文件描述符, 默认都是阻塞 IO

函数原型如下

cpp 复制代码
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

传入的 cmd 的值不同, 后面追加的参数也不相同.

fcntl 函数有 5 种功能:

• 复制一个现有的描述符(cmd=F_DUPFD).

• 获得/设置文件描述符标记(cmd=F_GETFD 或 F_SETFD).

• 获得/设置文件状态标记(cmd=F_GETFL 或 F_SETFL).

• 获得/设置异步 I/O 所有权(cmd=F_GETOWN 或 F_SETOWN).

• 获得/设置记录锁(cmd=F_GETLK,F_SETLK 或 F_SETLKW

第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为 非阻塞.

实现函数 SetNoBlock

基于 fcntl, 我们实现一个 SetNoBlock 函数, 将文件描述符设置为非阻塞

cpp 复制代码
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 参数

轮询方式读取标准输入

cpp 复制代码
#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) 降低了轮询频率。

相关推荐
myenjoy_11 小时前
串口采集与 Modbus RTU——字节流里的时间敏感博弈
网络·python·网络协议·tcp/ip
dxxt_yy1 小时前
光伏风电组网调试优选,鼎讯信通 GN-W10A 网络综合测试仪全项检测
网络·能源·信息与通信
是枚小菜鸡儿吖1 小时前
IT技术员远程修电脑用什么软件好?低延迟高清远控工具横评
网络·智能路由器·电脑
eam0511231 小时前
BGP反射器及联邦实验
网络
小子想咋滴2 小时前
bgp联邦实验
网络·智能路由器
代码中介商2 小时前
HTTP 完全指南(三):Cookie、Session 与 Token 深度详解
网络·网络协议·http
Irissgwe2 小时前
9、数据链路层
linux·网络·mac·ip·数据链路层·arp协议·以太网帧格式
2501_946786203 小时前
2026漏洞扫描服务:企业防护痛点解决指南
网络·安全·web安全
洛水水3 小时前
图床项目实现:MD5秒传 + 个人文件列表 + 图片分享等功能的完善
服务器·网络