那么今天我们就要讲解我们之前很熟悉但是又没有弄明白的知识点:缓冲区!!!
而在讲解之前我先帮大家回忆一下我们在哪些地方讲到了缓冲区这样的概念:


我们分别在初识struct file中和在进程终结的章节中提到了缓冲区的概念,我们从上面的两幅图可以看到,在struct file中的缓冲区前面有内核二字,而下面exit中我只写了缓冲区,说明这二者是有区别的,那么区别是什么呢?
那么要解决这个问题,等我们讲完了这篇的知识,相信大家心中就有了答案。
我们前面只提了缓冲区的字眼,但是我们还并不知道这缓冲区到底是何方神圣,下面我来解释一下:
缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间⽤来缓冲输⼊或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输⼊设备还是输出设备,分为输⼊缓冲区和输出缓冲区。
直接说缓冲区的概念比较晦涩,不太好理解,下面我们从另一个角度来理解缓冲区。
要探讨缓冲区存在的意义,下面我用一个示例来帮助大家更好地理解:

张三和李四两个人是好朋友,过段时间呢,李四就要生日了,作为好朋友的张三想送给李四一个生日礼物:一把机械键盘,那么我问大家一个问题:张三会自己从云南一路跑到到北京将礼物给李四吗?
答案并不会,我们在日常生活中也知道,给别人送个礼物不会每次都自己亲自去送,尤其是从云南到北京这么远的距离,这会耗费张三大量的时间,那么我们会怎么送呢?

张三的做法应该是将包裹交给楼下的菜鸟驿站,让它负责将礼物送到李四楼下的菜鸟驿站对吧。
而张三在把包裹交给菜鸟驿站后,回到宿舍后室友问他:你的礼物去哪儿了?张三说:“ 送给我朋友李四了 ”。
但是礼物真的已经送到了李四的手中了吗?
很明显并没有,它是被存在了菜鸟驿站中,那么在这个过程中菜鸟驿站就扮演着缓存的角色,而张三的键盘礼物就是数据,而将键盘礼物给菜鸟驿站,不就是我们之前讲的write函数将其拷贝到了菜鸟驿站中吗?
那么我想用上面的例子来说明什么问题呢?
我们可以比较两种送礼物的方式,对于第一种而言,张三亲自去送,但是会耗费大量的时间;而对于第二种,张三只需要下个楼,将包裹交给菜鸟驿站即可,然后他就可以回去接着忙自己的事情了。
那么缓冲区的第一个意义已经呼之欲出了:就是提高使用缓存的进程的效率!!!
大家可以想一想是不是这个理,我们创建一个进程,将某些数据写到某个文件中,有了缓存的存在,进程直接将数据放在缓存中就行,接着返回去做其他的事情,也就是在单位时间内可以做更多的事情,这不就提高了进程的效率了吗?
当然,送礼物的过程还得有人去做,但是我们关心吗?
我们不关心,我进程将数据交给缓存,你缓存找人把数据给我传过去就行,而菜鸟驿站将礼物送到李四手中的过程就是我们常说的刷新缓冲区!!!
那么这里又有一个问题:张三每次将包裹交给菜鸟驿站,菜鸟驿站就会立即将包裹发出去吗?
答案是不会的,菜鸟驿站要真这么做,那站长干一段时间就干不下去了,因为你送过来一个包裹,我直接就给你发过去,你又送过来,我又给你发出去,每次都要掏路费啊。
正常的做法应该是你送过来,我先放着,等我这个站点包裹满了,我一次性给你全发出去,这才是正确的做法,大家可以比较一下两种方式,明显第二种方式更为合理,效率也更高。
所以缓冲区的第二个意义也就揭晓了:缓冲区允许数据在它里面积压,然后一次性刷新多次数据,通过这种方式就可以变相的减少I/O次数,节省时间,提高效率!!!
我们之前也说过,I/O操作是要消耗时间的,频繁的进行I/O操作,势必会耗费大量的时间,而通过积压的方式,一次性刷新多次数据,就可以大大减少I/O操作的次数,也就会节省很多时间,进而提高效率。
那么菜鸟驿站什么时候发快递呢?或者说缓冲区决定使用什么样的刷新策略?
虽然上面我们说要等缓冲区满了才发快递,但那是我是站在缓冲区允许数据积压的角度去阐述的,但其实缓冲区有着不同的刷新策略:

其中第一条的无缓冲,立即刷新用的很少,我们知道有这种刷新策略即可。
而对于第二种我们其实也不陌生,我们在日常使用printf,fprintf等函数时,它们就是行刷新,行刷新的条件是当使用前面的函数时,最后有" \n "的字符,没有" \n "也是不会刷新的。
第三种也就是我们上面所说的写满再刷新,在普通文件很常用。
而刷新策略呢又分为两种情况:

