【Linux练习生】高级IO

IO的基本概念

什么是IO?

I/O(input/output)也就是输入和输出,在著名的冯诺依曼体系结构当中,将数据从输入设备拷贝到内存就叫做输入,将数据从内存拷贝到输出设备就叫做输出。

  • 对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。
  • 对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。

IO是输入input输出output的首字母缩写形式,直观意思是计算机输入输出,它描述的是计算机的数据流动的过程,因此IO第一大特征是有数据的流动;另外,对于一次IO,它究竟是输入还是输出,是针对不同的主体而言的,不同的主体有不同的描述。例如,甲乙两人交谈,甲将大脑中的想法通过声带震动,继而通过声波传入乙的耳朵,乙通过耳膜的震动再由神经将信号解析到大脑,这个数据流动的过程对甲而言是输出,对乙而言是输入。

因此,理解IO一定要弄清楚所要研究的本体。

下面,我们从三个层面来理解IO。

首先,从直观层面去理解IO:

此时,IO是计算机和外设之间的数据流动过程,本体是一个有使用意义的可运行的电脑,它是计算机运行的完全必要部分。姑且认为这个完全必要部分是台式电脑的主机,里面有CPU、内存、主板、电源等设备,因为有了这些,一台有使用意义的电脑即可运行。有了主机,并不能方便的为人所服务,因此得有外设。外设是电脑的外围设备,如显示器、键盘、鼠标等,它们是完成人机交互的辅助工具。外设包含两种重要设备(但不限于此):输入设备和输出设备。像鼠标键盘属于输入设备,将人的指令转成“鼠键行为”这种数据传给主机;显示器是输出设备,主机通过运算,把“返回信息”这种数据传给显示器。

其次,从计算机架构的角度去理解IO:

从计算机架构上来讲,任何涉及到计算机核心(CPU和内存)与其他设备间的数据转移的过程就是IO。本体就是计算机核心(CPU和内存)。例如从硬盘上读取数据到内存,是一次输入,将内存中的数据写入到硬盘就产生了输出。在计算机的世界里,这就是IO的本质。

最后,从编程的角度去理解IO:

此时,IO的主体是其应用程序的运行态,即进程,特别强调的是我们的应用程序其实并不存在实质的IO过程,真正的IO过程是操作系统的事情,这里把应用程序的IO操作分为两种动作:IO调用和IO执行。IO调用是由进程发起,IO执行是操作系统的工作。因此,更准确些来说,此时所说的IO是应用程序对操作系统IO功能的一次触发,即IO调用。

IO调用的目的是将进程的内部数据迁移到外部即输出,或将外部数据迁移到进程内部即输入。这里,外部数据指非进程空间数据,在编程时,通常讨论的场景是来自外部存储设备的数据,如硬盘、CD-ROM、以及需要socket通信传输的网络数据。

以一个进程的输入类型的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时“等”消耗的时间往往比“拷贝”消耗的时间多,钓鱼也恰好符合这个特点,钓鱼时我们大部分时间都在等鱼上钩,而当鱼上钩后只需要一瞬间就能将鱼“拷贝”上来。

我们先来看看什么样的钓鱼方式才是高效的:
下面给出五个人的钓鱼方式:

  • 张三:拿了1个鱼竿,将鱼钩抛入水中后就死死的盯着浮漂,什么也不做,当有鱼上钩后就挥动鱼竿将鱼钓上来。
  • 李四:拿了1个鱼竿,将鱼钩抛入水中后就去做其他事情,然后定期观察浮漂,如果有鱼上钩则挥动鱼竿将鱼钓上来,否则继续去做其他事情。
  • 王五:拿了1个鱼竿,将鱼钩抛入水中后在鱼竿顶部绑一个铃铛,然后就去做其他事情,如果铃铛响了就挥动鱼竿将鱼钓上来,否则就根本不管鱼竿。
  • 赵六:拿了100个鱼竿,将100个鱼竿抛入水中后就定期观察这100个鱼竿的浮漂,如果某个鱼竿有鱼上钩则挥动对应的鱼竿将鱼钓上来。
  • 田七:田七是一个有钱的老板,他给了自己的司机一个桶、一个电话、一个鱼竿,让司机去钓鱼,当鱼桶装满的时候再打电话告诉田七来拿鱼,而田七自己则开车去做其他事情去了。

