五种 IO 模型

文章详细介绍了操作系统的基础概念,包括内核空间与用户空间的划分、应用程序的内核态与用户态转换。接着,重点讨论了五种不同的IO模型:阻塞IO、非阻塞IO、IO复用(Select、Poll、Epoll)、信号驱动IO和异步IO,分析了它们的工作流程和优缺点。最后,对比了同步与异步在IO操作中的差异。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


5种 IO 模型分别是: 阻塞 IO 模型非阻塞 IO 模型IO 复用模型信号驱动 IO 模型异步 IO 模型

操作系统和内存

计算机由操作系统和硬件组成。

操作系统主要包含内核(kernel)应用程序

内核提供进程管理、内存管理、网络服务等底层功能,也提供了与硬件交互的接口,通过系统调用提供给上层的应用程序使用。

应用程序(如浏览器、QQ、MySQL 等,下面简称为程序)要操作硬件(如进行磁盘读写),需要先与内核交互再由内核与硬件交互

硬件包括 CPU、内存、硬盘、网卡、声卡、显卡等。

内核空间和用户空间

操作系统都是采用虚拟地址空间,内核是操作系统的核心,独立于普通的应用程序,内核可以访问受保护的内存空间(内核空间),也有访问底层硬件设备的所有权限。

为了保证内核的安全,操作系统将虚拟内存空间划分为内核空间用户空间两个部分。它们是隔离的,即使用户程序崩溃了,内核也不受影响。

  • 内核空间是操作系统的内核代码运行的地址空间,是受保护的内存空间。也称内核内存。
  • 用户空间是普通的用户程序代码运行的内存地址空间。也称用户内存。
应用程序的内核态和用户态

早期的操作系统是不区分内核空间和用户空间的。应用程序能随意访问任意内存空间,导致用户程序经常把系统搞崩溃。

后来,就按照 CPU 指令的重要程度对指令进行了分级。

CPU 指令分为四个级别:Ring0 ~ Ring3,Linux 只使用了 Ring0 和 Ring3 两个运行级别。

程序进程运行 Ring3 级别的指令时运行在用户态,只能访问用户空间。

程序进程运行 Ring0 级别的指令时被称为内核态,可访问任意内存空间。

当程序进程(线程)运行在内核空间时,它就处于内核态;当程序进程(线程)运行在用户空间时,它就处于用户态。

那么,程序什么时候运行在内核空间,什么时候运行在用户空间呢?

当需要进行 IO 操作时,比如读写磁盘文件、读写网卡数据时,程序进程需切换到内核态,否则无法操作。无论是从用户态切换到内核态,还是从内核态切换到用户态,都需要进行一次上下文的切换。一般情况下,程序不能直接操作内核空间的数据,需要把内核内存的数据拷贝到用户空间才能操作。

当程序进程执行系统调用而进入内核代码中执行时,称进程处于内核运行态(内核态)。

除了系统调用可以实现用户态到内核态的切换,软中断和硬中断也会切换用户态和内核态。

  • 在内核态下:程序进程(线程)运行在内核空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
  • 在用户态下:程序进程(线程)运行在用户空间中,被执行的代码要受到 CPU 的很多检查,比如:进程只能访问映射其地址空间的页表项中规定的在用户态下可访问的虚拟地址。
网络 IO 和磁盘 IO

IO 是 Input/Output 的缩写,也就是计算机中的输入和输出。

由于应用程序和运行时数据是在内存中驻留的,由 CPU 来执行各种操作,涉及到数据交换的地方(通常是内存、磁盘和网络等)就需要 IO 接口。

通常,程序完成 IO 操作会有 Input 和 Output 两个数据流。比如,从 MySQL 读取数据到内存,就有 Input 操作,再把数据展现出来给我们看,就有 Output 操作。

IO 操作是相对于内存而言的,数据从外部设备进入内存就是 Input;从内存取出数据输出到外部设备就是 Output。

用户进程无法直接操作 IO 设备,必须通过系统调用,请求内核来协助完成。内核会为每一个 IO 设备维护一个缓冲区。

通常,用户进程完成一次完整的 IO 操作,需要两个阶段:用户进程空间与内核空间的交互内核空间与设备空间(硬盘、网卡)的交互

IO 从读取数据的来源分为内存 IO、 网络 IO 和磁盘 IO 三种,通常说的 IO 是指网络 IO 和 磁盘 IO。(因为内存 IO 的读写速度远大于网络 IO 和磁盘 IO)