对于第一种就是我们平常写的main函数在return 0后进程就会结束,但同时也会自动刷新缓冲区。
而对于第二种方式相比我们都不陌生,这是上一篇我们遗留的问题之一,到了这里我们就对于为什么fflush后我们cat log.txt文件中的内容能显示出来有了一个简单的认识,因为它刷新了缓冲区,但是这个问题并没有完全解决,因为我们还没有见过缓冲区长什么样子。
我们用一个例子来引出这个深藏不露的缓冲区:


这里写了一个简单的例子,大家都能看懂,当我们执行后,printf中的内容立即就出现在显示器上面了,然后等待了三秒后,进程结束,这是完整的过程,那我们下面再看:



而当我把printf后面的" \n "给去掉后,我们执行后会发现,没有打印出任何东西,而是直接就执行了sleep语句。
但是我们都知道,程序是按顺序执行的,所以肯定是先执行了printf语句再执行sleep语句,那我们printf中的数据在呢?
在语言级别的缓冲区中!!!,那缓冲区在哪儿呢?
我们之前在将文件标识符的时候讲了C语言中FILE结构体,当时我们只说了这个结构体中封装了文件标识符fd,,又因为我们C语言访问文件,都是通过FILE访问的,所以现在我告诉你这个结构体中为我们维护了:语言级别的缓冲区!!!

在这个结构体中分别有两个指针分别指向了输入缓冲区和输出缓冲区,也就是内存中的两块空间,不过这两块空间不是你启动进程就立刻给你分配的,而是你打开输入输出文件时才会在内存中开辟相应的空间。
我们可以在C语言的库中找到这个FILE结构体的实现:


上面就是C语言中实现的FILE结构体,在实现时叫做_IO_FILE,但是上面它会对其进行重命名为FILE,在这个结构体中我们可以看到各种char* 的指针,后面都有各自的介绍,这里就不多说了。
而在这里面我们也能看到我们的老熟人:int _fileno,就是FILE结构体封装的文件标识符。
而在我们上面的例子中,没有了" \n ",那么printf函数就不会执行行刷新,数据就会保存在stdout文件的缓冲区中,等到进程结束了自动刷新缓冲区。
注意这里介绍的语言级别的缓冲区和最上面的文件内核缓冲区并不是一个东西,这俩一个在用户空间中,一个在内核空间中,注意不要搞混了。
有了对缓冲区的初步认识,我们就来完整的串联一下整个文件IO的过程,让大家对于缓冲区有个更深刻的理解:

我们来看这张图,下面是操作系统中的内容,也就是内核空间中内容,这里面有文件内核缓冲区,而上面属于用户空间中的内容。
这整个过程我来为大家阐述:
我们现在是用户,我们通过fgets,scanf等函数获取了" hello world "字符串数据,将其保存在一个字符数组中,而我们下来想通过printf,fprintf等函数将其打印到出来。
而我们调用printf函数实际上是向stdout文件中写入内容,那么就必然要先打开stdout这个文件,也就是会调用fopen函数来打开文件,此时就会返回一个FILE*的指针,其实也就是一个结构体指针,在结构体中有相应的文件缓冲区。
而通过printf函数,就会将用户定义的缓冲区(就是我们自己定义的数组)中的数据刷新到stdout的文件缓冲区中保存起来。
而到了最后由printf函数在内部使用write函数将stdout缓冲区中的内容刷新到了文件的内核缓冲区中,而把数据交给操作系统,那么我们就可以认为数据已经写入完毕!!!
当然我们有时候也不一定就会定义一个数组,也会直接在printf函数中输入字符串,这个字符串会保存在字符常量区中,这里也可以认为是用户定义的缓冲区。
并且我们同时也要注意用户空间中的struct FILE和内核空间中的struct file可不是一个东西,这是两个东西,里面内容是不一样的!!

而文件缓冲区有不同的刷新策略,在有了上面对IO过程的初步认识后,我们在理解printf函数的行刷新就好理解了:前面的过程一样,将用户定义的缓冲区中的内容刷新到stdout的文件缓冲区中,之后判断后面有没有" \n ",如果有,那么就会立即调用write函数将数据刷新到文件内核缓冲区,反之如果没有,数据就会停留在文件缓冲区中,等待进程结束后自动刷新缓冲区。
并且我们现在就可以真正解决我们上一篇遗留的问题:


