高级 I/O

阅读前导:

“高级 I/O”处于知识树中网络和操作系统的最后,因此本文默认读者有计算机网络和操作系统的基础。

1. 什么是 I/O

下面以“流”(stream)和冯诺依曼体系架构的视角来简单回顾一下什么是 I/O:

I/O 可以理解为数据在计算机内部和外部之间的流动。

在冯诺依曼体系架构中,程序和数据都是以二进制编码的形式存储在存储器中,CPU 可以直接访问存储器中的任何位置,也可以通过输入设备和输出设备与外部世界进行数据交换。因此,I/O 就是数据在存储器和输入输出设备之间的传输,或者说是数据在 CPU 和外部世界(即外设)之间的交换。

image-20220926132952292

I/O 的速度和效率受到多种因素的影响,例如存储器的容量和速度、输入输出设备的性能和类型、总线或接口的带宽和协议、CPU 的运算能力和指令集、操作系统的调度和管理、程序的设计和优化等。

2. I/O 的本质

==I/O = 等待 + 数据拷贝==

例子:假设要对磁盘中的文件修改,包括两个步骤:

  1. 将磁盘中的文件加载到内存的缓冲区中
  2. 将内存中的文件修改,然后再写回磁盘

什么是高效的 I/O ?

其中,如果缓冲区中没有数据,CPU 会阻塞地等待,直到缓冲区中有数据之后才会拷贝数据。如果等待的时间占比过大,就会造成 I/O 低效。也就是说,降低单位时间内,等待的比例,就相当于提高 I/O 的效率。

上面只是一个单机中的例子,实际上可能很难体会到效率上的差距,因为 I/O 的距离太短了。那么对于像网络这样的长距离的 I/O,效率就显得十分重要了。

如何理解这个“等”呢?

像我们在使用 C 或 C++的 scanf 或 cin 时,程序运行起来光标会一直闪烁,这就是程序在等待标准输入中的数据,这个数据就是从外设键盘而来。文件和网络相关接口诸如 read()、write() 和 send()、 recv() 也是类似的。事实上也是如此,例如 read() 就是将内核缓冲区的数据拷贝到用户缓冲区,write() 就是将用户缓冲区的数据拷贝到内核缓冲区。

  • 从 OS 的视角:调用这些 I/O 接口的进程或线程会被阻塞,更底层地说,操作系统将该进程或线程的状态设置为某种非 R 状态,然后将其放入等待队列中,直到缓冲区中的数据就绪后再唤醒它;
  • 从 I/O 的视角:阻塞就是让这些调用 I/O 的进程在“等”。

网络通信的本质可以从不同的角度来理解:

  • 从数据流的角度,网络通信就是数据在计算机内部和外部之间的流动;
  • 从协议的角度,网络通信就是一系列的规则和约定,保证通信的顺利进行;
  • 从进程的角度,网络通信就是不同计算机上的进程之间的通信。

其中进程是最具象的角度,因为数据的交换和共享的主体是进程,它们通过文件描述符来访问网络资源,例如套接字、管道、FIFO 等。在 Linux 下,一切皆文件,这意味着所有的设备、资源和对象都可以用统一的方式来操作,即打开、读写、关闭等。进程的 TCB 控制块存储进程的各种信息,例如进程 ID、状态、优先级、寄存器、信号、文件描述符等。每个进程都有一个文件描述符表,用来记录文件描述符和文件之间的映射关系,文件描述符是一个非负整数,是文件描述符表的下标,用来标识进程打开的文件。

3. 五种 I/O 模型

3.1 引入

