五种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过程。

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

相关推荐
冰冰的米咖9 小时前
交换与路由技术整理与总结(持续更新版)
网络·网络协议·智能路由器
翎沣9 小时前
C++面向对象三大特性
开发语言·c++
驭渊的小故事9 小时前
java中的进程的详细解析
java·开发语言
Sagittarius_A*9 小时前
H3CSE 高性能园区网:Smart Link 与 Monitor Link 技术详解
网络·计算机网络·h3cse
烟雨江南aabb9 小时前
Python第六弹:python爬虫篇:什么是爬虫
开发语言·爬虫·python
沐知全栈开发9 小时前
Servlet 文件上传详解
开发语言
Ether IC Verifier9 小时前
TCP/IP协议握手原理详解——结合以太网连接过程
服务器·网络·数据库·网络协议·tcp/ip
宋浮檀s9 小时前
DVWA通关教程1
网络·安全·web安全
basketball6169 小时前
C++ iostream 完全指南:从 cin/cout 到流式编程的奥秘
开发语言·c++