为什么通过这两种方式,就能通过cat log.txt打印出文件中的内容?
首先第一种,如果我们没有将close给注释掉,那么现象就是我们执行代码后,打印log.txt文件中的内容却什么都没有显示,那么是为什么呢?
看上面的代码,我们首先将stdout给关闭了,那么当open打开log.txt文件时,它分配到的文件标识符就是1,原因我们在上一篇已经讲过,这里就不再解释。
而后面我们要执行pritnf函数,printf函数会将数据刷新到stdout文件缓冲区中,这里注意无论1号文件标识符指向谁,printf函数都只向stdout的文件缓冲区中刷新数据,而因为传入的字符串含有" \n ",所以会进行行刷新,也就是printf函数内部会使用write函数将数据刷新到1号标识符所对应文件的文件内核缓冲区中,那么之后就会由操作系统将文件内核缓冲区中的数据给同步到磁盘文件中,这是完整的过程。
但问题就在最后,你在执行printf函数后,将1号标识符中给关闭了,那操作系统怎么将文件内核缓冲区中的数据同步到磁盘中的文件呢?
我的意思就是在操作系统将文件内核缓冲区中的数据同步到磁盘文件之前这段时间内,你把1号文件标识符给关了,文件关了操作系统还怎么同步呢?
但上面是普通文件,我们再来一种情况进行对比:


同样是在printf函数后执行close(1),但是这次却能打印出内容,这是为什么呢?
其实这就是终端设备与普通文件之间的区别:对于终端设备而言,在进行行刷新后,操作系统就会立刻将文件内核缓冲区中的内容同步到磁盘文件中,也就是显示器文件,所以在执行close(1)之前,操作系统就已将完成了同步工作,将内容显示出来。
但对于普通文件,操作系统并不会立刻将文件内核缓冲区中的内容同步到磁盘文件中,这也就导致在执行close(1)之前,操作系统并未完成同步工作,所以log.txt就没有内容了。
而我们上面的fflush操作就是在执行close(1)之前,强制将文件缓冲区的数据同步到磁盘文件中,所以即使后面执行了close(1),但内容已经在log.txt文件中了。
通过上面的介绍,大家对于语言级别的缓冲区有了更深的认识,那么这个时候就有人问了:为什么要有语言级别缓冲区?
要解决这个问题,我们先设想一种情况:如果没有语言级别的缓冲区,那么我们是不是就没有了所谓的刷新策略,进而导致的结果就是只要有数据需要刷新,就会去调用write函数来执行。
但是write函数是系统调用啊,我们要知道调用系统调用是有成本的,也就是比较耗费时间,那么没有了语言级别的缓冲区,我们就要频繁的去调用write系统调用函数,进而导致的结果就是效率低下。
而有了语言级别的缓冲区,有了这些刷新策略,尤其是全刷新,那么就可以减少系统调用的次数,进而带来效率的提升,举个例子:

就比如我们在普通文件中写入了大量的printf,fprintf等函数,而普通文件我们上面说了刷新策略是全刷新,那么在文件缓冲区满之前,printf函数只需要将格式化后的数据直接放在文件缓冲区中即可,不需要调用wirte函数,进而在单位时间内不就可以执行更多的C代码了吗?
所以说有了语言级别的缓冲区,就可以提高使用C语言IO接口的效率!!!
有了对上面知识的认识,我们来看一个有点难度的例子:



我们来看上面的例子,当我们正常执行的时候,结果是按顺序输出的,没有什么问题,但当我们把输出的结果重定向到log.txt中时,却出现了上面的情况,刚看到输出结果想必大家都比较迷茫,为什么输出结果是这样呢?
我来总结一下大家心中的疑问:
1.为什么执行hello write的语句在最下面,而输出结果却是在最上面?
2.为什么hello write只出现了一次,而剩下的三个却出现了两次?
先回答第一个问题:我们通过重定向的操作,使1号标识符指向的文件由stdout文件指向了log.txt文件,上面的printf,fprintf和fwrite会先将用户定义的缓冲区的内容刷新到stdout的文件缓冲区中,按理说下面要执行行刷新了对吧,毕竟它们后面都带有" \n "。
但此时问题就出现了,因为重定向的原因导致1号标识符指向的不再是stdout文件而是普通文件log.txt,而只有是stdout文件,也就是显示器文件才会使用行刷新,但是此时是log.txt文件,那么就不在使用行刷新的策略,而是会转去使用全刷新的策略,全刷新是什么情况啊?
写满再刷新啊,所以即使上面的三个都有" \n ",但并不会执行行刷新,而是都存在文件缓冲区中,而下面的write本身就是系统调用,直接就将用户定义的缓冲区的内容给刷新到了文件内核缓冲区中,先来后到,那可不就在最上面吗?
而上面转换刷新策略的做法在linux中叫做:刷新策略隐式调整!!!
我们与上面的这个例子进行比较:

