高级IO的相关知识点

目录

前言

五种IO模型

阻塞IO

非阻塞IO

信号驱动IO

多路转接IO

异步IO


前言

高级IO的相关知识点_第1张图片

学完了网络的所有协议栈后,我们能发现网络通信就是IO的过程,比如说当我们的主机下载位于公网上的某个服务器上的资源时,我们主机的网卡就会从网络中获取数据,然后网卡把数据交给内存(本质是交给OS,但OS是位于内存中的,所以这里也可以说是交给内存),内存又把数据交给磁盘,可以看到这个过程就是不同的外设在不断地相互IO。


在网络通信IO时,当接收端内核中的接收缓冲区没有数据时,调用recv或者read函数会导致进程在该函数处陷入阻塞,直到接收端的网卡把网络中的数据接收到并将其放到接收缓冲区、让接收缓冲区不为空时才会恢复运行继续recv/read拷贝数据;当发送端内核中的发送缓冲区为满时,调用send或者write函数会导致进程在该函数处陷入阻塞,直到发送端的网卡把发送缓冲区中的数据发到网络中、让发送缓冲区不为满时才会恢复运行继续send/write拷贝数据。所以从这里我们可以发现一个结论:网通通信IO = 等待 + 拷贝数据。

在单机IO时,比如数据是在磁盘和内存(或者说内存中的进程)之间进行IO,当内核缓冲区中没有数据时,调用recv或者read函数会导致进程在该函数处陷入阻塞,直到内核缓冲区把磁盘上的数据拷贝过来、让内核缓冲区不为空时才会恢复运行继续recv/read拷贝数据;当内核缓冲区为满时,调用send或者write函数会导致进程在该函数处陷入阻塞,直到内核缓冲区把数据发给磁盘,让内核缓冲区不为满时才会恢复运行继续send/write拷贝数据。所以从这里我们也可以发现一个结论:单机IO = 等待 + 数据拷贝。(在单机IO中说的内核缓冲区就是当前进程打开的某个文件的文件描述符对应的struct file结构体中的文件缓冲区,比如write向磁盘文件写入时,内核缓冲区就是表示磁盘文件的struct file结构体中的文件缓冲区,向显示器文件写入时,内核缓冲区就是表示显示器文件的struct file结构体中的文件缓冲区)

综合上面两段我们就能得出两个结论:

第一个结论:IO = 等待 + 拷贝数据。

在说第二个结论前,先给出一个判定IO效率高低的标准:在单位时间内拷贝(即从用户层缓冲区拷贝数据到内核缓冲区,或者是从内核缓冲区拷贝数据到用户层缓冲区)的数据量越大,则IO效率越高;在单位时间内拷贝的数据量越小,则IO效率越低。

第二个结论:实际上不管是网络通信的IO效率,还是单机IO的效率,都是比较低的,这是因为既然IO=等待+数据拷贝,而不管是网络通信IO还是单机IO,所使用的用于拷贝数据的函数都是send、read等同一套接口,这些接口拷贝数据(即从用户层缓冲区拷贝数据到内核缓冲区,或者是从内核缓冲区拷贝数据到用户层缓冲区)的速度是很快的,所以不管是网络通信的IO效率,还是单机IO的效率都比较低就是因为在IO的所花的总时间中进行等待的时间占比太大,真正用于拷贝数据的时间占比太小,最后导致拷贝的数据量较小(数据量 = 拷贝速度 * 拷贝时间,send或者write拷贝数据的速度是不变的,在速度不变的情况下,当然时间越长最后拷贝的数据总量越大),所以想要提高网络通信IO的效率和单机IO的效率,就需要在IO的所花的总时间中让进行等待的时间占比变小,让真正用于拷贝数据的时间占比变大,这样一来最后拷贝的数据量就会变大(数据量 = 拷贝速度 * 拷贝时间,send或者write拷贝数据的速度是不变的,在速度不变的情况下,当然时间越长最后拷贝的数据总量越大),根据上面的判定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的相关知识点_第2张图片

如上图所示,阻塞IO就是在内核将数据准备好之前,系统调用会一直等待。

