Skip to content

LV005-IO模型简介 ​

什么是IO模型?若笔记中有错误或者不合适的地方,欢迎批评指正😃。

我们经常提到 IO、 NIO 这些名词。 那么, 到底什么是 IO 呢? 什么又是 NIO 呢? 另外,我们平时又会听到两组很相似的概念: 阻塞/非阻塞、 同步/异步。 那么, 阻塞和非阻塞有什么区别呢? 同步和异步的差别又在哪里呢?

一、IO的基本概念 ​

1. 什么是IO? ​

IO 是英文 Input 和 Output 的首字母, 代表了输入和输出, 当然这样的描述有一点点抽象,更直观的意思是计算机的输入与输出。 在冯.诺依曼架构中, 将计算机分成了 5 个部分, 分别是运算器, 控制器, 存储器, 输入设备, 输出设备。

image-20250122153344117

上图中的输入设备指的是鼠标和键盘等向计算机输入数据和信息的设备, 输出设备指的是电脑显示器等用于计算机信息输出的设备, 下面对计算机输入输出过程进行实际举例, 当敲击键盘(输入设备) 任意按键后, 按键的数据会传递给计算机, 计算机 CPU 会对数据进行运算,运算完成之后会将数据输出到显示器(输出设备) 上,整个过程如下图:

image-20250122153427131

鼠标、 显示器只是输入输出的直观表现形式, 而在计算机架构层面上, IO 是涉及计算机核心与其他设备间数据迁移的过程。 以磁盘 IO 为例, 内存读取磁盘数据和将内存数据写入磁盘, 就是一对输入输出的过程。

2. IO的执行过程 ​

操作系统(Linux) 负责对计算机的资源进行管理和对进程进行调度, 应用程序运行在操作系统上, 处于用户空间。 应用程序不能直接对硬件进行操作, 只能通过操作系统提供的 API 来操作硬件。 需要将进程切换到内核空间, 才能进行 IO 操作, 并且应用程序不能直接操作内核空间的数据, 需要把内核空间的数据拷贝到用户空间。

应用程序运行在用户空间, 它不存在实质的 IO 过程, 真正的 IO 是在操作系统执行的。 那么应用程序操作 IO 就会有两个动作: IO 调用和 IO 执行。 IO 调用是应用程序向操作系统内核发起调用, IO 执行是操作系统内核完成的 IO 操作。

一个完整的 IO 过程需要包含以下三个步骤 :

(1) 用户空间的应用程序向内核发起 IO 调用请求(系统调用)

(2) 内核操作系统准备数据, 把 IO 设备的数据加载到内核缓冲区

(3) 操作系统拷贝数据, 把内核缓冲区的数据拷贝到用户进程缓冲区

image-20250122155231514

二、IO模型的分类 ​

假设有这样一个场景, 从磁盘中循环读取 100M 的数据并处理, 磁盘读取 100M 需要花费20 秒的时间, CPU 同样也需要 20 秒的时间处理完这些数据。 如果采用传统的模式编写代码:读数据→等待数据读取完毕→数据处理, 可以发现, 数据的读取花费了一半的时间, 而这就导致该任务的效率极其低下, 那么能不能在等待数据的同时对数据进行处理呢? 当然可以! 这时候就轮到 IO 编程模型来出场了。

IO 模型根据实现的功能可以划分为为阻塞 IO、 非阻塞 IO、 信号驱动 IO, IO 多路复用和异步 IO。 根据等待 IO 的执行结果进行划分, 前四个 IO 模型又被称为同步 IO, 如下图

image-20250122155218349

所谓同步, 即发出一个功能调用后, 只有得到结果该调用才会返回。 异步的概念和同步相对。 当一个异步过程调用发出后, 调用者并不能立刻得到结果, 实际处理这个调用的部件在完成后, 通过状态、 通知和回调来通知调用者。

以现实生活去餐馆吃饭为例, 根据菜单进行点餐之后, 这时会存在两个选择, 第一个选择是在餐馆等待饭菜制作完毕, 这就是同步 IO 的具体表现。 第二个选择是, 离开餐馆去做其他的事情, 工作人员会在饭菜制作完成之后提醒我们回餐馆取餐, 这就是异步 IO 的具体表现。下面让我们来了解一下这五种 IO 模型。

1. 阻塞IO ​

以阻塞读为例: 进程进行 IO 操作时(如 read 操作), 首先会发起一个系统调用, 从而转到内核空间进行处理, 内核空间的数据没有准备就绪时, 进程会被阻塞, 不会继续向下执行, 直到内核空间的数据准备完成后, 数据才会从内核空间拷贝到用户空间, 最后返回用户进程, 由用户空间进行数据的处理, 如下图

image-20250122160058568

以现实生活中的钓鱼为例, 在做好相应准备抛下鱼钩之后, 需要耐心等待鱼儿的上钩, 等待的过程中必须聚精会神的关注鱼竿的状态, 鱼儿上钩之后立刻扬竿, 这就是阻塞 IO 在实际生活中的事例。

通过上述例子可以总结出阻塞 IO 的优势与不足, 首先可以及时的获取结果, 并立刻对获取到的结果进行处理, 然而在获取结果之前, 无法去处理其他任务, 需要时刻对结果进行监听。

阻塞 IO 比较有代表性的是 C 语言中的 scanf()函数。 编写好的 io.c 文件, 如下所示:

