阅读前导:
“高级 I/O”处于知识树中网络和操作系统的最后,因此本文默认读者有计算机网络和操作系统的基础。
下面以“流”(stream)和冯诺依曼体系架构的视角来简单回顾一下什么是 I/O:
I/O可以理解为数据在计算机内部和外部之间的流动。
在冯诺依曼体系架构中,程序和数据都是以二进制编码的形式存储在存储器中,CPU可以直接访问存储器中的任何位置,也可以通过输入设备和输出设备与外部世界进行数据交换。因此,I/O就是数据在存储器和输入输出设备之间的传输,或者说是数据在CPU和外部世界(即外设)之间的交换。
I/O的速度和效率受到多种因素的影响,例如存储器的容量和速度、输入输出设备的性能和类型、总线或接口的带宽和协议、CPU的运算能力和指令集、操作系统的调度和管理、程序的设计和优化等。
I/O = 等待 + 数据拷贝
例子:假设要对磁盘中的文件修改,包括两个步骤:
什么是高效的 I/O ?
其中,如果缓冲区中没有数据,CPU 会阻塞地等待,直到缓冲区中有数据之后才会拷贝数据。如果等待的时间占比过大,就会造成 I/O低效。也就是说,降低单位时间内,等待的比例,就相当于提高 I/O的效率。
上面只是一个单机中的例子,实际上可能很难体会到效率上的差距,因为I/O的距离太短了。那么对于像网络这样的长距离的 I/O,效率就显得十分重要了。
如何理解这个“等”呢?
像我们在使用 C 或 C++的 scanf 或 cin 时,程序运行起来光标会一直闪烁,这就是程序在等待标准输入中的数据,这个数据就是从外设键盘而来。文件和网络相关接口诸如 read()、write()和send()、 recv()也是类似的。事实上也是如此,例如 read()就是将内核缓冲区的数据拷贝到用户缓冲区,write()就是将用户缓冲区的数据拷贝到内核缓冲区。
网络通信的本质可以从不同的角度来理解:
其中进程是最具象的角度,因为数据的交换和共享的主体是进程,它们通过文件描述符来访问网络资源,例如套接字、管道、FIFO等。在 Linux 下,一切皆文件,这意味着所有的设备、资源和对象都可以用统一的方式来操作,即打开、读写、关闭等。进程的 TCB 控制块存储进程的各种信息,例如进程 ID、状态、优先级、寄存器、信号、文件描述符等。每个进程都有一个文件描述符表,用来记录文件描述符和文件之间的映射关系,文件描述符是一个非负整数,是文件描述符表的下标,用来标识进程打开的文件。
下面用打电话的例子作为引入:
什么是阻塞和非阻塞?
什么是同步和异步?
阻塞和非阻塞是一种调用机制,主要指的是调用者(程序)在等待返回结果(或输入)时的状态。阻塞时,在调用结果返回前,当前线程会被挂起,并在得到结果之后返回。非阻塞时,如果不能立刻得到结果,则该调用不会阻塞当前线程,而是直接返回一个错误信息或空值,因此调用者需要多次调用或轮询来检查结果是否就绪。
阻塞和非阻塞的概念通常和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的控制权,而同步和异步的区别在于是否需要主动轮询或被动通知。
阻塞 I/O是一种在输入输出操作期间让进程或线程等待的 IO 模型,进程或线程在调用 I/O 操作时,会一直等待数据就绪,直到数据从内核缓冲区拷贝到进程缓冲区,然后才返回。言外之意,阻塞 I/O 在“等”和“拷贝”阶段都不会返回。
在 Linux 中,有很多系统调用是阻塞 I/O 的,例如 read, write, accept, connect, recv, send 等,即所有的套接字默认都以阻塞方式工作,这是因为:
缺点是效率低,因为在等待数据就绪和拷贝数据的过程中,进程或线程无法做其他事情,浪费时间和资源。所以阻塞 I/O 适合于数据量不大,实时性要求不高的场景。
非阻塞 I/O 是一种在输入输出操作期间让进程或线程不需要等待的 I/O 模型。
它的优点是可以提高程序的并发性和响应性,缺点是需要额外的处理逻辑和轮询机制。轮询机制是指:
阻塞 I/O 和非阻塞 I/O 的区别?
除了上述阻塞 I/O 和非阻塞 I/O 的检测数据就绪方式有区别以外,检测数据就绪的主体也有不同:
阻塞 I/O 当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的
非阻塞 I/O 当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。这也是阻塞 I/O 和非阻塞 I/O 的一个重要的区别。
多路复用 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 的效率,避免不必要的阻塞和轮询。
这就好像上学时老师总会定几个组长,这样每次收作业时老师只需要等这几个组长,但实际上等待不同组的同学上交作业的时间是有重叠的,这样便节省了时间。另外一个例子:百米赛跑都是几个人一起跑,而不是一个一个地测。
信号驱动 I/O 是一种在输入输出操作期间让进程或线程不需要等待,而是通过信号通知的 I/O 模型。信号驱动 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 系统调用。
异步 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 资源,提高系统的吞吐量和效率,缺点是编程复杂度较高,需要处理好异步通知和回调函数。
由于I/O=等待资源就绪+拷贝资源,那么异步 I/O 就是最理想的模式,因为它可以将“等”和“拷贝”的开销都降到最低。阻塞 I/O、非阻塞 I/O 和信号驱动 I/O 本质上不能提高 I/O 的效率,但非阻塞 I/O 和信号驱动 I/O 能提高整体的效率。
同步和异步是两种不同的消息通信机制,它们主要区别在于调用者和被调用者之间的交互方式:
同步通信是指调用者在发出一个调用后,必须等待被调用者返回结果,才能继续执行后续的操作。这种方式的好处是调用者可以马上得到结果,不会错过任何信息,但是也会造成调用者的阻塞和等待,降低效率。
异步通信是指调用者在发出一个调用后,就可以继续执行后续的操作,不需要等待被调用者返回结果。这种方式的好处是调用者可以充分利用时间,提高效率,但是也会导致调用者无法马上得到结果,需要通过其他的方式来获取信息,比如状态、通知或回调函数。
“调用”是指对一个函数或者一个系统服务的请求,也就是让一个已经定义好的代码段执行一定的功能。通信是一种手段,目的是达成进程间的资源交换或共享,数据不一定对通信双方都有用,例如在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 就是后者。
“同步”在通信和多进程或多进程中有不一样的意义。
通信同步和多进程或多线程间的同步的关系:
通信同步是指通信双方在发送和接收数据时需要协调它们的行为,比如等待对方的响应或信号,或者按照一定的顺序或时间间隔进行通信。
进程或线程间的同步是指两个或多个进程或线程基于某个条件来协调它们的活动,比如一个进程或线程的执行依赖于另一个进程或线程的消息或信号,或者多个进程或线程需要同时开始或结束某个任务。
通信同步和进程或线程间的同步的区别:
下面会用几个简单的例子,加深对阻塞 I/O 和非阻塞 I/O 的理解,关于多路复用 I/O,将会在下一节中着重学习。
在 Linux 一切皆文件的意义下,一个文件的 I/O 阻塞与否,也是一种属性,一个文件的 I/O 阻塞属性是由文件描述符中的一个文件状态标志来表示的。文件描述符是一个整数,用来标识一个打开的文件。文件状态标志是一个位图,用来记录文件的一些属性,比如读写模式、是否追加、是否同步等。其中,O_NONBLOCK
标志位用来表示文件是否为非阻塞模式。如果该位为1,表示文件为非阻塞模式,否则为阻塞模式。
下面以一个简单的例子作为引入:
#include
#include
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(),成功则回显,失败则打印错误信息。
测试:
当光标在闪烁时,说明用户设定的缓冲区 buffer 中没有数据就绪,那么read 会一直等待,使得这个进程处于阻塞状态。
在上面代码的基础上,如果要以非阻塞的方式打开某个文件或套接字,就需要使用 fcntl (file control)系统调用:
#include
#include
int fcntl(int fd, int cmd, ... /* arg */);
其中:
常用命令 cmd 的取值:
另外,fcntl系统调用除了用于修改已经打开的文件描述符的属性的函数,它还可以实现多种功能,例如:复制一个文件描述符,类似于dup或dup2函数。
返回值:
下面在一个函数SetNonBlock()中设置非阻塞选项:
我们知道Linux 内核中为每个进程都默认打开了三个文件描述符,0 便是标准输入,只要进程设置一次,后续的 I/O 操作就都是非阻塞式的了。
值得注意的是,当 read 函数以非阻塞方式读取标准输入的数据时,如果数据没有就绪(也就是没有键入)或者说缓冲区空,read 函数会立即返回-1,错误码 errno被设置为EAGAIN
或EWOULDBLOCK
(这两个错误码含义是相同的,因平台而异)。另外,当错误码被设置为 EINTR
时,说明 read 函数在读取数据时被信号中断。
所以还要为 read 函数的返回值进一步做差错处理,出现上述错误码则说明本次调用的 read 函数没有成功地读取缓冲区中的数据,所以应该等待下一次调用。
#include
#include
#include
#include
#include
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;
}
其中,为了方便观察,每次循环一开始都 sleep 1 秒,以上代码就是进行非阻塞 I/O 的基本处理方式,这样只要有数据就读,没数据就绪就会进入后面两个分支,在这里面可以放一些想让它执行的任务。