下面用打电话的例子作为引入:

  1. 阻塞 I/O:你在打电话给一个朋友,你一直等待他接听,直到你们开始通话,期间你不能做其他事情。
  2. 非阻塞 I/O:你在打电话给一个朋友,如果他没有接听,你就挂断,然后做其他事情,或者过一会再打一次,直到你们开始通话。
  3. I/O 多路复用:你在打电话给多个朋友,你用一个电话机同时拨打他们的号码,然后等待电话机响铃,告诉你哪些朋友接听了,你就可以和他们通话,而不需要每次只打一个电话。
  4. 信号驱动 I/O:你在打电话给一个朋友,你让电话机在他接听时给你发一个短信,你就可以和他通话,期间你可以做其他事情,或者等待短信的通知。
  5. 异步 I/O:你在打电话给一个朋友,你让电话机在你们通话结束时给你发一个短信,你就可以和他通话,期间你可以做其他事情,或者等待短信的通知。

什么是阻塞和非阻塞?

什么是同步和异步?

阻塞和非阻塞是一种调用机制,主要指的是调用者(程序)在等待返回结果(或输入)时的状态。阻塞时,在调用结果返回前,当前线程会被挂起,并在得到结果之后返回。非阻塞时,如果不能立刻得到结果,则该调用不会阻塞当前线程,而是直接返回一个错误信息或空值,因此调用者需要多次调用或轮询来检查结果是否就绪。

阻塞和非阻塞的概念通常和 I/O 操作(如文件读写,网络通信等)联系在一起,因为 I/O 操作涉及到系统调用,即用户空间的程序通过调用操作系统内核提供的接口来完成一些特权操作。系统调用可能会因为 I/O 设备的速度或者网络延迟等原因不能立即完成,这时操作系统内核会将调用者的进程挂起为等待状态,直到 I/O 操作完成后再唤醒该进程。这就是阻塞式的 I/O 操作。

非阻塞式的 I/O 操作则是指调用者在发起系统调用后,不会等待 I/O 操作的完成,而是立即返回。这样调用者可以继续执行其他的任务,而不会被阻塞。但是这也带来了一个问题,就是调用者如何知道 I/O 操作的结果呢?一种方法是调用者定期检查 I/O 操作的状态,这叫做轮询(polling)。另一种方法是调用者注册一个回调函数(callback),当 I/O 操作完成时,操作系统内核会调用该函数来通知调用者。这叫做异步(asynchronous)I/O 操作。

阻塞和非阻塞是描述调用者在等待结果时的状态,而同步和异步是描述调用者如何获取结果的方式。阻塞和非阻塞的区别在于是否让出 CPU 的控制权,而同步和异步的区别在于是否需要主动轮询或被动通知。

3.2 阻塞 I/O

阻塞 I/O 是一种在输入输出操作期间让进程或线程等待的 IO 模型,进程或线程在调用 I/O 操作时,会一直等待数据就绪,直到数据从内核缓冲区拷贝到进程缓冲区,然后才返回。言外之意,阻塞 I/O 在“等”和“拷贝”阶段都不会返回。

在 Linux 中,有很多系统调用是阻塞 I/O 的,例如 read, write, accept, connect, recv, send 等,即所有的套接字默认都以阻塞方式工作,这是因为:

  • 阻塞方式是最简单易用和最直观的 I/O 模型,它可以保证数据的完整性和一致性,不需要额外的处理逻辑。
  • 阻塞方式可以避免 CPU 的空转和资源的浪费,因为当 I/O 操作不能立即完成时,进程或线程会被挂起,让出 CPU 给其他任务。
  • 阻塞方式可以适应各种网络环境和数据量,因为它会根据缓冲区的大小和状态来调整数据的发送和接收,不会造成数据的丢失或拥塞。

缺点是效率低,因为在等待数据就绪和拷贝数据的过程中,进程或线程无法做其他事情,浪费时间和资源。所以阻塞 I/O 适合于数据量不大,实时性要求不高的场景。

3.3 非阻塞 I/O

非阻塞 I/O 是一种在输入输出操作期间让进程或线程不需要等待的 I/O 模型。

  • 当进程或线程调用一个 I/O 操作时,如果数据还没有准备好,内核会立即返回一个错误码,表示不能执行该操作,而不会阻塞进程或线程。
  • 进程或线程可以根据返回的错误码(EWOULDBLOCK,error would block)来判断是否需要重试该操作(在这里通常会轮询),或者执行其他的任务,这样就可以避免浪费时间在等待数据上。
  • 当数据准备好了,应用程序再次调用该 I/O 操作时,内核会将数据从内核缓冲区拷贝到用户空间,这个过程可能会阻塞进程或线程,直到数据拷贝完成。

