一、实验现象
我们先来看一个现象:
在显示器中打印内容时,fprintf先打印出来,write后打印出来,这也是非常正常的现象,因为fprintf代码在write之前嘛,但是我们可以看到将1号下标重定向到log.txt,此时write函数的内容先进行打印了??这是什么原因??
下面我们再来看一个奇怪的现象:
上述实验中fprintf打印了两次,这必定与fork函数有关!!而向显示器中打印内容与向文件中打印内容fprintf与write打印的顺序不一致,fprintf函数是C库函数,而write是系统调用函数,我们初步猜想这必定与两者的内部实现有关系!!事实上这是由于两者的刷新策略不同而导致的,而刷新策略又跟缓冲区有联系,所以下面我们将一步引出缓冲区的概念对它进行着重分析。
二、初步认知缓冲区
实际上我们的数据并不是立马就被显示出来的,我们的C库以及操作系统都会提供一个缓冲区,数据先会被保存在这个缓冲区当中,然后结合一定的刷新策略数据才显示出来!!
缓冲区的本质其实就是充当一个内存暂存数据的地方!!
为什么要引入缓冲区这个概念?有什么意义?我们通过一个例子感性的理解一下:
假设你的朋友小库要生日了,你想在他生日当天送一个礼物给他,但是小库跟你不在同一个地方,小库在美国,而你在湖南。那么你将礼物交给他有两种方式,一种方式是你自己订机票飞往小库的地方将礼物交给他,另外一种方式是去快递点将礼物交给快递公司让它送给小库。那么你会选择哪种方式呢?我想大部分都应该会选择用快递的方式将礼物送给他吧。那么其实快递公司就充当了缓冲区的角色,快递公司会通过它的方式将礼物交给小库,不需要你亲自送给小库,这有什么好处呢?它节省了你的时间,你将礼物交给了快递公司,那么你就可以继续去干你自己的事情,剩下的事交给快递公司就可以了。那么在进程中假设我们调用了fprintf函数,它就会转而将数据拷贝到缓冲区中让缓冲区帮你实现功能,然后立马返回进程继续执行剩下的任务!!
所以为什么要有缓冲区呢?有什么意义?
提高了IO效率,节省了调用者的时间!!
2.1 缓冲区的刷新策略
引出缓冲区的概念之后,那么在缓冲区的数据是如何被使用的呢?这就需要采取一定的刷新策略了。
缓冲区的刷新策略
- 无缓冲:数据直接传送到目标设备,不经过缓冲区。这种方式的特点是数据传输速度快,但效率低下,容易产生垃圾数据。
- 行缓冲:数据存储在缓冲区中,当填满一行或遇到换行符时,就会将整行数据一起刷新到目标设备中。行缓冲的特点是占用空间小,效率较高,但无法立即显示输出(当未填满一行或未遇到换行符时会一直存留在缓冲区中,无法立即输出到目标设备中)。显示器采用的刷新策略。
- 全缓冲:数据存储在缓冲区中,缓冲区填满后,将整个缓冲区一次性刷新到目标设备中。全缓冲的特点是效率高,但需要占用大量的存储空间。普通文件采用的刷新策略。
- 特殊情况:
1、进程退出时需要刷新缓冲区:在进程退出时,操作系统会自动关闭该进程的所有文件,并刷新缓冲区,确保数据正确保存。
2、关闭文件(close)时需要刷新缓冲区:在关闭文件时,操作系统会自动刷新该文件的缓冲区,确保数据正确保存。
3、用户强制刷新缓冲区:有些操作系统提供了命令或函数可以手动刷新缓冲区,例如Unix/Linux中的sync命令或fsync函数,Windows中的FlushFileBuffers函数等。
上述我说的是基于用户层的缓冲区刷新策略,实际上操作系统的缓冲区刷新策略更为复杂,采用定时刷新、事件触发刷新、混合策略刷新等等,但是大致的思想肯定是一样的,下面我们重点讲解用户层缓冲区的刷新策略就可以了。
行缓冲是现阶段语言层面我们用的最多的一种刷新策略,通过找到\n将缓冲区中的数据刷新出来,这也就是我们通常在printf中带\n数据能立马将缓冲区中的数据刷新出来最终在显示器上看到的原因。有了这个概念,我们就能对printf带\n有了更进一步的了解了,实际上\n不仅仅是为了调整格式,而且能使我们保存在缓冲区的数据刷新出来,最终显示在显示器上!!
为什么显示器通常采用的是行缓冲?
这是为了用户有更好的体验,我们通常希望看到一次一次的现象,而不是把缓冲区填满之后再一次性的刷新缓冲区最终在显示器上只看到一次现象。
对于缓冲区的设计者来说,为了极大的提高IO效率,通常采用的是类似全缓冲的刷新策略,因为需要刷新的数据量非常大,数据将缓冲区填满之后再进行刷新。这样为什么能提高IO效率?
对于显示器文件来说,我们使用的是行缓冲,找到\n就刷新缓冲区,刷新缓冲区的本质就是进行写入,最终将数据写入到显示器文件中,而数据写入外设必定要遵循冯诺依曼体系,换句话说每刷新一次就进行一次IO;而对于全缓冲来说,我们将缓冲区填满之后才刷新一次,不发生刷新的本质,不进行写入,就是不进行IO,不进行调用系统调用,所以fwrite函数调用会非常快,数据会暂时保存在缓冲区中,可以在缓冲区中积压多份数据,最后再统一进行刷新写入,换句话说就是就是一次IO可以IO更多的数据。IO是需要花费时间的,我们通过全缓冲减少了IO的次数,不就是节省了时间,提高了效率吗!!
注:进行IO必定需要访问外设,调用库函数将数据拷贝到缓冲区都不IO,叫拷贝数据!!
2.2 缓冲区在哪里
在之前的实验现象中,我们看到1号下标重定向到log.txt文件后调用write与fprintf,此时在普通文件中打印内容采用的是全缓冲策略,那么fprintf比write先进行调用,但是write比fprintf先进行打印,write是系统调用接口,而fprintf是C库函数。由此说明缓冲区一定不在内核层,所以此时的缓冲区一定是在用户层,它是C库提供的,更具体的来说它在FILE结构体中!!
接下来我们回答一下之前出现的奇怪现象:
用户先调用fprintf函数,实际fprintf会调用fwrite函数将数据先写入缓冲区中,fwrite在这里就充当一个拷贝函数。发生重定向之后我们采用的全缓冲刷新策略,此时"hello fprintf"暂存在缓冲区中,而write它是系统调用接口它的数据根本不放在用户级缓冲区中,而是放在内核层缓冲区中,然后结合操作系统的刷新策略将数据写入到显示器文件,所以它会较fprintf先打印出来。在最后我们使用fork创建子进程,在进程退出前要刷新缓冲区,此时谁先刷新缓冲区就要发生写时拷贝,所以最终我们可以看到两份fprintf打印的内容!!
关于缓冲区的理解到现在还是不够的,我们后续讲完缓冲区的模拟实现之后还会再次进行谈论,到时候我们就能彻底明白缓冲区以及它的刷新策略了。
三、缓冲区模拟实现
下面是我们进行缓冲区的简单模拟实现代码:
mystdio.h
#pragma once
#include <stdio.h>
#define NUM 1024
#define BUFF_NONE 0x1
#define BUFF_LINE 0x2
#define BUFF_ALL 0x4
typedef struct _MY_FILE
{
int fd; // 文件描述符
int flags; // flush method, 刷新策略
char outputbuffer[NUM]; // 缓冲区
int current; // 当前指向缓冲区字符的位置
} MY_FILE;
MY_FILE *my_fopen(const char *path, const char *mode); // fopen函数
size_t my_fwrite(const void *ptr, size_t size, size_t nmemb,MY_FILE *stream); // fwrite函数
int my_fclose(MY_FILE *fp); // fclose函数
int my_fflush(MY_FILE *fp); // fflush函数
mystdio.c
#include "mystdio.h"
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <malloc.h>
#include <unistd.h>
#include <assert.h>
MY_FILE *my_fopen(const char *path, const char *mode)
{
//1. 识别标志位
int flag = 0;
if(strcmp(mode, "r") == 0) flag |= O_RDONLY;
else if(strcmp(mode, "w") == 0) flag |= (O_CREAT | O_WRONLY | O_TRUNC);
else if(strcmp(mode, "a") == 0) flag |= (O_CREAT | O_WRONLY | O_APPEND);
else {
//other operator...
//"r+", "w+", "a+"
}
//2. 尝试打开文件
mode_t m = 0666; // 文件权限
int fd = 0;
// 如果文件需要不存在需要创建,此时我们就需要设置文件权限
if(flag & O_CREAT) fd = open(path, flag, m);
else fd = open(path, flag);
if(fd < 0) return NULL;
//3. 给用户返回MY_FILE对象,需要先进行构建
MY_FILE *mf = (MY_FILE*)malloc(sizeof(MY_FILE));
// 如果创建结构体失败就关闭文件
if(mf == NULL)
{
close(fd);
return NULL;
}
//4. 初始化MY_FILE对象
mf->fd = fd;
mf->flags = 0;
mf->flags |= BUFF_LINE; // 默认为行缓冲刷新策略
memset(mf->outputbuffer, '\0',sizeof(mf->outputbuffer)); // 初始化缓冲区
//mf->outputbuffer[0] = 0; //初始化缓冲区
mf->current = 0; // 当前指向缓冲区字符的位置
//5. 返回打开的文件
return mf;
}
void my_fflush(MY_FILE *fp)
{
assert(fp);
//刷新的本质是将用户缓冲区中的数据,通过系统调用接口,冲刷给OS
write(fp->fd, fp->outputbuffer, fp->current);
fp->current = 0; // 刷新缓冲区之后将指向缓冲区字符的置为0 -- 起始位置
fsync(fp->fd); // 用户强制将缓冲区的数据刷新到外设中
}
size_t my_fwrite(const void *ptr, size_t size, size_t nmemb, MY_FILE *stream)
{
// 1. 缓冲区如果已经满了,就先刷新缓冲区
if(stream->current == NUM) my_fflush(stream);
// 2. 根据缓冲区剩余情况,进行数据拷贝即可
size_t user_size = size * nmemb; // 用户需要传入的字节大小
size_t my_size = NUM - stream->current; // 缓冲区剩余字节大小
size_t writen = 0;
// 如果缓冲区剩余字节大小大于等于用户传入的字节大小,此时将数据拷贝至current之后;反之则最多拷贝my_size字节到current后
if(my_size >= user_size)
{
memcpy(stream->outputbuffer+stream->current, ptr, user_size);
//3. 更新当前位置
stream->current += user_size;
writen = user_size;
}
else
{
memcpy(stream->outputbuffer+stream->current, ptr, my_size);
stream->current += my_size;
writen = my_size;
}
// 4. 开始计划刷新
// 全缓冲
if(stream->flags & BUFF_ALL)
{
if(stream->current == NUM) my_fflush(stream);
}
// 行缓冲
else if(stream->flags & BUFF_LINE)
{
if(stream->outputbuffer[stream->current-1] == '\n') my_fflush(stream);
}
// 无缓冲
else
{
//...
}
return writen;
}
void my_fclose(MY_FILE *fp)
{
assert(fp);
//1. 冲刷缓冲区
if(fp->current > 0) my_fflush(fp);
//2. 关闭文件
close(fp->fd);
//3. 释放堆空间
free(fp);
fp = NULL;
}
main.c
// main.c读者可以自行去进行测试
#include "mystdio.h"
#include <string.h>
#include <unistd.h>
#define MYFILE "log.txt"
int main()
{
MY_FILE *fp = my_fopen(MYFILE, "w");
if(fp == NULL) return 1;
const char *str = "hello my fwrite";
int cnt = 500;
//操作文件
while(cnt)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%s:%d", str, cnt--);
//snprintf(buffer, sizeof(buffer), "%s:%d\n", str, cnt--);
size_t size = my_fwrite(buffer, strlen(buffer), 1, fp);
sleep(1);
printf("当前成功写入: %lu个字节\n", size);
//my_fflush(fp);
}
my_fclose(fp);
return 0;
}
我们先来看看行缓冲区的现象:
当snprintf带\n时,我们发现数据一次一次的回显在log.txt文件中:
当snprintf不带\n时,此时默认还是行缓冲刷新,当行缓冲区未满一行或找不到\n时,此时数据在缓冲区中一直积压,直到此时进程结束该缓冲区刷新,此时一下在文件中一次就看到了缓冲区的所有数据:
如果我们一次一次的回显到文件中,我们可以通过我们自己实现的my_fflush强制刷新缓冲区:
四、再次全面理解缓冲区
有了上述缓冲区的模拟实现,我们对缓冲区的刷新策略有了更近一步的了解。
接下来我们谈谈用户进行IO的整个流程:
这里以用户使用C语言调用printf打印数据到显示器上为例,在用户层printf会先调用fwrite函数将数据拷贝到用户层缓冲区当中,然后结合一定的刷新策略->通过系统调用(write)将数据拷贝到内核层缓冲区当中,随后操作系统也同样的采取一定的刷新策略将数据最终写入磁盘,所以最终我们就能在显示器文件中看到我们打印的数据了。
我们平时在语言级别中提到的缓冲区其实都是用户层缓冲区,因为内核级缓冲区我们也见不到。
4.1 用户强制刷新缓冲区(fflush/fsync)
下面我想重点讲讲fflush库函数与fsync系统调用接口:
fflush()函数
fflush()函数是用来刷新标准I/O库的缓冲区的,其中包括用户层的缓冲区。当我们使用C语言中的输出函数(如printf())向文件中写入数据时,这些数据首先被写入用户层的缓冲区中,而不是直接写入磁盘中。只有在缓冲区已满、文件关闭或调用fflush()函数时,才会将缓冲区的数据刷新到内核缓冲区中。那么如果此时我的刷新策略不符合第一二点,此时就使用fflush()函数将数据强制刷新到内核缓冲区中。刷新缓冲区的本质是什么?将用户缓冲区的数据通过系统调用(write)冲刷给操作系统,那么实际上fflush函数强制刷新缓冲区在底层就是通过调用系统调用(write)来完成这项工作的!!
结论:fflush()函数并不会直接将数据写入磁盘,而是将数据写入内核缓冲区中,由操作系统决定何时将数据写入磁盘。
fsync函数
fsync()函数会强制将一个文件的所有缓冲区数据和元数据(如文件大小、修改时间等)都写入磁盘中,包括文件的用户层缓冲区和内核缓冲区。当fsync()函数返回时,表示数据已经写入磁盘并且已经被持久化保存。这个函数在需要确保数据被持久化保存的场景中非常有用,比如在操作系统崩溃或断电时,而内存具有断点易失的性质,一旦断电文件中所有的数据就丢失了。但是,由于磁盘I/O的性能非常低,所以fsync()函数的执行速度也较慢,尤其是在写入大量数据时。因此,如果需要频繁调用fsync()函数来写入小量的数据,可能会对性能造成一定的影响。一个很常见的例子:我们在CSDN写博客时,我们边写它会边自动保存,实际它就是调用了fsync函数使我们的数据被持久化保存,防止我们突然断网断电导致数据丢失的问题,而且我们也知道有时候这种自动保存的方式是有点卡顿的,这是很常见的问题!!
值得注意的是,对于某些文件系统(如ext3、ext4等),即使使用fsync()函数,操作系统仍然可能会将数据先写入到内存中,然后再在后台将数据写入磁盘中。这种行为被称为延迟写(delayed write)或异步写(asynchronous write),可以提高磁盘I/O的性能。因此,在使用fsync()函数时,不能保证数据一定已经写入磁盘中,只能保证已经被提交到操作系统,并由操作系统尽快写入磁盘。
结论:fsync()函数用于确保数据被持久化保存。
本篇文章的内容就讲到这里了,关于本文如果有任何疑问或者错处欢迎大家评论区相互交流orz~🙈🙈