用户缓冲区
先介绍一下关于用户缓冲区的周边知识。
fread和fwrite的返回值
谈一谈fread和fwrite的返回值,如果写入/读取文件成功,fread或fwrite的返回值指的是实际写入/读取的内存块数量(实际的nmemb的大小)。假如fwrite写入的size是5字节,写的内存块数量为8,全部写入成功,fwrite的返回值就是8。假如fwrite写入的size是5字节,写的内存块数量为8,写入成功的内存块数量为5,fwrite的返回值就是5。
缓冲区刷新
下面我写一段代码,分别用不同的库函数和系统调用在显示器上输出一些内容。然后再将在显示器的内容重定向到文件中。
下面我微调一下代码。
上图代码在文件操作调用接口后加了一句fork(),创建了一个子进程。为什么将输出到显示器文件的内容重定向到普通文件lg.txt。为什么输出结果是这样的呢?带着疑问继续接着向下看。
上图代码中,在文件操作调用后关闭了1号fd,此时只有系统调用接口write写的内容成功写入了lg.txt文件中。这是为什么呢?首先,可以肯定的是C语言提供了用户级别的缓冲区将C接口对应写入1号文件的重定向内容给缓存了。但是1号fd已经被close掉了,这些内容还没来得及通过write写到系统文件缓冲区。所以,我们在上层也就没有看到lg.txt有C接口输出重定向的内容。而write接口是系统调用,它会直接将内容写入内核的文件缓冲区内,当1号fd close后,文件缓冲区的内容就已经刷新到了磁盘普通文件上了。而close是系统调用接口,它并不知道它的上层还有用户级别的缓冲区,所以它不等C接口调用write将数据写入内核缓冲区,就已经把文件关掉了,所以,普通文件lg.txt上没有C接口写入的输出重定向内容。在前面进程退出时,谈了exit和_exit。其中_exit调用,printf输出内容没法显示,这是因为_exit是系统调用,它不关心你上层用户级的缓冲区的存在,不等你将内容写入内核缓冲区就把文件描述符给关闭了。而exit是C语言提供的接口,它不仅会调用_exit退出之前调用fflush方法将用户级缓冲取得内容刷新到内核文件缓冲区里,所以exit调用前printf会正常输出内容。
下面我将所有字符串的’\n’去掉,请看看发生的现象。
通过上图代码和结果可以发现,不仅lg.txt文件上没有输出C接口的写入结果,居然连显示器都没有输出对应的写入结果。为什么显示器上没有输出结果呢?因为显示器是行刷新的刷新策略。即当printf执行写入时,若遇到’\n’就会立刻将数据刷新到内核缓冲区,然后刷新到磁盘上的显示器文件,显示器上也就输出对应的字符串信息。
目前暂且认为当数据到达内核缓冲区后,就可以直达硬件。有了这个认知接下来继续谈一谈上层(语言层)。当用户使用fprintf、fputs、fwrite等接口时,数据会先被写入C语言维护的用户级缓冲区中。在合适的时机,调用系统调用write写入到内核缓冲区中。
通过上面的样例,可以提炼出用户刷新缓冲区的行为本质就是调用系统调用接口write将用户缓冲区数据写入内核缓冲区。就用户层缓冲区刷新这一话题再谈一谈周边的问题。如刷新的方式分为三种,如无缓冲刷新、行缓冲刷新以及全缓冲刷新。无缓冲刷新即上层写入数据,写入完成后直接将用户缓冲区的内容写入内核缓冲区。 行缓冲刷新即在写入换行符’\n’缓冲区才会刷新,否则不刷新。全缓冲刷新即当缓冲区内空间写满了才去刷新。
显示器文件的刷新就是行刷新的经典应用,为什么printf输出的字符串带’\n’哪怕1号fd被close也能输出出来字符串,是因为行缓冲区刷新的策略。它会在执行完printf这一行代码就将C缓冲区内容刷新到内核缓冲区。
而普通文件的刷新策略是全缓冲刷新,那为什么printf()不带’\n’也能进行刷新呢?因为进程在退出时,会刷新缓冲区。但是若在printf()不带’\n’情况下,在下面的代码关闭了1号fd,相应的显示器上就不会输出printf的内容,因为在进程退出时,1号fd被关闭,所以无法使用write往内核写入,显示器文件也就不会收到数据。全缓冲刷新策略从OS的角度出发,是效率最高的刷新侧率。这就好比,在我们网购的时候,通常卖家都是会在每天的傍晚和早上发货。这是因为,间隔半天时间,将两次发货时间间隔内所有买家的包裹统一交给快递公司的专员。这样对卖家和快递公司双方的时间效率方面是最优的。对于买家来说,则是牺牲了一定的时间,但是这并不妨碍网购的便捷。全缓冲刷新侧率就是站在OS的视角上效率最高的方式。
为什么需要C语言需要提供缓冲区
最核心的一点就是提高用户层的效率。如果不提供缓冲区来缓存用户层写入的数据,直接将用户层的数据采取无缓冲侧率直接写入操作系统内核中,系统调用将高频次被调用。但是,操作系统中同一时刻跑着许许多多的进程,而每一个进程都会有读写数据的操作,。这势必导致操作系统的整体运行效率不佳,并且高频次的系统调用还会影响内核运行的稳定性。同时,用户层的进程执行代码时还需要等待每一次读写操作,这也导致进程的运行从逻辑层面看是串行的,整体的运行效率也不佳。而C语言提供的缓冲区很好的解决了进程运行从逻辑上看是串行的问题。上层的接口,将读写的数据先交给到语言级别的缓冲区中,然后继续执行自己的代码。此时进程运行的逻辑就变成了并行,效率较高。对于操作系统而言,它只需要定期以某种刷新策略,将语言级别缓冲区的内容通过系统调用将写入到内核,或是从内核缓冲区用系统调用读入语言级别缓冲区。这样操作系统的运行效率也会提高。
缓冲区还配合了printf、fprintf这个类C语言接口进行可对数据的格式化操作。将** 数据格式化后提高了与内核缓冲区数据交换的效率和数据交换的灵活性**。
C语言提供的缓冲区在哪里呢?
C语言提供的文件缓冲区在我们前面学习fd时谈到的FILE结构体里。这也是为什么我们在使用C语言提供的文件操作接口如fflush、fprintf时,需要传的是FILE*的指针。以fprintf为例,当我们要在显示器上输出内容时,我们传的是stdout,而stdout本质是一个FILE对象的指针,它指向的地址内部就有维护的缓冲区空间和缓冲区信息字段。第二个参数接收的是格式化字符串的指针,fprintf的函数内一定会将格式化字符串的内容写入到stdout内部的缓冲区中,并填写对应的缓冲区信息字段。然后,根据上层指定的不同刷新策略将FILE结构体管理的缓冲区内的数据通过系统调用写入到磁盘对应的文件上。
这个C语言的缓冲区具体有多少个呢?答案是我们打开多少个文件,他就有多少个。因为每一个打开的文件都会创建对应的FILE对象,每个FILE对象不仅需要维护不同的fd,还需要维护不同的缓冲区和缓冲区信息字段。 所以FILE是属于用户层的,它不属于系统层。当我们调用fopen这类的C接口时,系统层面上它会调用系统调用open从磁盘上以特定选项打开一个文件。然后,语言层面上fopen会malloc创建FILE对象,其中包含fd、缓冲区这类的成员。最后返回给用户层的是FILE*。
解释一下上面的问题
为什么直接运行程序时,打印顺序是正常的代码顺序呢?因为C语言将写入显示器数据的刷新策略定位行刷新,即碰到’\n’就刷新。所以,代码以正常的顺序打印内容。
下面再解释一下为什么进行重定向后文件内的内容如图例。首先,系统调用接口的数据被提前写入到lg.txt中,因为调用write在调用时会让数据从内核缓冲区刷新到磁盘上。C接口如何在系统调用后写入文件呢?因为往文件写入数据,C语言缓冲区采取的缓冲刷新策略是全缓冲刷新的方式,即将缓冲去打满时,才会调用write将缓冲区内的数据写入内核,再由OS将内核缓冲区数据写入磁盘。显然这几句代码还不至于打爆缓冲区。刷新到内核是因为进程退出了。至于为什么写入两份,是因为fork创建了子进程。而父进程退出时,将C语言缓冲区的数据写入内核,此时缓冲区进行了读写操作,触发了子进程的写实拷贝机制。此时,子进程将父进程的代码和数据各自拷贝私有一份。当子进程退出时,又将它对应缓冲区的内容刷新到了内核。所以,C接口的内容又刷新了一遍。这也是上面实验现象的原理。
模拟实现C文件库
模拟实现
模拟实现一份Cstdio库主要对于前面所学知识的一个提炼,本意是为了更好地学习,相应的模拟实现的库也比较简陋。下面就以一份demo代码来介绍一下需要模拟实现的结构以及相应的接口。
需要实现的接口有_fopen、_fwrite、_fflush以及_fclose。下面就采用声明实现分离的方式来模拟实现stdio库。首先就是需要创建一个.h文件和一个.c文件。紧接着,在.h文件中进行防止重复包含头文件的预处理。再定义一个文件结构体,别的先不管,对应的文件描述符字段是一定要有的。再声明一下上面的四个接口。
然后,在.c文件中对声明的接口进行实现。先实现模拟实现一下fopen接口。fopen其实是对系统调用open进行了封装。所以,使用open前需要包含系统调用open所需的头文件。
接下来第一步,使用系统调用open打开文件,这里模拟实现,仅仅实现"w"、“a"以及"r"这三种打开模式。需要定义两个整型变量。一个用于传递对应的打开方法,一个用于获取open返回的文件描述符。先通过_fopen的第二个参数flag来进行一个判断,若传入的参数为"w”,说明是以写方式打开,open对应需要参入的对应的flags为O_CREAT| O_WRONLY | O_TRUNC。若传入的参数为"a",说明是以追加写方式打开,open对应需要参入的对应的flags为O_CREAT | O_WRONLY | O_APPEND。若传入的参数为"r",说明是以读方式打开,open对应需要参入的对应的flags为O_RDONLY。紧接着对打开失败做一下特殊处理。
第二步就是使用malloc开辟动态内存,用动态内存创建_FILE结构体成员指针,并使用open返回的fd来初始化对应文件结构体的fileno字段。最后将创建_FILE结构体成员指针返回上层。
模拟实现了fopen,下面再实现一下fclose。fclose的实现思路如下,使用系统调用close将文件结构体指针的fileno字段所对应的文件描述符关闭,然后free释放掉文件结构体指针指向的堆区空间。
模拟实现fwrite实现思路就是通过调用系统调用write完成对文件的写入。
接下来对上面实现的接口进行一个简单的测试。
下面以追加写的方式再验证一下。
上面我在linux系统下以linux系统提供的系统调用接口封装了一套类似于Cstdio库。可以窥探触C语言代码是如何做到跨平台的。跨平台是根据条件编译裁剪出不同系统下的对应系统调用接口代码。若是fopen跑在windows环境下,底层就是使用了windows提供的系统调用。若是fopen跑在macOS环境下,底层就是使用了macOS提供的系统调用。这也是为什么跨平台项目的代码复用性需要有保证。
上面模拟实现部分,将文件操作的基本功能搭建出来了。下面引入语言级别的缓冲区,语言级别的缓冲区的知识覆盖量之广我无法一一讲解,所以就是以一个捡漏demo的方式进行一个模拟实现。然后谈一谈与它相关的周边知识。
首先,为了简单模拟就以数组模拟的方式来模拟实现缓冲区。
这里插一嘴题外话,为什么常说键盘显示器是字符设备?因为我们使用scanf从键盘读取到的内容都是字符,虽然在上层看了是键盘获取到的字符写入到整型变量中,那是因为scanf在格式控制上动了手。归根结底键盘还是字符设备,将它读取到的内容转化成整型势必需要借助缓冲区,而从缓冲区读取数据的格式决定了数据被上层以哪种类型拿走。
下面就在代码中引入缓冲区以及对应的刷新策略,使用数组模拟缓冲区,并且定义了三种刷新策略。
下面调整一下对应的接口。
修改一下fwrite接口的处理逻辑,将数据先缓存到缓冲区中。模拟不同的刷新策略进行对数据的操作。这里由于使用后静态数组模拟的原因,没有对异常情况做处理。以及对于行刷新策略的完善程度还有待考究,这里先通过实验简单看一看我们模拟实现的缓冲区。
分别以行刷新和全刷新来看。
当刷新策略设置成全缓冲刷新时,进程退出后,缓冲区的内容并没有如愿刷新到文件中。这是因为,我们在关闭文件描述符时强制刷新缓冲区内容。下面就模拟实现_fflush接口来进一步完善一下代码。_fflush的实现思路就是将缓冲区内的数据通过write系统调用写入到对应文件中。
缓冲区的意义
为什么需要缓冲区呢?本质上是解决效率层面上的意义。假如上层用户每调用一次fwrite这样的接口都需要调用底层的系统调用时,势必造成系统资源极大程度的浪费。缓冲区的作用是囤积一批数据,在某些条件成立时,再通过系统调用接口进行缴费,这样减少系统调用的调用次数,可以有效的提高系统运行的效率。这就好比快递驿站每收到一个快递都直接将快递送往区域的统一收集派发站点,这样运行效率肯定非常低下。若是,在早晚时间段囤积的包裹统一送往区域的统一收集派发站点,这样一两趟就送完一天的快递,效率上肯定是更加好了。通过模拟实现缓冲区,可以比较直观感受到这一结论,这也是为什么上面花了一些篇幅来进行代码和实验的描述。