它的优点是可以提高程序的并发性和响应性,缺点是需要额外的处理逻辑和轮询机制。轮询机制是指:

  • 非阻塞 IO 往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对 CPU 来说是较大的浪费,一般只有特定场景下才使用 。
  • 轮询的方式有多种,例如忙等待(busy-waiting),信号驱动(signal-driven),select,poll,epoll 等,它们的效率和适用性各有不同 。
  • 轮询的目的是及时地检测到数据的就绪状态,从而进行数据的读写操作,但是这也会导致程序的复杂度增加,以及对 CPU 的占用率和功耗的影响 。

阻塞 I/O 和非阻塞 I/O 的区别?

除了上述阻塞 I/O 和非阻塞 I/O 的检测数据就绪方式有区别以外,检测数据就绪的主体也有不同:

  • 阻塞 I/O 当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的

  • 非阻塞 I/O 当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。这也是阻塞 I/O 和非阻塞 I/O 的一个重要的区别。

3.4 多路复用 I/O

多路复用 I/O 是一种同步 I/O 模型,实现一个线程或进程可以同时监视多个文件描述符是否可以执行 I/O 操作。多路复用 I/O 的原理是:

  • 当应用程序调用一个多路复用 I/O 的函数(如 select, poll, epoll 等)时,它会将想要监视的一组文件描述符传递给内核(让它监视),然后阻塞等待某些事件的发生或超时。(注意,这些函数只负责 I/O 中“等”的操作
  • 当内核检测到某些(至少一个)文件描述符就绪(可以进行读或写操作)或者超时,它会将就绪的文件描述符集合返回给应用程序,然后应用程序可以对这些文件描述符进行相应的 I/O 操作(如果多路复用 I/O 的函数完成了“等待”,那么用户进程只需要进行“拷贝操作”)。
  • 应用程序可以根据不同的多路复用 I/O 的函数,设置不同的参数和选项,来控制文件描述符的监视方式,如超时时间,事件类型,触发模式等。

I/O 多路复用的效率更高,因为进程可以一次处理多个 I/O 事件,而不需要轮询,而且可以减少 I/O 等待的时间,但是数据拷贝的开销仍然存在,并且也需要额外的处理逻辑和系统调用。

什么是“多路复用”?(多路复用将会在后续继续学习)

I/O 操作包括等待和拷贝两个步骤,而像 read、recvfrom 等 I/O 系统调用,一个进程或线程一次只能对一个文件描述符操作(注意数量上的对应),如果需要同时处理多个 I/O 事件,自然而然地想创建多个进程或线程,我们知道这么做是很难的,因为维护进程和线程的成本不低。

所以 Linux 的 select、poll、epoll 接口的参数都是文件描述符数组,而不是一个文件描述符。这样,用户进程可以一次性地监测多个文件描述符的 I/O 状态,而不需要逐个地检查。这种方式可以提高 I/O 的效率,避免不必要的阻塞和轮询。

这就好像上学时老师总会定几个组长,这样每次收作业时老师只需要等这几个组长,但实际上等待不同组的同学上交作业的时间是有重叠的,这样便节省了时间。另外一个例子:百米赛跑都是几个人一起跑,而不是一个一个地测。

3.5 信号驱动 I/O

信号驱动 I/O 是一种在输入输出操作期间让进程或线程不需要等待,而是通过信号通知的 I/O 模型。信号驱动 I/O 的原理是:

  • 当进程或线程调用一个 I/O 操作时,如果数据还没有准备好,内核会立即返回一个错误码,表示不能执行该操作,而不会阻塞进程或线程。
  • 进程或线程可以为该文件描述符设置一个信号处理函数,当数据准备好了,内核会发送一个 SIGIO 信号给进程或线程,然后调用信号处理函数。而不需要轮询或阻塞。
  • 信号处理函数可以对文件描述符执行 I/O 操作,直到数据读写完毕或者出现错误。

信号驱动 I/O 的效率和 I/O 多路复用相当,因为进程可以避免无效的轮询,而且可以在信号处理函数中执行 I/O 操作,但是数据拷贝的开销仍然存在,而且需要额外的处理逻辑和信号处理函数。

值得注意的是,虽然信号的产生是异步的,但信号驱动 IO 是同步 IO 的一种。

为什么说信号的产生是异步的?

信号的产生是异步的,是指信号的发生时间和进程的执行状态没有固定的关系,也就是说,信号可以在任何时刻发生,不管进程正在做什么。

[注]:信号的产生通常是由外部事件触发的,例如用户按下 Ctrl+C,或者系统发生异常,或者其他进程发送了信号等。信号的产生是一个中断的过程,它会打断进程的正常执行流程,让进程去处理信号。

什么是同步 I/O?

同步 I/O 的特点是在 I/O 操作进行时,用户线程会被阻塞,直到 I/O 操作完成后才返回。同步 I/O 通常需要用户线程主动发起 I/O 请求,并等待或轮询 I/O 操作的结果。同步 I/O 的优点是简单易用,缺点是效率低下,因为用户线程在等待 I/O 操作时无法做其他事情。

信号驱动 I/O 是同步 I/O 的一种,是因为在信号产生后,用户进程还需要调用 IO 系统调用来完成数据的读写操作,这个过程是阻塞的,因为信号的处理是在进程的控制下进行的,进程可以选择是否接收信号,以及何时处理信号,而不是被动地等待信号的到来。所以用户进程需要等待 I/O 操作的完成。

异步 I/O 则不同,用户进程只需要发起 I/O 请求,然后就可以继续做其他事情,当 I/O 操作完成后,内核会通知用户进程,而不需要用户进程再次调用 I/O 系统调用。

3.6 异步 I/O

异步 I/O 是在 I/O 操作进行时,用户进程不需要等待或轮询 I/O 操作的结果,而是继续执行其他任务。当 I/O 操作完成后,内核会发送信号通知用户进程,用户进程再根据 I/O 事件执行相应的回调函数。这样用户进程就不需要等待或轮询 I/O 状态,而是在收到信号后,直接获取 I/O 结果。

异步 I/O 的原理是利用操作系统的内核支持,让内核负责数据的传输和通知。不同的操作系统有不同的异步 I/O 实现方式,比如 Linux 的 epoll,Mac 的 kqueue,Windows 的 IOCP 等。这些方式都是基于事件驱动的,即当 I/O 事件发生时,内核会将事件放入一个队列,用户进程可以从队列中获取事件,并执行相应的回调函数。这样,用户进程就不需要主动查询 I/O 状态,而是被动地响应 I/O 事件。

异步 I/O 的效率最高,因为进程可以完全避免阻塞和轮询,而且不需要数据拷贝,因为内核会直接将数据放到进程指定的位置,也就是说“等”和“拷贝”两个 I/O 操作都由操作系统完成,用户进程只需要发起 IO 请求,然后就可以去做其他事情,不需要关心 IO 的具体细节,比如数据的传输、缓冲、通知等。这些细节都由内核来处理,用户进程或线程只需要在 IO 完成后,根据内核的通知,执行相应的回调函数。

这样,它可以充分利用 CPU 资源,提高系统的吞吐量和效率,缺点是编程复杂度较高,需要处理好异步通知和回调函数。

3.7 小结

由于 I/O=等待资源就绪+拷贝资源,那么异步 I/O 就是最理想的模式,因为它可以将“等”和“拷贝”的开销都降到最低。阻塞 I/O、非阻塞 I/O 和信号驱动 I/O 本质上不能提高 I/O 的效率,但非阻塞 I/O 和信号驱动 I/O 能提高整体的效率。

4. 同步通信和异步通信

同步和异步是两种不同的消息通信机制,它们主要区别在于调用者和被调用者之间的交互方式:

  • 同步通信是指调用者在发出一个调用后,必须等待被调用者返回结果,才能继续执行后续的操作。这种方式的好处是调用者可以马上得到结果,不会错过任何信息,但是也会造成调用者的阻塞和等待,降低效率。

  • 异步通信是指调用者在发出一个调用后,就可以继续执行后续的操作,不需要等待被调用者返回结果。这种方式的好处是调用者可以充分利用时间,提高效率,但是也会导致调用者无法马上得到结果,需要通过其他的方式来获取信息,比如状态、通知或回调函数。

“调用”是指对一个函数或者一个系统服务的请求,也就是让一个已经定义好的代码段执行一定的功能。通信是一种手段,目的是达成进程间的资源交换或共享,数据不一定对通信双方都有用,例如在 C/S 模式下,server 处理后的数据通常是 client 需要的。

同步通信是需要等待的,而异步通信是不需要等待的。同步通信是直接获取结果的,而异步通信是通过其他方式获取结果的。

因此,我们可以以“进程或线程是否参与 I/O”为标准,判断以上五种 I/O 模型是否为同步 I/O(除了异步 I/O,其他都是同步 I/O)。

照这么说,非阻塞 I/O 也算是同步 I/O 吗?它在数据未就绪,也就是未得到结果时就直接返回一个错误码了。

虽然在数据未就绪时返回错误码,但是这不是一次完整的 I/O,即它没有完成“等”+“拷贝”两个步骤,所以用户进程才会需要用循环不断轮询它,如果返回值不是错误码,那就说明数据就绪了,这样用户进程才会进行一次完整的 I/O。

从这个例子中,还可以将 I/O 中的“等待”分为“阻塞式地等待”和“非阻塞式地等待”,其中非阻塞 I/O 就是后者。

“同步”在通信和多进程或多进程中有不一样的意义。

通信同步和多进程或多线程间的同步的关系:

  • 通信同步是指通信双方在发送和接收数据时需要协调它们的行为,比如等待对方的响应或信号,或者按照一定的顺序或时间间隔进行通信。

  • 进程或线程间的同步是指两个或多个进程或线程基于某个条件来协调它们的活动,比如一个进程或线程的执行依赖于另一个进程或线程的消息或信号,或者多个进程或线程需要同时开始或结束某个任务。

通信同步和进程或线程间的同步的区别:

  • 通信同步是一种通信方式;而进程或线程间的同步是一种协作方式,例如通过何种手段,使得进程或线程按照某种规则安全地访问临界资源,从而有效避免饥饿问题。
  • 通信同步的目的是实现数据的传输和交换,相反的概念是异步;而进程或线程间的同步是为了实现任务的分工和协作,相反的概念是互斥。

5. 测试

下面会用几个简单的例子,加深对阻塞 I/O 和非阻塞 I/O 的理解,关于多路复用 I/O,将会在下一节中着重学习。

5.1 阻塞 I/O

在 Linux 一切皆文件的意义下,一个文件的 I/O 阻塞与否,也是一种属性,一个文件的 I/O 阻塞属性是由文件描述符中的一个文件状态标志来表示的。文件描述符是一个整数,用来标识一个打开的文件。文件状态标志是一个位图,用来记录文件的一些属性,比如读写模式、是否追加、是否同步等。其中,O_NONBLOCK标志位用来表示文件是否为非阻塞模式。如果该位为 1,表示文件为非阻塞模式,否则为阻塞模式。

下面以一个简单的例子作为引入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <unistd.h>
using namespace std;

int main()
{
    char buffer[1024];
    while (true)
    {
        ssize_t s = read(0, buffer, sizeof (buffer) - 1);
        if (s > 0)
        {
            buffer[s] = '\0';
            cout << "echo>>> " << buffer << endl;
        }
        else
        {
            cerr << "read error" << endl;
        }
    }
    return 0;
}

在这段代码中,用字符数组 buffer 来存储从标准输入(键盘)读取的数据,然后在死循环中调用 read(),成功则回显,失败则打印错误信息。

测试: 屏幕录制2023-09-29 23.09.19

当光标在闪烁时,说明用户设定的缓冲区 buffer 中没有数据就绪,那么 read 会一直等待,使得这个进程处于阻塞状态。

5.2 非阻塞

在上面代码的基础上,如果要以非阻塞的方式打开某个文件或套接字,就需要使用 fcntl (file control)系统调用:

1
2
3
4
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */);

