3.执行I/O操作时底层发生了什么?
你有没有想过当我们执行I/O操作时计算机底层都发生了些什么?
在回答这个问题之前,我们先来看下为什么对于计算机来说I/O是极其重要的。
不能执行I/O的计算机是什么?
相信对于程序员来说I/O操作是最为熟悉不过的了:
当我们使用C语言中的printf、C++中的”<<”,Python中的print,Java中的System.out.println等时, 这是I/O;
当我们使用各种语言读写文件时,这也是I/O;
当我们通过TCP/IP进行网络通信时,这同样是I/O;
当我们使用鼠标龙飞凤舞时,当我们扛起键盘在评论区里指点江山亦或是埋头苦干努力制造bug时、 当我们能看到屏幕上的漂亮的图形界面时等等,这一切都是I/O。
想一想,如果没有I/O计算机该是一种多么枯燥的设备,不能看电影、不能玩游戏,也不能上网,这样的计算机最多就是一个大号的计算器。
既然I/O这么重要,那么到底什么才是I/O呢?
什么是I/O
I/O就是简单的数据Copy,仅此而已。
这一点很重要,为了加深大家的印象,来,everybody,follow me,那边树上的朋友,还有那边墙上的朋友们,举起你们的双手,跟我唱,苍茫的天涯是。。。sorry,I/O仅仅就是数据copy、I/O仅仅就是数据copy。
既然是copy数据,又是从哪里copy到哪里呢?
如果数据是从外部设备copy到内存中,这就是Input。
如果数据是从内存copy到外部设备,这就是Output。
内存与外部设备之间的数据copy就是I/O(Input/Output),仅此而已。
I/O与CPU
现在我们知道了什么是I/O,接下来就是重点部分了,大家注意,坐稳了。
我们知道现在的CPU其主频都是数GHz起步,这是什么意思的呢?简单说就是CPU执行机器指令的速度是纳秒级别的,而通常的I/O比如磁盘操作,一次磁盘seek大概在毫秒级别,因此如果我们把CPU比作战斗机的话,那么I/O操作的速度就是肯德鸡。
也就是说当我们的程序跑来时(CPU执行机器指令),其速度是要远远快于I/O速度的,那么接下来的问题就是二者速度相差这么大,那么我们该如何设计、该如何更加合理的高效利用系统资源呢?
既然有速度差异,而且进程在执行完I/O操作前不能继续向前推进,那么显然只有一个办法,那就是等待,wait。
同样是等待,有聪明的等待,也有傻傻的等待,简称傻等,那么是选择聪明的等待呢还是选择傻等呢?
假设你是一个急性子(CPU),需要等待一个重要的文件,不巧的是这个文件只能快递过来(I/O),那么这时你是选择什么事情都不干了,深情的的注视着门口就像盼望着你的哈尼一样专心等待这个快递呢?还是暂时先不要管快递了,玩个游戏看个电影刷会儿短视频等快递来了再说呢?
很显然,更好的方法就是先去干其它事情,快递来了再说。因此这里的关键点就是手头上的事情可以先暂停,切换到其它任务,等快递过来了再切换回来。
理解了这一点你就能明白执行I/O操作时底层都发生了什么。
我们以读取磁盘文件内容为例。
执行I/O时底层都发生了什么
在上一篇《看完这篇还不懂多线程与线程池你来打我》中,我们引入了进程和线程的概念,实际上操作系统调度的是线程而不是进程,为了更加清晰的理解I/O过程,我们暂时假设操作系统只有进程这样的概念,先不去考虑线程,这并不会影响我们的讨论。
现在内存中有两个进程,进程A和进程B,当前进程A正在运行,如图所示:
进程A中有一段读取文件的代码,不管在什么语言中通常我们自定义一个用来装数据的buff,然后调用read之类的函数,像这样:
read(buff);
这就是一种典型的I/O操作,当CPU执行到这段代码的时候会向磁盘发送读取请求,注意与CPU执行指令的速度相比,I/O操作操作是非常慢的,因此操作系统是不可能把宝贵的计算资料浪费在无谓的等待上的,这时重点来了,注意接下来是重点哦。
由于外部设备执行I/O操作是相当慢的,因此在I/O操作完成之前进程是无法继续向前推进的,这就是所谓的阻塞,即通常所说的block。操作系统检测到进程向I/O设备发起请求后就暂停进程的运行,怎么暂停运行呢?很简单,只需要记录下当前进程的运行状态并把CPU的PC寄存器指向其它进程的指令就可以了。
进程有暂停就会有继续执行,因此操作系统必须保存被暂停的进程以备后续继续执行,显然我们可以用队列来保存被暂停执行的进程,如图所示,进程A被暂停执行并被放到阻塞队列中(注意,不同的操 作系统会有不同的实现,可能每个I/O设备都有一个对应的阻塞队列,但这种实现细节上的差异不影 响我们的讨论)。
这时操作系统已经向磁盘发送了I/O请求,因此磁盘driver开始将磁盘中的数据copy到进程A的buff 中,注意虽然这时进程A已经被暂停执行了,但这并不妨碍磁盘向内存中copy数据。注意,现代磁盘向内存copy数据时无需借助CPU的帮助,这就是所谓的DMA(Direct Memory Access),这个过程如图所示:
让磁盘先copy着数据,我们接着聊。
实际上操作系统中除了有阻塞队列之外也有就绪队列,所谓就绪队列是指队列里的进程准备就绪可以被CPU执行了,你可能会问为什么不直接执行非要有个就绪队列呢?答案很简单,那就是僧多粥少, 在只有2个核的机器上可以创建出成千上万个进程,CPU不可能同时执行这么多的进程,因此必然存在这样的进程,即使其一切准备就绪也不能被分配到计算资源,这样的进程就被放到了就绪队列。
现在进程B就位于就绪队列,万事俱备只欠CPU,如图所示:
当进程A被暂停执行后CPU是不可以闲下来的,因此就绪队列中还有嗷嗷待哺的进程,这时操作系统开始在就绪队列中找可以执行的下一个进程,也就是这里的进程B。
此时操作系统将进程B从就绪队列中取出,找出进程B被暂停时执行到的机器指令位置,然后将CPU的PC寄存器指向该位置,这样进程B就开始运行啦,如图所示:
注意,注意,接下来的这段是重点中的重点。
注意观察上图,此时进程B在被CPU执行,磁盘在向进程A的内存空间中copy数据,看出来了吗,大家都在忙,谁都没有在闲着,数据copy和指令执行在同时进行,在操作系统的调度下CPU、磁盘都得到了充分的利用,这就是程序员的智慧所在。
现在你应该理解为什么操作系统这么重要了吧。
此后磁盘终于将全部数据都copy到了进程A的内存中,这时磁盘通知操作系统任务完成啦,你可能会问怎么通知呢?这就是中断。
操作系统接收到磁盘中断后发现数据copy完毕,进程A重新获得继续运行的资格,这时操作系统小心翼翼的把进程A从阻塞队列放到了就绪队列当中,如图所示:
注意,操作系统是不会直接运行进程A的,进程A必须被放到就绪队列中等待,这样对大家都公平。
此后进程B继续执行,进程A继续等待,进程B执行了一会儿后操作系统认为进程B执行的时间够长了,因此把进程B放到就绪队列,并把进程A取出继续执行。
注意操作系统把进程B放到的是就绪队列,因此进程B被暂停运行仅仅是因为时间片到了而不是因为发起I/O请求被阻塞,如图所示:
进程A继续执行,此时buff中已经装满了想要的数据,进程A就这样愉快的运行下去了,就好像从来没有被暂停过一样,进程对于自己被暂停一事一无所知,这就是操作系统的魔法。
现在你应该明白了I/O是一个怎样的过程了吧。
这种进程执行I/O操作被阻塞暂停执行的方式被称为阻塞式I/O,blocking I/O,这也是最常见最容易理解的I/O方式,有阻塞式I/O就有非阻塞式I/O,在这里我们暂时先不考虑这种方式。
零拷贝,Zero-copy
在本节开头我们说过暂时只考虑进程而不考虑线程,现在我们放宽这个条件,实际上也非常简单,只需要把前图中调度的进程改为线程就可以了,这里的讨论对于线程一样成立。
最后需要注意的一点就是上面的讲解中我们直接把磁盘数据copy到了进程空间中,但实际上一般情况下I/O数据是要首先copy到操作系统内部,然后操作系统再copy到进程空间中。因此我们可以看到这里其实还有一层经过操作系统的copy,对于性能要求很高的场景其实也是可以绕过操作系统直接进行数据copy的,这也是本文描述的场景,这种绕过操作系统直接进行数据copy的技术被称为Zero-copy,也就零拷贝,高并发、高性能场景下常用的一种技术,原理上很简单吧。
总结
本文讲解的是程序员常用的I/O,一般来说作为程序员我们无需关心,但是理解I/O背后的底层原理对于设计高性能、高并发系统是极为有益的,希望这篇能对大家加深对I/O的认识有所帮助。