一般我们在写一些简单的小项目的时候,免不了会用到IO接口,比如C语言中的scanf/printf又或者是
C++中的cout/cin,或者是在Linux操作系统中的文件IO接口read/write。这些接口默认都是阻塞的,
这又引出了阻塞/非阻塞IO的概念,由此可见IO是我们在进行代码编写时不可或缺的一个模块,其实
关于IO不仅仅有阻塞/非阻塞的IO方式,还有着其它类型的IO模式,那么从这一篇文章开始,我将会
介绍不同模式的IO,以便让我们在不同项目环境下编写出更好的代码。
五种IO模型
接下来我们先简单的认识一下五种IO模型。
a. 阻塞IO
阻塞IO是我们最经常使用的一种IO方式,比如C语言中的scanf、C++中的cin、Linux中的read、recv。这些都是阻塞式的读入输入缓冲区中的内容到进程中,而它们阻塞的原因也很简单那就是此时这些接口要读取的输入缓冲区没有数据而导致该执行流获取不到资源而被阻塞,同时一些输出接口也会遇到这种问题,但是输出接口阻塞的原因是因为输出接口要输出的目标缓冲区满了,此时不能再向该缓冲区中输入数据,执行流也会被阻塞。但是输出缓冲区满了的情况是很少的,主要还是输入缓冲区中没有数据导致执行流 以阻塞式读取该缓冲区数据时,执行流被阻塞。
b. 非阻塞IO
关于非阻塞IO,有些使用C语言或者C++再Windows上写过一些单线程的小游戏,比如贪吃蛇、俄罗斯方块等这些都是需要非阻塞IO的存在,因为当游戏开始的时候就算用户没有输入,游戏也会正常运行,也就是让程序正常执行,这样的场景下,非阻塞式的IO就是必要的。非阻塞IO不像阻塞IO一样在使用IO接口时,如果读写条件不就绪(输入缓冲区为空或者输出缓冲区为满)执行流就会被阻塞,此时非阻塞IO接口会直接返回,并且继续向后执行代码,这样当我们实现一个小游戏的时候就算玩家没有使用键盘或者鼠标进行任何操作,游戏也会正常执行之后的代码,使游戏正常运行。
关于C语言中在Windows下有一个非阻塞IO的接口:
头文件是 conio.h,接口是_getch,是一个非阻塞的字符输入接口。
而在Linux中,设置非阻塞的方式有多种,我们也主要认识Linux中的非阻塞设置方式。
Linux中的非阻塞
使用过Linux中的关于文件描述符的IO接口都知道,这些IO接口一般在传参的时候,会让我们提供一个标志位:
而这个标志位的本质其实就是一个位图,众多标志位中,有一个功能就是设置非阻塞:
这两个标记位都可以实现非阻塞的功能,但是使用的场景有所不同,MSG_DONTWAIT是 使用在一些IO接口上,如:recv,recvfrom等。O_NONBLOCK则是在文件打开或者创建套接字文件时使用的标志位,非阻塞作用在文件描述符上。
在有了上面的认识之后我们再来认识一个将一个打开的文件描述符设置为非阻塞的接口。
fcntl
这个系统调用可以对一个文件描述符进行某些操作,其中有一点就是将一个文件描述符设置为非阻塞,使用的方式也很简单:
首先我们先来认识一下使用阻塞IO时的情况:
可以看到 ,当我们不使用键盘进行输入操作时,进程就会被阻塞。
现在我们来实现非阻塞IO:
这就是将一个文件描述符设置为非阻塞的流程:首先我们先使用F_GETFL获取到文件描述符上记录该文件描述符特点的标志位,之后我们将这个标志位按位或上O_NONBLOCK,再将它设置进文件描述符的标志位中,这样该文件描述符不论被哪个IO接口使用都是非阻塞状态了,接下来我们在来体验一下对非阻塞的文件描述符进行读取数据是什么样的感觉:
关于非阻塞我们知道当读取数据时如果有数据就会读取数据,如果没有数据也会立即返回,所以我们的代码可以是这样,同时我们也来观察输入缓冲区当没有数据时read的返回值:
这时候我们知道,当以非阻塞的方式使用read对文件描述符进行读取数据输入缓冲区没有数据,那么read的返回值是-1,这里可能有人就会有疑问了read的返回值-1不是代表读取出错了吗,怎么又会是没有数据呢?这个问题我们稍后再谈,我们先来规范一下上面关于读文件时的过程,在上面的代码中我们只分析了输入缓冲区有数据,然后就直接else了,这样的处理方式是不对的,加入了解管道或者套接字通信的会知道当不需要再读(对端关闭)的时候,使用read继续对该文件描述符读取,此时read的返回值是0,所以我们的代码应该是下面这样:
在运行起程序的时候我们,可以使用ctrl + D(表示向进程发送结束输入的信号)的方式来演示出对端关闭的场景:
read的返回值
在上面使用非阻塞方式读取数据的时候我们发现读取数据出现错误与读取数据时输入缓冲区没有数据的返回值都是-1,难道两种是一种情况吗?显然不是的,所以我们再次阅读read的返回值我们就会发现这两者的不同:
在文档的介绍中,虽然非阻塞读取数据出入缓冲区没有数据的行为被认定为是read读取出现错误,但是同时错误码也会被设置,错误码标示着read出错的错误原因,而我们需要关注其中几个错误码:
EAGAIN:用于指示操作目前无法完成,但可以在将来的某个时刻再次尝试。IO时就是在上述非阻塞读取输入缓冲区
没有数据时错误码就会被设置为EAGAIN。
EWOULDBLOCK:跟EAGAIN其实是一个意思,查看代码我们会发向它俩是一个东西。
EINTR:这个错误码表示,当执行流正在读取数据时突然被信号中断了,那么错误码就会被设置为EINTR。
在了解过上面相关的错误码之后我们就知道了,其实read返回值为-1之后我们可以进一步通过错误码来判断返回值为-1的原因,并根据这个原因做出相应的处理方式,那么我们最终的结果代码 就出来了:
这也就是Linux中非阻塞的读取文件的方式。
c. 信号驱动IO
对Linux中的信号有过了解的话就会知道,当操作系统向进程发送信号的时候,该进程就会直该信号对应的信号处理函数,并且我们也可以使用signal函数来让进程在处理某个信号时,使用我们的自定义方法。
通过这个机制我们在自定义信号处理函数中进行IO,这样当IO条件就绪时,就让操作系统向该进程发送信号,从而让该进程进行IO,这种就是信号驱动式的IO。
d. 多路转接IO
这里我们要对IO有统一的认识在认识:我们发现在要进行IO时,如果IO条件不就绪时,我们需要等这个条件就绪,就绪之后我们就可以开始进行IO了。而且我们进行输入输出的过程,本质上是用户层与内核层之间的数据互相拷贝的过程。所以IO的本质 其实就是等 + 拷贝。
在之前认识的三种IO模型中,阻塞式IO不必多说,如果IO条件不就绪,那进程就会一直阻塞在一个文件描述符上,也就是进程会一直等直到该文件描述上的IO条件就绪。
非阻塞式IO虽然在IO条件不就绪时会立即返回,这样的设计能够让进程将这段“等”IO条件就绪的时间利用起来去做其他事情,但是实际上并没有减少“等”IO条件就绪的时间,同样的信号驱动式IO同样也没有减少等的时间。
由于IO的本质是等 + 拷贝。拷贝的过程我们是必须做的这一点我们无法优化,但是我们可不可以对这个等的时间进行优化呢,也就是减少IO等的时间。这个时候多路转接IO就出现了。
多路转接的大致原理就是从 原来由IO接口判断某个文件描述符的IO条件是否就绪,并且如果就绪IO接口就会进行IO 这样的过程转化为由多路转接接口来判断某个文件描述符的IO条件是否就绪,然后由进程根据这个接口返回的结果来决定后续是否进行IO。这样说完之后有人就会说多路转接IO也没有减少等的时间啊,无非就是把IO 接口等换做多路转接接口等了,这样的理解是理所当然的,但是厉害之处就在于多路转接接口 可以一次性判断多个文件描述符上的IO时间是否就绪。
这样说来多路转接接口的优化等的方式并不是从单个文件描述符来说的,而是在面对大量的文件描述符IO的时候,多路转接接口将这些文件描述符等的时间进行了重叠,这样的话也算是优化了等的时间。
此时 有人就有疑问了,同时检测多个文件描述符IO条件是否就绪,如果就绪的我们就发,不就绪就不发,这样的功能我们也可以自己实现啊比如使用非阻塞式IO + 一次遍历所有的文件描述符进行IO。
这样的想法虽然确实可以实现等待时间重叠的效果,但是仍然会有很大的缺陷,首先从效率上来说由于我们处于用户层,而IO条件就绪的本质 是在内核层,这样的话我们对于IO条件是否就绪的判断一定不如内核去判断某个文件描述符是否可以IO的效率更高(最明显的就是用户态和内核态之间需要进行切换)。
其次 从编程角度来说 ,倘若使用非阻塞IO进行遍历文件描述符进行IO时,如果某个文件描述符不具备IO条件,那我们可能会对特定的文件描述符进行特定的操作,这增加了代码的复杂性 。
最后关于多路转接的底层,其实也并不一定是对遍历所有需要 关心IO条件是否就绪的文件描述符进行遍历 操作式的判断,多路转接会进行更高效率的判断某个文件描述符的IO条件是否就绪,这也是上述方式不可行的原因。
而在之后的过程中,我也会介绍多路转接式的IO如何去进行编写。
e. 异步IO
在上面认识的四种IO模型中,它们的IO模式又可以被归结为一类,那就是当我有了IO需求的时候,我必须得知这个IO需求给我返回的结果(阻塞IO体现为读好数据或者进程被阻塞,非阻塞体现在 读好数据或者返回结果,信号驱动体现在去读数据 ,多路转接体现在关心的文件描述的IO事件有没有准备好),而还有一种IO方式,那就是在执行流提出IO需求之后 ,不需要立即获取这个IO需求返回的结果,执行流会继续干其他事情,而这个IO需求或许会交给其他执行流让其他执行流来处理这个IO需求,而当这个IO需求有结果时,会通过回调或者其他方式来通知发起IO需求的执行流直接使用这个结果即可 。
在这种IO模式下,我们发现 执行流只是发起了一个IO请求,但是具体的IO操作(等 + 拷贝)它一个都没参与,而之前的四种IO方式,它们都参与或者部分参与了IO操作,所以判断一个执行流使用的IO方式可以看它在提出IO需求后有没有参与IO操作,如果参与了那就是同步IO,如果没有那就是异步IO。
IO和系统中的同步与异步
在上面提出异步IO时,我们也把介绍的前四个IO方式归类为同步IO。有人就会自然而然地将执行流的同步和异步类比到IO中,首先说明:直接将两者混为一谈是不对的(场景不同),但是两者在思想上具有相似性。
对于同步而言不论是IO还是执行流,它们都体现了很强的顺序性,那就是我必须干完这件事之后再干其他事(比如IO需求提出后必须得到IO结果再干其他事,一个执行流要继续向后执行也必须等待某一个执行流执行到一定程度在开始执行),而异步也是一样,在提出IO需求之后不需要得到IO结果可以继续干其他事,执行流的执行也不需要其他执行流执行到某一程度再开始执行。
这就是我对五种IO模型的大致理解,如果有错误的地方,希望指正。