那为什么这个例子中printf依旧是执行行刷新呢?
这就要了解刷新略调整的时机:这个例子中其实可以算是在程序执行过程中进行了重定向的操作,这个想必没有什么问题。
但是我们上面的例子是在程序执行之前就进行了重定向的操作,这两种方式是不一样的,区别在于重定向的时机。
如果在程序启动前就重定向,那么就会根据重定向的文件来调整刷新策略,反之如果在程序执行过程中进行重定向,那么就不会改变刷新策略!!!
这也就解释了为什么一个没有调整刷新策略,另一个调整了刷新策略,上面因为在程序执行之前重定向的文件是普通文件,所以才会将行刷新调整为全刷新,那么调整刷新策略的工作是由谁来做的呢?
这个工作是由C标准库来完成的,它会根据isatty的结果来调整刷新策略。
而当进程结束时就会自动刷新缓冲区,所以剩下的三个自然就在后面了。
第二个问题:看到write函数后面fork函数了吗?
这个fork函数当然不是白写的,在执行fork函数之前,printf等三个函数中的数据已经刷新到stdout的文件缓冲区,当然此时是全刷新,所以在内部并不会使用write将数据刷新到文件内核缓冲区中。
而当执行了fork函数之后,产生了父子进程,并且父子进程都要执行后面的代码对吧,那后面剩下什么代码呢?
不就剩一个return 0了嘛,那么父子进程return 0后代表着什么呢?
进程的结束啊,那么进程结束后干什么啊?
自动刷新缓冲区嘛,所以为什么上面三个函数的内容会出现两次呢?
就是因为父子进程的出现,导致它们刷新了各自的缓冲区,刷新一次就拷贝一份,刷新两次不就拷贝了两次吗?
所以到这里我们就理解为什么上面三个函数的内容会出现两次了。
有了上面对语言级别的缓冲区的认识,要理解文件内核缓冲区也就不用那么麻烦了。
而文件内核缓冲区与语言级别缓冲区是有区别的,对于刷新策略:内核缓冲区只有全缓冲,语言级别的缓冲区有不同的刷新策略!!!
但刷新方式毕竟是由操作系统来操作的,当基本刷新条件不满足的情况下,会有单独的执行流,根据内存的使用情况来动态刷新,还是比较灵活的。
这里还要注意一点:
语言级别的缓冲区是在用户空间中的,文件内核缓冲区是在内核中的,两者的层级是不同的!!!
那既然语言级别缓冲区的都有fflush函数来强制刷新缓冲区中的数据,文件内核缓冲区当然也是有的:

这个函数就叫做fsync,这个函数就可以强制刷新文件内核缓冲区的数据到磁盘文件中。
我们之前总是介绍stdin或者stdout,也就是标准输入流和标准输出流,今天我们来简单认识一下,下面我们先从现象入手,先来带大家看看stderr有什么不一样的:


同样是执行和上面一样的重定向操作,为什么这里就能打印出内容呢?
我们只记得1号标识符指向显示器文件,但何曾记得2号标识符同样也指向显示器文件呢?
我们通过重定向的操作只是修改了1号标识符的指向,但是2号标识符并没有被改变,依旧是指向显示器文件的,所以当第一步的操作执行后,写入到显示器文件的就是perror和cerr的内容。
我们通过上面的例子其实做到了正常输出和错误输出的分离,这也就是提供perror和cerr的原因!!!
当然上面的例子还不太明显,我们再来修改一下:

这次我们将1号标识符和2号标识符都进行重定向,正确的信息输入到ok.txt文件中,错误的信息输入到err.txt文件中,打印后就可以看到相应的内容。
这种操作在我们工作中也很常用,比如:你现在负责一个项目,有时候程序报错了,你总要知道是哪里出错了?出的什么错?
而通过上面的方式,在每个模块中如果出现问题,就用perror或者cerr打印出错误信息,而在最后执行的时候只需要进行上面的操作,我们就能把错误信息全部输入到一个文件中,这样一旦程序出问题,我们直接查看相应的错误信息文件就知道哪里出问题了,出的什么问题。
而上面是省略一点的写法,完整写法应该是这样:

而我们要想将所有的信息都输入到一个文件中也是可以做到的:

我们只需要对2号标识符进行重定向即可,将2号标识符重定向到1号,而1号此时已经重定向到log.txt文件了,所以2号此时也会指向log.txt文件,写法就如上图所示,这里就不过多介绍了。
以上就是缓冲区:系统大厨的“万能传菜员”与它的效率魔法的全部内容。