IO 按照设备来分,可分为网络 IO 和磁盘 IO。网络 IO 就是通过网络进行数据的拉取和输出。
磁盘 IO 就是对磁盘进行的读写操作。

  • 网络 IO:等待网络数据到达网卡,把网卡中的数据读取到内核缓冲区,然后从内核缓冲区拷贝数据到用户进程空间。
  • 磁盘 IO:把数据从磁盘读取到内核缓冲区,然后从内核缓冲区拷贝数据到用户进程空间。

由于 CPU 和内存的运行速度远远高于外部设备(网卡、磁盘等),所以在 IO 编程中,存在速度严重不匹配的问题。

简易的网络通信流程

以两个应用程序的通信为例,程序 A 给程序 B 发送消息,基本流程如下:

  • A 把数据发送到 TCP 发送缓冲区。
  • TCP 发送缓冲区再把数据发送出去,经过网络传递后,数据会发送到 B 所在服务器的 TCP 接收缓冲区。
  • B 再从 TCP 接收缓冲区去读取属于自己的数据。

也就是说,消息发送要经过应用层的程序 A、A 所在服务器的 TCP 发送缓冲区,经过网络传输后消息发送到了另一个应用层的程序 B 所在服务器的 TCP 接收缓冲区,最终 B 读取到消息。

阻塞和非阻塞

由于应用程序之间发送消息是间断性的,那么,当程序 B 所在服务器的 TCP 接收缓冲区,还未接收到消息数据时,此时 B 向 TCP 接收缓冲区发起读取申请,TCP 接收缓冲区是应该马上告诉 B 现在没有你的数据,还是让 B 继续等待,直到有数据再交给 B。

对于应用程序 A 也是一样。A 在向 TCP 发送缓冲区发送数据时,如果 TCP 发送缓冲区已经满了,那么是立即告诉 A 现在没空间了,还是让 A 等着,等 TCP 发送缓冲区有空间了再把 A 的数据拷贝到发送缓冲区。

阻塞:以读取数据为例,当程序 B 发起读取申请时,在内核把数据准备就绪之前,B 一直处于等待状态(其它什么也不做),直到内核把数据准备好交给 B,才结束。

基本流程:

  1. 程序进程(或线程)向内核发起 recfrom 读取数据。
  2. 内核准备数据。
  3. 程序进程将数据从内核空间拷贝到用户空间。
  4. 拷贝完成后,返回成功提示。

非阻塞:以读取数据为例,当程序 B 发起读取申请时,如果内核没有把数据准备好,会立即告诉 B(返回提示或错误),不会让 B 一直等待。

基本流程:

  1. 程序进程(或线程)向内核发起 recfrom 读取数据。
  2. 内核没有把数据好时,立即返回 EWOULDBLOCK 错误码。
  3. 程序进程不断调用 recvfrom,即向内核发起轮询请求。
  4. 当数据准备就绪,就进行下一步;否则还是返回错误码。
  5. 将数据从内核空间拷贝到用户空间。
  6. 拷贝完成后,返回成功提示。

IO 操作分为两个阶段(步骤)

  • 内核进行数据准备的阶段
  • 数据从内核空间拷贝到用户空间的阶段

根据这两个阶段,处理方式的不同,IO 操作可细分为下面五种。

阻塞 IO 模型

阻塞 IO 模型是指当程序 B 发起 IO 请求时,如果内核没有把数据准备就绪,B 会一直处于等待状态,直到内核把数据准备好了,并交给 B 才结束。

优点:开发相对简单,在阻塞期间,用户线程被挂起,期间不会占用 CPU 资源。

缺点

  • 连接利用率不高,内核如果没有响应数据,则该连接一直处于阻塞状态,占用连接资源。
  • 一个线程维护一个 IO 资源,当有大量并发请求时,需要创建等价的线程来处理请求,不适合高并发场景。

非阻塞 IO 模型

非阻塞 IO 模型是指当程序 B 发起 IO 请求时,如果内核没有把数据准备就绪,会立即告诉 B(返回提示或错误码),这样,B 就不会一直等待,而是每过一段时间发起轮询请求

优点:每次发起 IO 请求时,在内核准备数据的过程中可以立即返回,用户线程不会阻塞,实时性较好。

缺点

  • 当用户线程 B 没有获取到数据时,需不断轮询,占用大量 CPU 时间,效率不高。
  • 和阻塞 IO 一样,一个线程维护一个 IO 资源,当有大量并发请求时,需要创建等价的线程来处理请求,不适合高并发场景。

