先来看看下面的代码:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
// C语言文件接口
printf("hello lirendada:print\n");
fprintf(stdout, "hello lirendada:fprint\n");
const char* fputsStr = "hello lirendada:fputs\n";
fputs(fputsStr, stdout);
// 系统接口
const char* str = "hello write\n";
write(stdout->_fileno, str, strlen(str));
return 0;
}
// 调用结果:
[liren@VM-8-2-centos buffer]$ ./myfile
hello lirendada:print
hello lirendada:fprint
hello lirendada:fputs
hello write
[liren@VM-8-2-centos buffer]$ ./myfile > log.txt
[liren@VM-8-2-centos buffer]$ cat < log.txt
hello write
hello lirendada:print
hello lirendada:fprint
hello lirendada:fputs
[liren@VM-8-2-centos buffer]$
目前打印结果和重定向到文件中都是按我们的预期执行的,接下来我们在代码的最后 fork()
一下,看看发生什么:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
// C语言文件接口
printf("hello lirendada:print\n");
fprintf(stdout, "hello lirendada:fprint\n");
const char* fputsStr = "hello lirendada:fputs\n";
fputs(fputsStr, stdout);
// 系统接口
const char* str = "hello write\n";
write(stdout->_fileno, str, strlen(str));
// 只调用fork,后面什么都不做
fork();
return 0;
}
// 调用结果:
[liren@VM-8-2-centos buffer]$ ./myfile
hello lirendada:print
hello lirendada:fprint
hello lirendada:fputs
hello write
[liren@VM-8-2-centos buffer]$ ./myfile > log.txt
[liren@VM-8-2-centos buffer]$ cat < log.txt
hello write
hello lirendada:print
hello lirendada:fprint
hello lirendada:fputs
hello lirendada:print
hello lirendada:fprint
hello lirendada:fputs
[liren@VM-8-2-centos buffer]$
嘶,是不是很奇怪,为什么 fork
之后,向文件里面输出之后居然多了一倍的数据,我们 fork
明明是在输出完之后执行的啊,并且就算是都打印了两倍,那为什么 hello write
只被输出了一次 ❓❓❓
其实这一切都和 缓冲区
以及 frok
有关系,下面我们就来正式介绍一下缓冲区!
首先我们要知道,缓冲区的本质就是一段用作缓冲的内存,下面我们举个例子来解释一下为什么要有缓冲区!
我们在生活中总是需要去买东西或者寄东西,这也是为什么快递会存在的原因,因为如果我们自己想把东西送给远方的朋友,我们需要自己出门去送,这样子来回既消耗时间,并且过程中我们也不能做很多的事情,而对方也可能需要一直等待接受你的到来的时刻,那么这样子双方一个在传递,一个在等待,效率不高,并且我们传递了过去之后,还得返回来,这样子就走了双倍的路程~
为了提高效率,就出现了快递行业,我们只需要将要送出去的东西交给快递人员,我们就基本把发送东西的事情解决了,而接收方也只需要等到快递人员去通知即可,不需要一直关注着。除此之外,快递行业不是说只运你这个包裹,那么这和自己来回送东西有什么区别对不对!所以快递站一般都是等到快递的量达到一定程度的时候才一起发送过去,本质上就是增加传输量,来降低传输次数以此提高效率,缓冲区也是同样的道理!
简单地说,缓冲区的意义就是为了节省进程进行 IO
的次数,提高效率!而对于我们来说 write
函数虽说是写入文件函数,但是 本质上这些文件函数都是拷贝函数,将进程中的内容拷贝到缓冲区内存段中!
为什么会有刷新策略呢?试想一下,如果有一块数据,第一种方法是一次性将其写到外设,第二种方法是分为多次,每次少批量的写入外设,这两种方法的效率对于 IO
来讲肯定是第一种方法效率要高!
但是我们并不是每次都想要一次性写入外设中,比如说如果我们是一台大型服务器,里面存储着很多信息,那么如果突然断电了,保存在缓冲区中的数据就全没了,为了避免这种情况,一般我们对于这种专门用于存储大型数据的机器采用的都是直接刷新,所以就有不同的刷新策略,下面介绍不同的刷新策略:
这种情况出现的比较很少,比如调用 printf()
后,我们手动调用 fflush()
刷新缓冲区。
显示器需要满足人的阅读习惯,故采用行刷新的策略而不是全缓冲的策略。
通常来说行刷新都是以 \n
为标志的!
虽然全缓冲的刷新方式,可以大大降低数据 IO
的次数,节省时间。但若数据暂存于缓冲区,等缓冲区满后再刷出,当人阅读时面对屏幕中出现的一大堆数据,很难不懵逼。所以显示器采用行刷新的策略,既保证了人的阅读习惯,又使得数据 IO
效率不至于太低。
对于存储在磁盘中的文件,比如说我们要向文件中写入数据或者读取数据,一般都是等到缓冲区满了才会刷新出来!
就像我们上面说的那种情况,我们如果有用于存储信息的机器,为了防止断电后信息丢失,我们必须采用强制刷新,虽然说效率变低,但是为了数据不丢失,这是值得的!或者说进程退出的时候,该进程块被回收,那么其缓冲区就得被释放出来,就会直接刷新!
一般我们强制刷新的话要调用 fsync()
这个函数去强制刷新到磁盘!
首先我们先来确定一个问题,就是上面那个问题引入,一定是和缓冲区有关的,但是缓冲区到底在哪里呢 ❓❓❓
我们没办法一下子得知缓冲区在哪里,但是我们可以排除的是**缓冲区一定不在内核中**!因为如果在内核中的话,那么之前那段代码中的 write
也肯定会被打印两次,但是实际上并没有,也就可以排除在内核中的情况!
其实我们所说的缓冲区 指的是用户级语言层面给我们提供的缓冲区(其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内),而 这个缓冲区其实就存在 FILE
结构体中,其中 FILE
中有段代码为 typedef struct _IO_FILE FILE
(在/usr/include/stdio.h),而 _IO_FILE
的具体实现在 libio.h
中,下面我们打开 /usr/include/libio.h
的源码查看一下对应内容:
我把代码再拿出来方便看:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
// C语言文件接口
printf("hello lirendada:print\n");
fprintf(stdout, "hello lirendada:fprint\n");
const char* fputsStr = "hello lirendada:fputs\n";
fputs(fputsStr, stdout);
// 系统接口
const char* str = "hello write\n";
write(stdout->_fileno, str, strlen(str));
// 只调用fork,后面什么都不做
fork();
return 0;
}
4
条打印信息,因为 stdout
默认是行刷新,在进程 fork
之前,3
条C语言函数已经将数据进行打印输出到显示器上了,所以此时 FILE
结构体内部以及进程内部就不存在对应的数据了!3
条C语言函数虽然带了 \n
,但是不足以让将缓冲区写满,所以数据并没有被刷新出来! fork
的时候,stdout
属于父进程,创建子进程时,紧接着就是进程退出!谁先退出,一定要就要进行缓冲区刷新!又因为子进程和父进程中的数据是独立的,就算子进程刷新了缓冲区,这也是通过写时拷贝新开辟的空间,并不会影响父进程,所以数据最后才会显示两份!!!write
为什么不会被刷新两份呢?上面的过程与系统调用 write
无关,因为 write
不是 FILE
所管理的,而使用的是系统的 fd
,当然就没有C语言提供的缓冲区(内核也有缓冲区,下面会讲)!也就是说使用 write
等系统 IO
接口,函数直接输出到输出设备上,是不带缓冲;但是 标准 IO
库是带有缓冲的,比如 printf
遇到 \n
的时候才会冲刷缓冲区,输出到输出设备上。 下面我们来通过调用系统接口实现的C语言库接口,主要是用于理解系统调用接口和缓冲区。通过代码我们就能理解到,缓冲区是实则是结构体文件(FILE
)中的一段内存,是通过文件标识符链接的:缓冲区通过文件标识符链接打开文件,然后再将缓冲区数据拷贝到文件中。
下面就将代码展示,不理解的代码中有非常详细的注释。
myStdio.h
接口定义:
#pragma once
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <assert.h>
#include <stdlib.h>
#define MAX_SIZE 1024 // 缓冲区的最大个数
#define SYNC_NOW 1 // 直接刷新
#define SYNC_LINE 2 // 行缓冲
#define SYNC_FULL 4 // 全缓冲
typedef struct _FILE
{
int flags; // 缓冲方式
int fileno; // 文件描述符
char buffer[MAX_SIZE]; // 缓冲区
int size; // buffer的有效个数
int cap; // buffer的总容量
}_FILE;
_FILE* _fopen(const char* path_name, const char* mode);
void _fwrite(_FILE* fp, const void* ptr, int num);
void _fclose(_FILE* fp);
void _fflush(_FILE* fp); // 强制刷新函数
myStdio.c
接口实现:
#include "myStdio.h"
_FILE* _fopen(const char* path_name, const char* mode)
{
int flag = 0; // 标记打开方式
if(strcmp(mode, "r") == 0)
{
flag |= O_RDONLY;
}
else if(strcmp(mode, "w") == 0)
{
flag |= (O_WRONLY | O_CREAT | O_TRUNC);
}
else if(strcmp(mode, "a") == 0)
{
flag |= (O_WRONLY | O_CREAT | O_APPEND);
}
int fd = 0;
int defaultMode = 0666;
// 选择打开方式
if(flag & O_RDONLY)
fd = open(path_name, flag);
else
fd = open(path_name, flag, defaultMode);
if(fd < 0)
{
// 写入错误信息
const char* err = strerror(errno);
write(2, err, strlen(err));
// 返回null,这也就是为什么我们自己调用fopen的时候失败返回null
return NULL;
}
// 为结构体开辟空间
_FILE* fp = (_FILE*)malloc(sizeof(_FILE));
assert(fp);
fp->flags = SYNC_LINE; // 默认设置为行刷新
fp->fileno = fd;
fp->cap = MAX_SIZE;
fp->size = 0;
memset(fp->buffer, 0, MAX_SIZE); // 将buffer设为0
return fp; // 这就是为什么打开文件就返回一个文件指针
}
void _fwrite(_FILE* fp, const void* ptr, int num)
{
// 1、将数据写到缓冲区内,而不是写到操作系统内
// 这里不考虑缓冲区溢出的问题
memcpy(fp->buffer + fp->size, ptr, num);
fp->size += num;
// 2、判断是否需要刷新
if(fp->flags & SYNC_NOW)
{
_fflush(fp);
}
else if(fp->flags & SYNC_LINE)
{
if(fp->buffer[fp->size - 1] == '\n') // 判断最后一个字符为换行则刷新
{
_fflush(fp);
}
}
else if(fp->flags & SYNC_FULL)
{
if(fp->size == fp->cap) // 满了则刷新
{
_fflush(fp);
}
}
}
void _fflush(_FILE* fp)
{
if(fp->size > 0)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; // 记得清空有效个数
// 需要全刷新的话执行下面这个函数
// fsync(fp->fileno);
}
}
void _fclose(_FILE* fp)
{
// 刷新缓冲区,并关闭
_fflush(fp);
close(fp->fileno);
}
main.c
主函数:
#include "myStdio.h"
#include <stdio.h> // 为了打印测试
int main ()
{
_FILE* fp = _fopen("./log.txt","w"); // 传入路径名和刷新模式
if(fp == NULL)
return 1;
int cnt = 10;
const char *msg = "lirendada!\n";
while(1) // 循环方便监视查看
{
_fwrite(fp, msg, strlen(msg));
//fflush_(fp);
sleep(1);
printf("count: %d\n", cnt);
cnt--;
if(cnt == 0) break;
}
_fclose(fp);
return 0;
}
另外为了方便查看文件与进程退出的关系,我们写一段脚本:
while :; do cat log.txt ; sleep 1 ;echo "##########################" ; done
具体结果自行运行观察!
学习了缓冲区,我们就明白了 数据是不能直接就拷贝到磁盘的,而是 struct file --> *files --> 文件描述符 --> 内核缓冲区 --> 刷新缓冲区 --> 磁盘
。内核缓冲区的刷新并不遵循用户级的刷新策略,由操作系统自主决定,例如内存不足等原因均会影响操作系统的刷新。这个就跟我们上述代码中的行缓冲,全缓冲是不一样的。上述是C语言应用层方面自己封装的 FILE
,这里是操作系统层从缓冲区刷新到磁盘中是非常复杂的。
特别需要理解的 库级别
的缓冲区和 系统级别
的缓冲区不是一个概念,库级别是 FILE
中的一段内存,系统级别则是更加复杂的处理方式。比如说如果操作系统突然挂了,那么内核缓冲区中的数据将会丢失。但如果是银行这种对数据安全敏感的行业呢?这个时候就要使用 fsync
强制操作系统将内核缓冲区中该文件的数据立即刷新至存储设备。
#include <unistd.h>
int fsync(int fd);