其中:

  • fd:要操作的文件描述符;
  • cmd:要执行的命令;
  • …:可选参数,因命令 cmd 而不同。

常用命令 cmd 的取值:

  • 复制一个现有的描述符(cmd=F_DUPFD)。
  • 获得 / 设置文件描述符标记(cmd=F_GETFD )。
  • 获得 / 设置文件状态标记(cmd=F_GETFL)。
  • 获得 / 设置异步 I/O 所有权(cmd=F_GETOWN)。
  • 获得 / 设置记录锁(cmd=F_GETLK, F_SETLK)。

另外,fcntl 系统调用除了用于修改已经打开的文件描述符的属性的函数,它还可以实现多种功能,例如:复制一个文件描述符,类似于 dup 或 dup2 函数。

返回值:

  • 成功:根据不同的命令有不同的含义。一般返回 0 或正数。
  • 失败:返回-1 并设置错误码 errno。

下面在一个函数 SetNonBlock() 中设置非阻塞选项:

  1. 传入文件描述符
  2. 使用 fcntl 函数以命令 F_GETFL 获取获取当前文件描述符 fd 对应的文件读写标志位(返回值是一个位图,以不同权位的二进制位标识不同属性的状态);
  3. 使用 fcntl 函数以命令 F_SETFL 设置非阻塞选项。