张三、李四、王五的钓鱼效率是否一样?为什么?

张三、李四、王五的钓鱼效率本质上是一样的。

  • 首先他们的钓鱼方式都是一样的,都是先等鱼上钩,然后再将鱼钓上来。

  • 其次,因为他们每个人都是拿的一根鱼竿,当河里有鱼来咬鱼钩时,这条鱼咬哪一个鱼钩的概率都是相等的。

  • 因此张三、李四、王五他们三个人的钓鱼的效率是一样的,他们只是等鱼上钩的方式不同而已,张三是死等,李四是定期检测浮漂,而王五是通过铃铛来判断是否有鱼上钩。

需要注意的是,这里问的是他们的钓鱼效率是否是一样的,而不是问他们整体谁做的事最多,如果说整体做事情的量的话,那一定是王五做得最多,李四次之,张三最少。

张三、李四、王五它们三个人分别和赵六比较,谁的钓鱼效率更高?

赵六毫无疑问是这四个人当中钓鱼效率最高的,因为赵六同时在等多个鱼竿上有鱼上钩,因此在单位时间内,赵六的鱼竿有鱼上钩的概率是最大的。

  • 为了方便计算,我们假设赵六拿了97个鱼竿,加上张三、李四、王五的鱼竿一共就有100个鱼竿。当河里有鱼来咬鱼钩时,这条鱼咬张三、李四、王五的鱼钩的概率都是百分之一,而咬赵六的鱼钩的概率就是百分之九十七。因此在单位时间内,赵六的鱼竿上有鱼的概率是张三、李四、王五的97倍。

高效的钓鱼就是要减少单位时间内“等”的时间,增加“拷贝”的时间,所以说赵六的钓鱼效率是这四个人当中最高的。

赵六的钓鱼效率之所以高,是因为赵六一次等待多个鱼竿上的鱼上钩,此时就可以将“等”的时间进行重叠。

如何看待田七的这种钓鱼方式?

田七让自己的司机帮自己钓鱼,自己开车去做其他事情去了,此时这个司机具体怎么钓鱼已经不重要了,他可以模仿张三、李四、王五、赵六任何一个人的钓鱼方式进行钓鱼。

最重要的是田七本人并没有参与整个钓鱼的过程,他只是发起了钓鱼的任务,而真正钓鱼的是司机,田七在司机钓鱼期间可能在做任何其他事情,如果将钓鱼看作是一种IO的话,那田七的这种钓鱼方式就叫做异步IO

而对于张三、李四、王五、赵六来说,他们都需要自己等鱼上钩,当鱼上钩后又需要自己把鱼从河里钓上来,对应到IO当中就是需要自己进行数据的拷贝,因此他们四个人的钓鱼方式都叫做同步IO。

实际这五个人的钓鱼方式分别对应的就是五种IO模型。

  • 张三这种死等的钓鱼方式对应就是阻塞IO。
  • 李四这种主动定时检测是否有鱼上钩的方式就是非阻塞IO。
  • 王五这种通过设置铃铛得知事件是否就绪的方式就是信号驱动IO。
  • 赵六这种一次等待多个鱼竿上有鱼的钓鱼方式就是IO多路转接。
  • 田七这种让别人帮自己钓鱼的钓鱼方式就是异步IO。

了解了五种模型基本原理,下面我们来具体说明一下。

阻塞IO

阻塞IO就是在内核将数据准备好之前,系统调用会一直等待。
在这里插入图片描述
阻塞IO是最常见的IO模型,所有的套接字,默认都是阻塞方式。

  • 比如当调用recvfrom函数从某个套接字上读取数据时,可能底层数据还没有准备好,此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后recvfrom函数才会返回。
  • 在recvfrom函数等待数据就绪期间,在用户看来该进程或线程就阻塞住了,本质就是操作系统将该进程或线程的状态设置为了某种非R状态,然后将其放入等待队列当中,当数据就绪后操作系统再将其从等待队列当中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。

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

非阻塞IO

非阻塞IO就是,如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。