阻塞IO是最常见的IO模型,对套接字文件和普通文件的文件描述符进行write或者read时默认都是以阻塞方式。

  • 比如当调用recvfrom函数从某个套接字上读取数据时,可能底层数据还没有准备好,此时就需要等待数据就绪,当数据就绪后再将数据从内核缓冲区拷贝到用户空间,最后recvfrom函数才会返回。(在单机IO中说的内核缓冲区就是当前进程打开的某个文件的文件描述符对应的struct file结构体中的文件缓冲区,比如write向磁盘文件写入时,内核缓冲区就是表示磁盘文件的struct file结构体中的文件缓冲区,写入后,write函数就调用完毕了,至于文件缓冲区中的数据如何被交给磁盘就全看OS的策略了;再比如write向显示器文件写入时,内核缓冲区就是表示显示器文件的struct file结构体中的文件缓冲区,写入后,write函数就调用完毕了,至于文件缓冲区中的数据如何被交给显示器就全看OS的策略了
  • 在recvfrom函数等待数据就绪期间,在用户看来该进程或线程就阻塞住了,本质就是操作系统将该进程或线程的PCB的状态设置为了某种非R状态,然后将该PCB放入对应的等待队列当中,当数据就绪后操作系统再将其从等待队列当中唤醒,然后该进程或线程再将数据从内核缓冲区拷贝到用户空间。

以阻塞方式进行IO操作的进程或线程,在“等”和“拷贝”期间都不会返回,在用户看来就像是阻塞住了,因此我们称之为阻塞IO。

非阻塞IO

高级IO的相关知识点_第3张图片

非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。非阻塞IO需要程序员以while循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。

  • 比如当调用recvfrom函数以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么recvfrom函数会立马错误返回(即返回一个错误码),而不会让该进程或线程进行阻塞等待。
  • 因为没有读取到数据,因此该进程或线程后续还需要继续调用recvfrom函数(这是需要通过程序员编码才能做到的,比如设置一个while循环,发现recvfrom的返回值<0,则继续循环钓鱼recvfrom),检测底层数据是否就绪,如果没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
  • 每次调用recvfrom函数读取数据时,就算底层数据没有就绪,recvfrom函数也会立马返回,在用户看来该进程或线程就没有被阻塞住,因此我们称之为非阻塞IO。

阻塞IO和非阻塞IO的区别在于,阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的(即通过用户编码设置while循环轮询调用recvfrom)


问题:说一下,对于write和read函数,这两个函数的参数不具备将函数本身设置成阻塞或非阻塞模式,那进程如何以非阻塞的模式调用write或者read函数呢?

答案:write和read函数本质是对文件描述符fd对应struct file中的内核缓冲区进行读写,所以如果我们在调用write和read函数前就把文件描述符fd的属性(更准确地说是把fd对应的struct file的属性)设置成非阻塞模式,那么进程在调用write和read函数时也就会以非阻塞模式调用了。那如何设置文件描述符fd的属性(更准确地说是fd对应的struct file的属性)呢?有两种方式,如下:

第一种:如下图所示,对于单机IO来说,调用open函数打开目标文件时在mode参数处增加上O_NONBLOCK选项即可;对于网络通信IO来说,调用socket函数创建套接字文件时在第二个参数处增加上SOCK_NONBLOCK选项即可。

  • 从这里我们还能反推出为什么平时调用read/write函数时,当内核缓冲区中的数据不就序或者空间不就绪时会导致进程在read/write函数处陷入阻塞,这就是因为在调用open或者socket函数以打开目标文件、创建struct file并返回fd时,没有给open或者socket函数的参数添加上非阻塞的选项以把fd的属性(更准确地说是fd对应的struct file的属性)设置成非阻塞模式,于是其属性默认就是阻塞模式了。可以发现,就像文件的大小是文件的属性一样,阻塞或者非阻塞实际也是一个文件(或者说struct file)的属性,当OS对一个文件进行读写时,先查看该文件的属性,如果发现是阻塞模式,则在读写条件不满足时就阻塞,如果是非阻塞模式,则在条件不满足时不阻塞你进程即可。
  • 说一下,因为socket函数和open函数在把文件描述符设置成非阻塞模式时,所用的选项的名字具有略微的区别,并不统一,所以为了方便,在实际开发中一般会选择使用接下来所要讲解的第二种方法把文件描述符设置成非阻塞模式。

高级IO的相关知识点_第4张图片

问题:为什么调用cin或者scanf函数时,当键盘没有输入数据时,当前进程就会陷入阻塞呢?

答案:走到这里我们就能更深刻地理解,就是因为没有把cin或者scanf等stdin所表示的0号文件描述符设置成非阻塞模式,也没有在外设键盘中输入数据,导致外设没有把数据交给OS为键盘文件创建的struct file中的内核缓冲区,导致内核缓冲区中没有数据,导致cin或者scanf函数中的read函数无法将内核缓冲区的数据交给C库缓冲区,而是让当前进程阻塞在cin或者scanf函数中的read函数处(cin或者scanf本质是把位于应用层的C库转缓冲区中的数据移到位于应用层的用户指定的缓冲区里;cin或者scanf底层调用的是read函数,read函数就负责把内核缓冲区中的数据拷贝到应用层的C库缓冲区里,现在内核缓冲区没有数据,所以read函数就阻塞了、进而导致cin/scanf阻塞;C库缓冲区在哪呢?cin或者scanf默认是对FILE*类型的文件指针变量stdin进行操作,其中C库缓冲区就在stdin指向的FILE变量中)

第二种:通过调用fcntl函数可以把文件描述符的属性(更准确地说是把文件描述符对应的struct file的属性)设置成非阻塞模式,在下文中会通过编码演示具体流程。

高级IO的相关知识点_第5张图片

如下图代码所示,其中的逻辑是读取0号文件描述符,也就是读取表示外设键盘的struct file中的内核缓冲区,可以看到当我们没有敲键盘往该内核缓冲区输入数据时,test进程就会一直阻塞在下图的红框处(更准确地说是阻塞在read函数处)

高级IO的相关知识点_第6张图片

(结合下图思考)但当我们调用了fcntl函数将0号文件描述符(表示外设键盘)对应的struct file的状态设置成非阻塞状态后,即使没有敲键盘往表示外设键盘的struct file中的内核缓冲区输入数据,此时调用read函数发现内核缓冲区中为空时也不会导致当前进程陷入阻塞,而是直接返回并继续向下执行代码。(说一下,有细心的小伙伴可能会发现运行结果图中,即使往键盘中输入asd使得read函数调用成功了,但调用成功后所打印的退出码errno的值依然是11,这是为什么呢?很简单,当read函数成功读取到数据调用成功时,退出码不会被设置,只有read函数发生了错误和没有读取到数据时,退出码才会被设置,如果想在read函数调用成功时看到errno的值为0,可以在while循环中的第一行执行代码errno = 0;)

高级IO的相关知识点_第7张图片

完整的编码层面上的非阻塞IO模型

如下图1所示,该图中的代码就是一个完整的非阻塞IO模型,这种类型的代码以后会经常在网络代码中出现。说明一下代码中的细节,如下:

1、(结合下图2思考,下图2是read函数因为各自原因调用失败时,会给退出码errno设置的值)当以非阻塞模式调用read函数时,当目标文件描述符对应的struct file中的内核缓冲区中没有数据时,read函数就会立刻返回-1,并将错误码设置成EWOULDBLOCK(或者说EAGAIN,这两者是等价的),即整形值11,此时根据设计非阻塞IO模型的初衷,就需要在该if分支中趁内核缓冲区的数据不就绪时处理一些其他的业务。

2、(结合下图2思考,下图2是read函数因为各自原因调用失败时,会给退出码errno设置的值。说一下,如果errno的值是11、即是EAGAIN或者也称EWOULDBLOCK,则说明read是以非阻塞模式调用并且当前接收缓冲区中为空,这时read函数就会返回-1并且将错误码errno设置成11)如果进程在执行一个系统调用的过程中收到一个信号,该系统调用可能会被中断(如果当初设置了对应信号的信号处理函数,则就会被中断),如果真的被中断了,此时系统调用就会直接结束(不要认为系统调用完成了任务,此时可以认为系统调用什么都没有做就立刻结束了)并将错误码errno设置成EINTR表示示系统调用的中断,然后立刻执行信号处理函数,当信号处理函数结束后,会从之前中断的系统调用的下一行开始执行代码,注意此时是需要重新执行之前被中断的系统调用的,否则就坑爹了,那如何判断系统调用是否被中断了呢?退出码errno的值表示当前进程最近一次调用的函数的执行结果,所以可以在系统调用的下一行设置一个if判断,如果发现errno的值等于EINTR,则说明之前的系统调用被中断了,则该系统调用就需要重新被执行,这也是为什么在下图1的代码中,当发现errno的值等于EINTR后,会直接continue重新进入循环以重新调用read函数。(说一下,检查退出码errno的值是否为EINTR还有一个好处,举个例子,当read函数以阻塞模式调用并且因为内核缓冲区没有数据导致当前进程阻塞在了read函数处时,如果此时因为来了一个信号导致当前进程执行完信号处理函数后不再继续阻塞在read函数处,而是继续向下处理业务,那就会出现问题,而检查退出码errno的值是否为EINTR就能避免这样的情况出现)

  • 图1如下。高级IO的相关知识点_第8张图片
  • 图2如下。高级IO的相关知识点_第9张图片 

上面的图1的代码如下。

#include
using namespace std;
#include
#include
#include

bool SetNonBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);//获取fd对应的文件struct file的目前的状态
    if(fl < 0 )
        return false;
    else
    {
        //在fd对应的文件struct file的现有的状态之上再加上O_NONBLOCK非阻塞状态,注意一定要再原有的状态上再加入O_NONBLOCK非阻塞状态,以免破坏原有的状态
        fcntl(fd, F_SETFL, fl | O_NONBLOCK);
        return true;
    }
}