我们知道 Linux 内核中为每个进程都默认打开了三个文件描述符,0 便是标准输入,只要进程设置一次,后续的 I/O 操作就都是非阻塞式的了。

值得注意的是,当 read 函数以非阻塞方式读取标准输入的数据时,如果数据没有就绪(也就是没有键入)或者说缓冲区空,read 函数会立即返回-1,错误码 errno 被设置为EAGAINEWOULDBLOCK(这两个错误码含义是相同的,因平台而异)。另外,当错误码被设置为 EINTR时,说明 read 函数在读取数据时被信号中断。

所以还要为 read 函数的返回值进一步做差错处理,出现上述错误码则说明本次调用的 read 函数没有成功地读取缓冲区中的数据,所以应该等待下一次调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>
using namespace std;

bool SetNonBlock(int fd)
{
    // 在底层获取当前文件描述符 fd 对应的文件读写标志位
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) 
    {
        cerr << "fcntl error" << endl;
        return false;
    }
    // 设置非阻塞选项
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
    return true;
}

int main()
{
    SetNonBlock(0);
    char buffer[1024];
    while (true)
    {
        sleep(1);
        errno = 0;
        ssize_t s = read(0, buffer, sizeof (buffer) - 1);
        if (s > 0)
        {
            buffer[s] = '\0';
            cout << "echo>>> " << buffer << endl;
        }
        else
        {
            if (errno == EWOULDBLOCK || errno == EAGAIN)
            {
                cout << "当前 0 号文件描述符对应的数据没有就绪,请稍后重试 " << "错误:" << strerror(errno) << endl;
                continue;
            }
            else if (errno == EINTR)
            {
                cerr << "当前 I/O 可能被中断,请稍后重试 " << "错误:" << strerror(errno) << endl;
                continue;
            }
            else 
            {
                // 差错处理
            }
        }
    }
    return 0;
}

测试: 屏幕录制2023-09-30 00.07.00

其中,为了方便观察,每次循环一开始都 sleep 1 秒,以上代码就是进行非阻塞 I/O 的基本处理方式,这样只要有数据就读,没数据就绪就会进入后面两个分支,在这里面可以放一些想让它执行的任务。