IO 复用模型

思考一个问题:

高并发的情况下,有很多人向应用程序 B 发送消息,此时程序 B 可能就要创建很多个线程,每个线程都会调用 recvfrom 读取数据。B 是不知道什么时候 TCP 接收缓冲区里会有数据,为了保证能及时读取到消息,每个线程必须不断内核发起 recvfrom 请求。

问题在于,大量线程不断调用 recvfrom,实在太浪费系统资源。

于是,有人便提出了一个解决思路:

在程序 B 中,采用 IO 复用器(select),监控多个网络请求(fd 文件描述符,句柄),这样,只需一个线程就可以完成数据状态的询问操作,当内核空间中有数据准备就绪,再分配其他的线程(或自己)去读取数据。而不用为每一个请求创建一个线程,从而节省出大量的线程资源。这就是 IO 复用模型

Linux 中 IO 复用的实现方式主要有 Select、Poll 和 Epoll。

IO 复用模型的思路是系统提供了一种函数可以同时监控多个 fd,这个函数就是我们常说的 select、poll 或 epoll,程序线程通过调用 select 函数就可以同时监控多个 fd,监控的 fd 集合中只要有任何一个数据状态准备就绪了,select 函数就会返回可读状态,这时再去分配其他线程(或自己),发起 recvfrom 请求读取数据。

Select

水平触发(Level Triggered),它会无差别地遍历(轮询)整个被监听的 fd 文件描述符集合(fd_set) 。如果有哪一个 fd 准备就绪,就返回这个活跃的连接。fd_set 的大小是受限制的(由 Linux 内核的 FD_SETSIZE 定义)。

  • 事件集合:通过3个参数分别可读、可写及异常等事件。内核通过对这些参数的在线修改来反馈其中的就绪事件,使得每次调用 select 函数都要重置这 3 个参数。
  • 工作模式:LT
  • 程序获得就绪 fd 的复杂度:时间复杂度 O(n)
  • 支持最大的 fd 数量:一般为 1024
  • 内核实现和复杂度:轮询方式检测就绪事件,时间复杂度:O(n)
Poll

原理和 select 类似,poll 底层需要分配一个 pollfd 结构数组,维护在内核中,不受 fd_set 大小的限制。

  • 事件集合:统一处理所有事件类型,因此只需要一个事件集参数。用户通过 pollfd.events 传入要监听的事件,内核通过修改 pollfd.revents 反馈其中就绪的事件。
  • 工作模式:LT
  • 程序获得就绪 fd 的复杂度:时间复杂度 O(n)
  • 支持最大的 fd 数量:65535
  • 内核实现和复杂度:轮询方式检测就绪事件,时间复杂度:O(n)
Epoll

边缘触发(Edge Triggered),采用事件驱动和回调函数。三大要素:mmap、红黑树、链表。

  • 事件集合:内核通过一个事件表直接管理用户程序监听的所有事件。因此每次调用 epoll_wait 时,无需反复传入事件。epoll_wait 系统调用的参数 events 仅用来反馈就绪的事件。
  • 工作模式:ET
  • 程序获得就绪 fd 的复杂度:时间复杂度 O(1)
  • 支持最大的 fd 数量:65535
  • 内核实现和复杂度:回调方式检测就绪事件,时间复杂度:O(1)

说明:epoll 并不是在所有的场景都比 select 和 poll 高效很多,尤其是当活动连接比较多的时候。epoll 特别适用于连接数量多,但活动连接较少的场景

小结

select、poll、epoll 都是 IO 多路复用的机制。

IO 多路复用就是通过一种机制,监听多个 fd 文件描述符,一旦某个 fd 准备就绪,能够通知程序进行相应的读写操作。

但 select、poll、epoll 本质上都是同步 IO,因为它们都需要在读写事件就绪后自己负责读写(一个个的处理),也就是说这个读写过程是阻塞的。而异步 IO 则无需自己负责读写,异步 IO 的实现会负责把数据从内核空间拷贝到用户空间。

IO 复用的基本思想是通过 select、poll、epoll 来监控多个 fd ,达到不必为每个 fd 创建一个对应的监控线程,从而减少线程资源的创建。

IO 复用的优势并不是对于单个连接处理得多快,而是在于能处理更多的连接。

IO 复用模型的优点:系统不必创建和维护大量的线程,只需要一个或几个线程便可同时处理成千上万个连接,大大减少了系统的开销。