在这里插入图片描述
非阻塞IO往往需要程序员以循环的方式反复尝试读写文件描述符,这个过程称为轮询,这对CPU来说是较大的浪费,一般只有特定场景下才使用。

  • 比如当调用recvfrom函数以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么recvfrom函数会立马错误返回,而不会让该进程或线程进行阻塞等待。
  • 因为没有读取的数据,因此该进程或线程后续还需要继续调用recvfrom函数,检测底层数据是否就绪,如果没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
  • 每次调用recvfrom函数读取数据时,就算底层数据没有就绪,recvfrom函数也会立马返回,在用户看来该进程或线程就没有被阻塞住,因此我们称之为非阻塞IO。

阻塞IO和非阻塞IO的区别在于,阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。

阻塞与非阻塞举例:

系统中大部分的接口都是阻塞式接口,比如我们可以用read函数从标准输入当中读取数据。

#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>

int  main()
{
	while(1)
	{
		char buffer[1024];
		ssize_t s=read(0,buffer,sizeof(buffer)-1);
		if(s>0)
		{
			buffer[s]=0;
			write(1,buffer,strlen(buffer));
		}
	}
	return 0;	

}

程序运行后,如果我们不进行输入操作,此时该进程就会阻塞住,根本原因就是因为此时底层数据不就绪,因此read函数需要进行阻塞等待。
在这里插入图片描述

一旦我们进行了输入操作,此时read函数就会检测到底层数据就绪,然后立马将数据读取到从内核拷贝到我们传入的buffer数组当中,并且将读取到的数据输出到显示器上面,最后我们就看到了我们输入的字符串。
在这里插入图片描述
如果我们想让上面的输入变成非阻塞的,应该怎么做?

如果要将已经打开的某个文件或套接字设置为非阻塞,此时就需要用到fcntl函数。

fcntl函数

fcntl函数的函数原型如下:

int fcntl(int fd, int cmd, ... /* arg */);
参数说明:

  • fd:已经打开的文件描述符。
  • cmd:需要进行的操作。
  • …:可变参数,传入的cmd值不同,后面追加的参数也不同。

fcntl函数常用的5种功能与其对应的cmd取值如下:

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

返回值说明:

  • 如果函数调用成功,则返回值取决于具体进行的操作。
  • 如果函数调用失败,则返回-1,同时错误码会被设置。

实现SetNonBlock函数

我们可以定义一个函数,该函数就用于将指定的文件描述符设置为非阻塞状态。

  • 先调用fcntl函数获取该文件描述符对应的文件状态标记(这是一个位图),此时调用fcntl函数时传入的cmd值为F_GETFL
  • 在获取到的文件状态标记上添加非阻塞标记O_NONBLOCK,再次调用fcntl函数对文件状态标记进行设置,此时调用fcntl函数时传入的cmd值为F_SETFL
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>
#include<signal.h>
#include<fcntl.h>


void SetNonBlock(int fd)
{
	int fl = fcntl(fd, F_GETFL);
	if (fl < 0){
		perror("fcntl");
		return;
	}
	fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}


int  main()
{
	SetNonBlock(0);//将文件描述符0(输入)设为非阻塞
	while(1)
	{
		char buffer[1024];
		ssize_t s=read(0,buffer,sizeof(buffer)-1);
		if(s>0)
		{
			buffer[s]=0;
			write(1,buffer,strlen(buffer));
			printf("read success, s:%d, errno:%d\n",s,errno);
		}
		else {
			printf("read failed, s:%d, errno:%d\n",s,errno);
		}
		sleep(1);
	}
	return 0;	

}

原理:

  • 对于非阻塞,运行代码后,当我们没有输入数据时,程序就会不断调用read函数检测底层数据是否就绪。

  • 一旦我们进行了输入操作,此时read函数就会在轮询检测时检测到,紧接着立马将数据读取到从内核拷贝到我们传入的buffer数组当中,并且将读取到的数据输出到显示器上面。

此时我们运行代码发现,当read函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么read函数就会立即返回,但read函数是以出错的形式(返回-1)返回的,但是我们要知道没有就绪和错误是不一样的,那么怎么进一步区分到底是因为错误返回的还是因为数据没有就绪返回的呢?
在这里插入图片描述