c
#include <stdio.h>
int main(int argc, const char *argv[]){
    int i = 0;
    scanf("%d", &i);
    printf("i = %d\n", i);
    return 0;
}

在以上代码中, scanf 函数用于从键盘上接收数据, 如果键盘不进行数据的输入, 该任务会持续阻塞, 只有键盘输入数据之后, 才会有相应的输入值打印到系统终端上。

2. 非阻塞 IO ​

和阻塞 IO 模型不同, 非阻塞 IO 进行 IO 操作时, 如果内核数据没有准备好, 内核会立即向进程返回 err, 不会进行阻塞; 如果内核空间数据准备就绪, 内核会立即把数据返回给用户空间的进程, 如下图

image-20250122160849733

仍旧以现实生活中钓鱼为例, 在做好相应准备抛下鱼钩之后, 这次并没有持续不断的关注鱼竿的状态, 而是去做其他的事情(不阻塞等待结果) , 每隔几分钟对鱼竿的状态进行检查,如果没有鱼儿上钩, 就继续去做其他事情, 如果上钩了就把鱼钓上来,这就是非阻塞 IO 在实际生活中的事例。

从上述案例中可以看出非阻塞 IO 的优点是效率高, 同样的时间可以做更多的事。 但是缺点也很明显, 需要不断对结果进行轮询查看, 从而导致结果获取不及时(结果可能在两次轮询之间就已经准备完毕, 但是只能在发起轮询的时候才能知道) , 如果要增加非阻塞 IO 的实时性, 就要加快轮询的频率, 但这样无疑也会增加 CPU 的负担。

3. 信号驱动IO ​

信号驱动 IO 顾名思义与信号相关。 系统在一些事件发生之后, 会对进程发出特定的信号,而信号与处理函数相绑定, 当信号产生时就会调用绑定的处理函数。 例如在 Linux 系统任务执行的过程中可以按下 ctrl+C 来对任务进行终止, 系统实际上是对该进程发送一个 SIGINT 信号,该信号的默认处理函数就是退出当前程序。

具体到 IO 模型上, 可以对 SIGIO 信号注册相应的信号处理函数, 并打开对应描述符的信号驱动。 每当有 IO 数据产生时, 系统就会发送一个 SIGIO 信号, 进而调用相应的信号处理函数,从而在这个处理函数中对数据进行读取, 如下图

image-20250122161444752

仍旧以现实生活中的钓鱼为例, 在做好相应准备抛下鱼钩之后, 这次同样没有持续不断的关注鱼竿的状态, 而是去做其他的事情(不阻塞等待结果) , 与之前不同的是, 在鱼竿处绑定了一个提醒铃铛, 当鱼咬钩之后, 铃铛就会响(有 SIGIO 信号), 进而得知到鱼儿上钩的消息,这样就可以及时把鱼钓上来了(调用处理函数)。

4. 多路复用IO ​

通常情况下使用 select()、 poll()、 epoll()函数实现 IO 多路复用。 这里以 select 函数为例进行讲解, 使用时可以对 select 传入多个描述符, 并设置超时时间。

当执行 select 的时候, 系统会发起一个系统调用, 内核会遍历检查传入的描述符是否有事件发生(如可读、 可写事件) 。 如有, 立即返回, 否则进入睡眠状态, 使进程进入阻塞状态, 直到任何一个描述符事件产生后(或者等待超时) 立刻返回。 此时用户空间需要对全部描述符进行遍历, 以确认具体是哪个发生了事件, 这样就能使用一个进程对多个 IO 进行管理, 如下图 :

image-20250122161612367

继续以现实生活中的钓鱼为例, 和之前案例只有一个鱼竿不同, 这次会在十个不同的地方做好相应准备抛下鱼钩, 并把十个鱼竿连在了一个铃铛上, 这样只要铃铛响了就表示有鱼上钩,只需挨个检查到底是哪个鱼竿有鱼上钩即可。

这样的优点是一个进程/线程可以同时监听和处理多路 IO, 效率成倍提高。 但是 IO 多路复用并不是能医治百病的良药, 虽然 IO 多路复用可以监听多个 IO, 但是实际上对结果的处理也只能依次进行, 比较适合 IO 密集但是每一路 IO 数据量不多且到达时间分散的场合(如网络聊天) 。

另外 select 监听的描述符有上限(一般描述符最大不超过 1024) , 而且需要遍历究竟是哪一个 IO 产生了数据。 因此 IO 较多时, 效率不高(这个问题被 epoll 解决) 。

5. 异步IO ​

aio_read 函数常常用于异步 IO, 当进程使用 aio_read 读取数据时, 如果数据尚未准备就绪就立即返回, 不会阻塞。 若数据准备就绪就会把数据从内核空间拷贝到用户空间的缓冲区中,然后执行定义好的回调函数对接收到的数据进行处理。

image-20250122161736246

最后, 还是以钓鱼为例。 小马同学喜欢吃新鲜的鱼, 但是不想自己钓, 所以他请了一个助手来帮他钓鱼, 他自己去忙其他的事情(进程不阻塞, 立即返回) 。 如果有鱼上钩助手会帮忙钓上来(将数据拷贝到指定的缓冲区) , 并立即通知小马同学回来把鱼取走(处理数据) 。