IO 复用模型的缺点:本质上还是同步阻塞模式。

信号驱动 IO 模型

IO 复用模型实现了一个线程可以监控多个 fd,但 select 是采用轮询的方式来监控多个 fd 的,通过不断轮询 fd 的状态来判断是否有数据准备就绪,这种无脑的轮询显得有些暴力,因为大部分情况下的轮询都是无效的。

所以有人就想,能不能不要让我总是去询问你是否有数据准备就绪,能不能我发出请求后等你数据准备好了就通知我。于是,就诞生了信号驱动 IO 模型。

信号驱动 IO 模型是指程序 B 通过系统调用 sigaction,向内核空间注册一个信号处理回调函数,就立即返回(非阻塞);当内核把数据准备就绪时,会发送一个信号(SIGIO)通知 B,B 再向内核调用 recvfrom 读取数据。

信号驱动 IO 模型解决了轮询询问数据状态的问题,线程在发出信号监控后立即返回(非阻塞),因此,一个线程也可以同时监控多个 fd。

信号驱动 IO 也可以看成是一种异步非阻塞 IO。

在内核进行数据准备期间,程序进程(线程)不阻塞。但是,当程序进程(线程)将数据从内核空间拷贝到用户空间期间,是阻塞的。这是它和异步 IO 的本质区别。

异步 IO 模型

可以发现,不管是 IO 复用还是信号驱动 IO,要读取数据需发起两次请求,第一次发起判断数据就绪状态的请求第二次发起 recvfrom 读取数据的请求

为什么我们明明是想读取数据,却非要先发起一个判断数据状态的请求,然后再发起真正的读取请求。有没有一种方法,只需发送一个请求告诉内核我要读取数据,就什么都不管,由内核去帮我完成所有的事情。

于是,异步 IO 模型便诞生了。

异步 IO 模型是指程序 B 向内核发送一个 IO 请求(比如 read),告知内核我要读取数据,便立即返回;内核收到请求后会与之建立一个信号联系,当数据准备就绪,内核会主动把数据从内核空间复制到用户空间。所有操作完成之后,内核会发送一个完成通知告知 B。

异步 IO 模型做到了真正的非阻塞。

异步 IO 模型与信号驱动 IO 模型的主要区别在于,信号驱动 IO 只是让内核通知我们何时可以开始下一个 IO 操作,而异步 IO 模型是让内核通知我们操作什么时候完成。

此模型和前 4 个模型最大的区别是:前 4 个模型从内核空间拷贝数据到用户空间这一过程,必须由程序自身来进行,必然是阻塞的。
而异步 IO 模型在内核准备数据数据从内核空间拷贝到用户空间这两个过程都不用等待,完全非阻塞。

用户进程(线程)完全不需要关心实际的整个 IO 操作是如何进行的,只需先发起一个请求,当收到内核返回的成功信号时,所有的 IO 操作都已完成。它是最理想的模型。

五种 IO 模型的对比

  • 阻塞 IO:在数据准备阶段和数据拷贝阶段,都会阻塞。
  • 非阻塞 IO:在数据准备阶段,非阻塞;但在数据拷贝阶段阻塞。
  • IO 复用:在数据准备阶段,采用复用器轮询遍历多个 fd,会阻塞;在数据拷贝阶段阻塞。但是,它用单一线程监听了多个连接,减少了线程资源的创建。
  • 信号驱动 IO:在数据准备阶段,注册一个信号处理函数,来接收内核准备数据的结果信号,实现非阻塞;在数据拷贝阶段阻塞。
  • 异步 IO:在数据准备阶段和数据拷贝阶段,都非阻塞。

IO 模型里的同步和异步

我们常常可以听到同步阻塞 IO、同步非阻塞 IO、异步 IO。

应用程序发起读取数据请求,若数据还没准备就绪,需要等待就是阻塞;反之,若立即返回就是非阻塞。

那么,这里的同步和异步怎么理解呢?

同步:是指应用程序发起 IO 请求,从发起请求到请求最终完成,整个过程都需要参与其中(与内核进行交互)。

异步:是指应用程序发起 IO 请求之后,就不再参与后续的具体过程,接收最终完成结果的通知由回调实现。

为什么只有异步非阻塞而没有异步阻塞?因为异步 IO 模型下,程序发送完请求指令后就立即返回了,没有任何后续流程。因此,它注定不会阻塞,也就只会有异步非阻塞。

阻塞必然同步,同步不一定阻塞。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值