以非阻塞方式读取数据时,如果调用read函数时得到的返回值是-1,此时还需要通过错误码进一步进行判断,如果错误码的值是EAGAINEWOULDBLOCK,说明本次调用read函数出错是因为底层数据还没有就绪,因此后续还应该继续调用read函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。

在这里插入图片描述
我们可以看到,EAGAIN错误码所对应的值就是11

信号驱动IO

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

  • 比如我们需要调用recvfrom函数从某个套接字上读取数据,那么就可以将该操作定义为SIGIO的信号处理程序。
  • 当底层数据就绪时,操作系统就会递交SIGIO信号,此时就会自动执行我们定义的信号处理程序,进程将数据从内核拷贝到用户空间。

信号的产生是异步的,但信号驱动IO是同步IO的一种。

  • 我们说信号的产生异步的,因为信号在任何时刻都可能产生。
  • 但信号驱动IO是同步IO的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,因此当前进程或线程仍然需要参与IO过程。

判断一个IO过程是同步的还是异步的,本质就是看当前进程或线程是否需要参与IO过程,如果要参与那就是同步IO,否则就是异步IO。

IO多路转接

IO多路转接也叫做IO多路复用,能够同时等待多个文件描述符的就绪状态。在这里插入图片描述
IO多路转接的思想:

  • 因为IO过程分为“等”和“拷贝”两个步骤,因此我们使用的recvfrom等接口的底层实际上都做了两件事,第一件事就是当数据不就绪时需要等,第二件事就是当数据就绪后需要进行拷贝。
  • 虽然recvfrom等接口也有“等”的能力,但这些接口一次只能“等”一个文件描述符上的数据或空间就绪,这样IO效率太低了。
  • 因此系统为我们提供了三组接口,分别叫做select、poll和epoll,这些接口的核心工作就是“等”,我们可以将所有“等”的工作都交给这些多路转接接口。
  • 因为这些多路转接接口是一次“等”多个文件描述符的,因此能够将“等”的时间进行重叠,当数据就绪后再调用对应的recvfrom等函数进行数据的拷贝,此时这些函数就能够直接进行拷贝,而不需要进行“等”操作了。

IO多路转接就像现实生活中的黄牛一样,只不过IO多路转接更像是帮人排队的黄牛,因为多路转接接口实际并没有帮我们进行数据拷贝的操作。这些排队黄牛可以一次帮多个人排队,此时就将多个人排队的时间进行了重叠。

异步IO

异步IO就是由内核在数据拷贝完成时,通知应用程序。
在这里插入图片描述

  • 进行异步IO需要调用一些异步IO的接口,异步IO接口调用后会立马返回,因为异步IO不需要你进行“等”和“拷贝”的操作,这两个动作都由操作系统来完成,你要做的只是发起IO。
  • 当IO完成后操作系统会通知应用程序,因此进行异步IO的进程或线程并不参与IO的所有细节。

IO重要概念

同步通信 VS 异步通信

同步和异步关注的是消息通信机制。

  • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
  • 异步则是相反,调用在发出之后,这个调用就直接返回了,所有没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

为什么非阻塞IO在没有得到结果之前就返回了?

  • IO是分为“等”和“拷贝”两步的,当调用recvfrom进行非阻塞IO时,如果数据没有就绪,那么调用会直接返回,此时这个调用返回时并没有完成一个完整的IO过程,即便调用返回了那也是属于错误的返回。
  • 因此该进程或线程后续还需要继续调用recvfrom,轮询检测数据是否就绪,当数据就绪后最后再把数据从内核拷贝到用户空间,这才是一次完整的IO过程。

因此,在进行非阻塞IO时,在没有得到结果之前,虽然这个调用会返回,但后续还需要继续进行轮询检测,因此可以理解成调用还没有返回,而只有当某次轮询检测到数据就绪,并且完成数据拷贝后才认为该调用返回了。

同步通信 VS 同步与互斥

在多进程和多线程当中有同步与互斥的概念,但是这里的同步通信和进程或线程之间的同步是完全不相干的概念。

  • 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
  • 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。

因此当看到“同步”这个词的时候,一定要先明确这个同步是同步通信的同步,还是同步与互斥的同步。

阻塞 VS 非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态。

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

其他高级IO

非阻塞IO,记录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射IO(mmap),这些统称为高级IO。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

…狂奔的蜗牛~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值