五种IO模型与⾮阻塞IO

IO的基本概念

什么是IO?

I/O(input/output)也就是输入和输出,在著名的冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫做输入,将数据从内存拷贝到输出设备就叫做输出。

对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。

对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。

输入的本质与中断机制:操作系统如何得知外设有数据?

输入的本质:从外设拷贝到内存

输入(Input)在操作系统层面,本质上就是将数据从外设拷贝到内存的过程。但这里有一个关键问题:操作系统必须能够得知特定外设上是否有数据就绪。

并不是操作系统想读数据时,外设就一定有数据。例如:

  • 用户访问某台服务器,发出请求报文后,需要等待从网卡读取服务器发回的响应数据。

  • 但此时,对方服务器可能还没收到请求,或者正在处理,又或者响应数据还在网络中传输。

如果操作系统采用主动轮询的方式,不断去检测外设的状态,那么绝大多数检测都是徒劳的------因为大部分时间外设中并没有数据。这种做法会严重降低操作系统的效率。

中断机制:让外设主动通知 CPU

实际的操作系统采用中断(Interrupt) 方式来感知外设数据就绪。当某个外设上有数据就绪时,该外设会向 CPU 中的中断控制器发送中断信号。中断控制器根据中断的优先级,按顺序将信号传递给 CPU。

中断处理流程

  • 中断信号产生:外设数据就绪 → 发送中断信号给中断控制器。

  • 中断向量表:系统维护一张 中断向量表,存储中断信号与对应中断处理程序的映射关系。

  • CPU 响应:CPU 收到中断信号后,暂停当前正在运行的程序。

  • 执行处理程序:根据中断向量表,跳转到对应的中断处理程序执行。

  • 恢复现场:处理完毕后,返回原被暂停的程序继续运行。

注意

CPU 不直接与外设打交道,指的是数据层面(数据传输通过内存或 DMA)。但外设可以直接将某些控制信号发送给 CPU 中的控制器(如中断请求线),这正是中断机制的硬件基础。

OS如何处理从网卡中读取到的数据包?

操作系统任何时刻都可能会收到大量的数据包,因此操作系统必须将这些数据包管理起来。所谓的管理就是"先描述,再组织",在内核当中有一个结构叫做sk_buff,该结构就是用来管理和控制接收或发送数据包的信息的。

简化版的sk_buff结构:

当操作系统从网卡当中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户,那对应到这个sk_buff结构来说具体是如何进行数据包的解包和分用的呢?

核心设计:双链表 + 指针偏移

  • sk_buff 中的 data 指针指向原始数据包。

  • 多个 sk_buff 以双链表形式组织,便于内核进行增删查改。

  • 通过不同指针(mac_header、network_header、transport_header)标记协议头的位置,实现零拷贝解包------无需移动数据,只需移动指针。

链路层处理

数据包首先交给链路层。让 sk_buff->mac_header 指针指向数据包的起始位置,然后向后读取链路层报头(如以太网头)。报头之后的部分,就是需要交给网络层的有效载荷。链路层的解包至此完成。

网络层处理

"向上交付"并非将数据拷贝到网络层的新缓冲区,而是让 sk_buff->network_header 指针指向链路层报头之后的位置。然后从这个位置向后读取网络层报头(如 IP 头),剩下的就是传输层的有效载荷。

传输层处理

同理,让 sk_buff->transport_header 指针指向网络层报头之后的位置,读取传输层报头(如 TCP 或 UDP 头)。解包完成后,根据传输层协议类型,将剩余的数据(即应用层有效载荷)拷贝到对应协议的接收缓冲区(如 TCP 的接收队列或 UDP 的接收缓冲区),等待用户进程通过 socket 读取。

整个过程中,数据本身在内存中只存在一份拷贝。每向上一层,只需移动指针并减少长度字段,从而极大提高了网络协议栈的处理效率。这是 Linux 内核高性能网络的关键设计之一。

什么是高效的IO?

IO主要分为两步:

  • 第一步是等,即等待IO条件就绪。
  • 第二步是拷贝,也就是当IO条件就绪后将数据拷贝到内存或外设。

任何IO的过程,都包含"等"和"拷贝"这两个步骤,但在实际的应用场景中"等"消耗的时间往往比"拷贝"消耗的时间多,因此要让IO变得高效,最核心的办法就是尽量减少"等"的时间。

五种IO模型

IO 的过程其实和钓鱼非常相似。

钓鱼可以分成两个步骤:"等" 和 "拷贝"。

  • "等":鱼上钩之前,我们只能耐心等待。

  • "拷贝":鱼上钩之后,迅速把它从河里"拷贝"到鱼桶里。

IO 也是一样:先等待数据就绪(比如从网卡或磁盘),再把数据从内核拷贝到用户空间。