int main()
{
    SetNonBlock(0);//只要设置一次,后序0号文件描述符对应的struct file的状态就都是非阻塞状态了
    char buffer1[1024];
    while(1)
    {
        ssize_t size = read(0, buffer1, sizeof(buffer1)-1);
        if (size > 0)
        {
            //全局变量errno(即退出码)的值等于最近一次所调用的函数的返回值,也就是read函数的返回值,为什么要把退出码errno的值和errno所表示的退出信息打印出来
            //呢?因为当read函数的返回值小于0时,可能是read函数发生了错误,也可能是外设键盘对应的struct file中的内核缓冲区中没有数据导致以非阻塞模式调用的read
            //函数退出,所以把退出码errno的值和errno所表示的退出信息打印出来就是为了区分到底是哪种情况。

            //因为在命令行中输入asd时要以回车结束,而read会把回车\n也读入buffer1中,为了不让\n进入buffer1中,所以这里size减了1。至于为什么要这么做,只是
            //单纯的不希望"echo#:"和"errno:"的值分成两行打印。
            buffer1[size-1] = 0;
            cout<<"echo#:"<

信号驱动IO

高级IO的相关知识点_第10张图片

信号驱动IO就是当内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作,比如说当底层数据就绪的时候会向当前进程或线程递交SIGIO信号,因此可以通过signal或sigaction函数将SIGIO的信号处理函数自定义为需要进行的IO操作,当底层数据就绪时就会自动执行对应的IO操作。

  • 比如我们需要调用recvfrom函数从某个套接字上读取数据,那么就可以将recvfrom函数以及相关逻辑在SIGIO的信号处理函数中调用。当底层数据就绪时,操作系统就会递交SIGIO信号给当前进程,此时当前进程就会自动执行我们定义的信号处理函数以将数据从内核缓冲区拷贝到用户空间。(在单机IO中说的内核缓冲区就是当前进程打开的某个文件的文件描述符对应的struct file结构体中的文件缓冲区,比如write向磁盘文件写入时,内核缓冲区就是表示磁盘文件的struct file结构体中的文件缓冲区,向显示器文件写入时,内核缓冲区就是表示显示器文件的struct file结构体中的文件缓冲区)
  • 信号的产生是异步的,但信号驱动IO是同步IO的一种。我们说信号的产生异步的,这是因为信号在任何时刻都可能产生;但信号驱动IO是同步IO的一种,这是因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,即当前进程或线程虽然不需要参与IO中的“等”,但仍需要参与“拷贝数据”,所以信号驱动IO依然是同步IO的一种。

多路转接IO

高级IO的相关知识点_第11张图片

多路转接IO也叫做多路复用IO,能够同时等待多个文件描述符的就绪状态。

多路转接IO的思想:

  • 因为IO过程分为“等”和“拷贝”两个步骤,因此我们使用的recvfrom等接口的底层实际上都做了两件事,第一件事就是当数据不就绪时需要等,第二件事就是当数据就绪后需要进行拷贝。
  • 虽然recvfrom或sendto等接口也有“等”的能力,但这些接口一次只能“等”一个文件描述符上的数据或空间就绪,这样IO效率太低了。
  • 因此系统为我们提供了三组接口,分别叫做select、poll和epoll,这些接口只负责“等”,完全不参与“拷贝数据”,我们可以将所有“等”的工作都交给这些多路转接接口,这些多路转接接口是可以一次“等”多个文件描述符的,所以能够将“等”的时间进行重叠,当有一个或者同时有多个文件描述符中有数据就绪时,这些接口就会通过某种手段把对应的这一个或多个文件描述符告诉当前进程,以让当前进程再调用recvfrom等函数将数据从内核缓冲区拷贝到应用层缓冲区,注意因为底层数据就绪后这3个多路转接接口才会通知当前进程,所以此时调用recvfrom等函数就能够直接进行拷贝,而不需要进行“等”操作了。

走到这里我们理解完多路转接IO的思想后,我们再回看在前言部分中提出的问题【那么如何在IO的所花的总时间中让真正用于拷贝数据的时间的占比变大呢?】 

答案:因为多路转接IO模型支持一次等待多个文件描述符就绪,并且等待的时间还是重叠的,那么在单位时间内多路转接IO模型相比于其他IO模型大概率是有更多时间是在拷贝数据的,比如说对于其他模型而言,因为这些模型只能等待一个文件描述符就绪,所以当文件描述符A不就序时,它们就无法拷贝数据;但对于多路转接IO模型来说,因为该模型能等待多个文件描述符就绪,所以当文件描述符A不就序时,可能文件描述符B或者C、D就绪了,此时该模型就依然可以继续拷贝数据,所以在单位时间内多路转接IO模型相比于其他IO模型大概率是有更多时间是在拷贝数据的,所以也就在IO的所花的总时间中让真正用于拷贝数据的时间的占比变大了,在单位时间中(或者说在IO的所花的总时间中)就能拷贝更多的数据以提高IO效率了。

关于多路转接IO更详细的内容,请参考<<多路转接IO——select服务器、poll服务器、epoll服务器>>一文。​​​​​​​

异步IO

高级IO的相关知识点_第12张图片

异步IO就是由内核在数据拷贝完成时,通知应用程序。

详细说明如下:

  • 进行异步IO需要当前进程调用一些异步IO的接口,在调用该接口时需要用户给该接口传递一个表示用户层缓冲区的参数和一个表示信号的参数,异步IO接口调用后会立马返回,这是因为异步IO不需要当前进程进行“等”和“拷贝”的操作,这两个动作都由操作系统来完成,当前进程所要做的只是调用异步IO的接口以发起异步IO。
  • 当IO完成后,即内核缓冲区中的数据被OS拷贝到【之前用户给异步IO接口传递的用户层缓冲区】后,操作系统就会给当前进程发【之前用户给异步IO接口传递的信号】以通知当前进程,然后当前进程直接从用户层缓冲区中拿数据即可(更准确地说是当前进程直接调用提前设置好的信号处理函数以处理用户层缓冲区中的数据),因此进行异步IO的进程或线程并不参与IO的所有步骤,即既不参与“等待”,也不参与“拷贝数据”。
  • 可以发现,异步IO对比信号驱动IO的区别在于,信号驱动IO虽然不需要当前进程等待文件描述符的数据就绪,但好歹还需要当前进程调用recvfrom等IO函数把内核缓冲区中的数据拷贝到应用层缓冲区;而异步IO就更过分了,异步IO可以让当前进程既不用等待文件描述符的数据就绪,也不用调用recvfrom等IO函数把内核缓冲区中的数据拷贝到应用层缓冲区,而是把所有的事情都交给了OS。

你可能感兴趣的:(Linux,服务器)