有趣的是,无论是在 IO 还是钓鱼中,"等"消耗的时间往往比"拷贝"要多得多。钓鱼时,大部分时间都在等鱼上钩,真正提竿、取鱼的瞬间很短。IO 也是如此:数据就绪前的等待常常是性能瓶颈,而一旦数据来了,拷贝的过程相对很快。

下面给出五个人的钓鱼方式:

  • 张三:拿 1 个鱼竿,鱼钩入水后就死死盯着浮漂,什么也不做。有鱼上钩,立刻提竿。

    → 这是阻塞 I/O。

  • 李四:拿 1 个鱼竿,鱼钩入水后去做别的事,定期回来看一眼浮漂。有鱼上钩就提竿,否则继续干别的。

    → 这是非阻塞 I/O(轮询)。

  • 王五:拿 1 个鱼竿,鱼钩入水后在竿头绑个铃铛,然后放心去做别的事。铃铛一响,回来提竿。

    → 这是信号驱动 I/O。

  • 赵六:拿 100 个鱼竿,全部抛入水中,然后定期轮流观察每个鱼竿的浮漂。哪个有鱼,就提哪个。

    → 这是I/O 多路复用(如 select/poll/epoll)。

  • 田七:有钱的老板。他给司机一个桶、一个电话、一个鱼竿,让司机去钓鱼。等鱼桶装满了,司机再打电话告诉田七。田七自己则开车去做别的事。

    → 这是异步 I/O(真正的非阻塞,完成后通知)。

张三、李四、王五的钓鱼效率是否一样?为什么?

  • 钓鱼步骤一致

    三人的钓鱼过程都是:先等鱼上钩,再将鱼钓上来。没有谁用了更高效的"捕鱼技巧"。

  • 鱼竿数量相同

    每人只有一根鱼竿。在河里鱼咬钩的概率相同的情况下,每根鱼竿单位时间内钓上鱼的数量期望是一样的。

  • 等待方式不影响效率

    张三:死盯着浮漂(阻塞等待)

    李四:定期回来检查(非阻塞轮询)

    王五:绑铃铛等通知(信号驱动)

他们只是"等鱼上钩"的方式不同,但鱼咬钩的时机和概率完全相同。因此,最终钓上来的鱼的数量没有本质区别。

这里问的是钓鱼效率(即单位时间内钓上来的鱼的数量),而不是整体做的杂事多少。如果比较的是谁做的事情多:

  • 王五做了最多事(绑铃铛、做其他活、回来提竿)

  • 李四次之(定期回来检查)

  • 张三最少(只是干等)

但效率只看结果,不看过程中是否"顺手干了别的"。

张三、李四、王五它们三个人分别和赵六比较,谁的钓鱼效率更高?

赵六拿了 97 根鱼竿,加上张三、李四、王五各 1 根,一共 100 根。

假设河里每条鱼咬钩时,咬到任意一根鱼竿的概率相等(1%)。那么:

  • 张三、李四、王五的鱼竿被咬的概率各为 1%

  • 赵六的 97 根鱼竿中,至少有一根被咬的概率接近 97%

  • 单位时间内,赵六的鱼竿上有鱼的概率是其他人的 97 倍。

高效的 I/O 本质就是减少"等"的时间,增加"拷贝"的时间。赵六之所以效率高,是因为他一次等待多个鱼竿(监控多个文件描述符),将"等"的时间重叠起来------同一段时间里,他在为 97 个鱼竿同时等待,任何一个有鱼都能立刻处理。

对应到 I/O 模型:I/O 多路复用(select/poll/epoll)正是通过批量监控多个描述符,大幅提升单位时间内的 I/O 就绪概率,从而提升整体效率。

田七的这种钓鱼方式

田七的"钓鱼效率"取决于司机的速度和鱼的数量。但田七本人的时间利用率是最高的------他的时间完全没有花在钓鱼上,全部用在了自己的业务上。这也是异步 I/O 在高并发、高吞吐场景下极具价值的原因。

阻塞IO

阻塞IO就是在内核将数据准备好之前,系统调用会一直等待。

典型场景:recvfrom 调用

以 recvfrom 函数从套接字读取数据为例:

  • 进程调用 recvfrom,进入内核。

  • 如果底层数据还没有准备好(比如网卡还没收到数据包),进程就需要等待数据就绪。

  • 数据就绪后,内核将数据从内核空间拷贝到用户空间。

  • 拷贝完成,recvfrom 函数返回,进程继续执行。

在 recvfrom 等待数据就绪的这段时间里,从用户视角看,进程或线程"卡住了"------这就是阻塞。

其背后的内核行为是:

  • 操作系统将该进程/线程的状态设置为非 R 状态(如 TASK_INTERRUPTIBLE),意味着它暂时不被调度。

  • 内核将其放入等待队列中,与所等待的事件(如套接字接收缓冲区有数据)关联。

  • 当数据就绪(例如网卡收到数据包并触发中断,内核将其放入套接字接收缓冲区),内核从等待队列中唤醒该进程/线程。

  • 被唤醒的进程/线程将数据从内核拷贝到用户空间,然后返回。

非阻塞IO

非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。

非阻塞IO往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。

  • 如果底层数据还没有准备好,recvfrom 立即返回错误(通常是 EWOULDBLOCK 或 EAGAIN),不会让进程或线程阻塞等待。

  • 因为没有读到数据,后续进程需要继续主动调用 recvfrom,反复检测数据是否就绪。

  • 每次检测,如果数据仍未就绪,recvfrom 仍然立即错误返回。

  • 直到某一次检测发现数据就绪,recvfrom 将数据从内核拷贝到用户空间,然后成功返回。

非阻塞 I/O 本身并不会提高 I/O 效率,反而可能因为频繁的系统调用增加 CPU 开销。

信号驱动IO

信号驱动IO就是当内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作。

信号驱动 I/O(Signal-Driven I/O)的工作方式如下:

  • 通过 signal 或 sigaction 函数,将 SIGIO 信号的处理程序自定义为所需的 I/O 操作(例如调用 recvfrom 读取数据)。

  • 当底层数据就绪时,操作系统向当前进程/线程递交 SIGIO 信号。

  • 进程收到信号后,自动执行信号处理程序,在程序中调用 recvfrom 将数据从内核拷贝到用户空间。

  • 拷贝完成后,进程继续原来的工作。

同步与异步的判断标准

判断一个 I/O 过程是同步还是异步,关键在于:当前进程/线程是否需要亲自参与 I/O 操作(即数据拷贝)。

  • 如果要参与 → 同步 I/O

  • 如果完全不参与(由内核代劳,完成后通知)→ 异步 I/O

阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O 都属于 同步 I/O。

只有真正意义上的 异步 I/O(如 Windows IOCP、Linux AIO)才是异步 I/O。

IO多路转接

IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。

I/O 多路转接的核心思想

I/O 过程分为 "等" 和 "拷贝" 两个步骤。我们常用的 recvfrom 等接口,底层实际上做了两件事:

  • 如果数据不就绪,就等。

  • 数据就绪后,进行拷贝。

问题在于,recvfrom 这类接口一次只能等一个文件描述符上的数据或空间就绪,效率太低。

多路转接接口:专门负责"等"

系统提供了三组专门用于"等"的接口:select、poll、epoll。它们可以一次等待多个文件描述符,将"等"的时间重叠起来。

  • 我们把所有"等"的工作交给这些多路转接接口。

  • 当某个文件描述符上的数据就绪后,我们再调用 recvfrom 等函数进行拷贝。

  • 此时,这些拷贝函数不需要再等待,直接拷贝并返回。

多路转接的核心价值:用一个系统调用(select/poll/epoll)等待多个 fd,减少"等"的总时间,提高单位时间内的 I/O 就绪概率。

异步IO

异步IO就是由内核在数据拷贝完成时,通知应用程序。

  • 进行异步IO需要调用一些异步IO的接口,异步IO接口调用后会立马返回,因为异步IO不需要你进行"等"和"拷贝"的操作,这两个动作都由操作系统来完成,你要做的只是发起IO。
  • 当IO完成后操作系统会通知应用程序,因此进行异步IO的进程或线程并不参与IO的所有细节。

高级IO重要概念

同步通信 VS 异步通信

同步和异步关注的是消息通信机制。

  • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
  • 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

为什么非阻塞IO在没有得到结果之前就返回了?

  • IO是分为"等"和"拷贝"两步的,当调用recvfrom进行非阻塞IO时,如果数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的IO过程,即便调用返回了那也是属于错误的返回。
  • 因此该进程或线程后续还需要继续调用recvfrom,轮询检测数据是否就绪,当数据就绪后最后再把数据从内核拷贝到用户空间,这才是一次完整的IO过程。

因此,在进行非阻塞IO时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。

同步通信 VS 同步与互斥

在多进程和多线程当中有同步与互斥的概念,但是这里的同步通信和进程或线程之间的同步是完全不相干的概念。

  • 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
  • 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。

因此当看到"同步"这个词的时候,一定要先明确这个同步是同步通信的同步,还是同步与互斥的同步。

相关推荐
Scott9999HH3 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
码智社4 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海4 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖5 小时前
JDK 26 新特性详解
java·开发语言
马优晨5 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
andxe5 小时前
安科士 AndXe 技术博客:400G QSFP112 SR4 光模块|AI 算力与超算短距互联最优方案
网络·人工智能·光模块·光通信
shiyi.十一6 小时前
第2章:应用层 — 知识要点与架构
网络·计算机网络·架构
DFT计算杂谈7 小时前
无 Root 权限在 Tesla K80 零门槛部署 DeepSeek 大模型
linux·服务器·网络·数据库·机器学习
水境传感 李兆栋7 小时前
GNSS 位移监测站 :毫米级感知,筑牢安全监测防线
网络
人邮异步社区7 小时前
怎么把C语言学到精通?
c语言·开发语言