【Linux初阶】基础IO - FILE结构体中的缓冲区

hello,各位读者大大们你们好呀
系列专栏:【Linux初阶】
✒️✒️本篇内容:库函数和系统函数的刷新差异,缓冲区的意义、刷新策略、存在位置、文件写入的逻辑,缓冲区的代码实现,缓冲区和OS的关系
作者简介:计算机海洋的新进船长一枚,请多多指教( •̀֊•́ ) ̖́-


文章目录

  • 一、为什么我的库函数输出两次,系统函数只输出一次?
  • 二、理解缓冲区问题
    • 1.缓冲区存在的意义
    • 2.缓冲区的刷新策略
    • 3.缓冲区在哪里?
    • 4.文件写入的底层逻辑(1)
    • 5.回答库函数输出两次,系统函数输出一次的问题
  • 二、缓冲区的封装实现
    • 1.myStdio.h
    • 2.myStdio.c
    • 3.main.c
    • 4.Makefile
  • 三、理解缓冲区和OS的关系
    • 1.文件写入的底层逻辑(2)
    • 2.OS宕机对缓冲区的影响
  • 结语


一、为什么我的库函数输出两次,系统函数只输出一次?

在正式学习缓冲区知识前,我们用一个代码问题作为引入

#include 
#include 
int main()
{
	const char* msg0 = "hello printf\n";
	const char* msg1 = "hello fwrite\n";
	const char* msg2 = "hello write\n";
	printf("%s", msg0);
	fwrite(msg1, strlen(msg0), 1, stdout);
	write(1, msg2, strlen(msg2));
	fork();
	return 0;
}

运行出结果:

hello printf
hello fwrite
hello write

但如果对进程实现输出重定向呢?./hello > file, 我们发现结果变成了:

hello write
hello printf
hello fwrite
hello printf
hello fwrite

我们发现 printf 和 fwrite (库函数)都输出了2次,而 write 只输出了一次(系统调用)。

假如我们将 fork()代码注释掉,再运行上面的重定向指令,我们可以发现它会正常将 write、printf、fwrite三个信息重定向到 file文件中,因此我们可以推断出问题肯定和fork有关!

要理解上面问题出现的原因,就要涉及我们接下来要学习的缓冲区的知识了。

———— 我是一条知识分割线 ————

二、理解缓冲区问题

1.缓冲区存在的意义

在这里我先给出一条结论:缓冲区实际上就是一段内存

那么缓冲区是什么?谁提供的?为什么要存在呢?

内存的进程要将数据写入到磁盘的文件,这个动作的实现,首先需要在内存中开辟一块缓冲区,进程将数据拷贝到我们的缓冲区中,然后进程对应的拷贝函数就直接返回了,返回之后,进程就可以继续向后执行自己的代码了。

在进程执行代码期间,缓冲区中的数据会定期的发送到磁盘的文件中。
【Linux初阶】基础IO - FILE结构体中的缓冲区_第1张图片

为什么进程不直接自己将数据写入到文件中呢?这是因为对于进程来说太消耗时间了,我们知道,进程写入磁盘文件的这个动作属于IO,涉及到外设,因此相对比较慢,如果进程自己执行这个动作,效率会很低。

至此,我们就上面的三个问题先做一个回答:

  • 缓冲区实际上就是一段内存
  • 缓冲区是用户级语言层面(语言库)给我们提供的(后面会将讲解);
  • 缓冲区的意义是什么呢?节省进程进行数据 IO的时间

那么问题又来了:你说我将信息拷贝给了缓冲区,我哪里有拷贝呢?

以 fwrite函数为例,与其我们将 fwrite理解为写入文件的函数,倒不如将 fwrite理解为拷贝函数!将数据从进程,拷贝到缓冲区或外设中。

———— 我是一条知识分割线 ————

2.缓冲区的刷新策略

假设我们有一块数据,我们是应该一次性写入到外设 还是 少量多次的写入到外设呢?

实际上,一次性写入到外设是效率最高的。在我们 IO的过程中,最耗费时间的并不是数据的拷贝时间,以 1秒为例,有可能 990毫秒都在等待外设准备就绪,10毫秒完成拷贝。因此,对于计算机来说,一次性写入效率是最高的。

缓冲区一定会根据具体的设备,定制自己的刷新策略。一般而言,我们有三种策略两种特殊情况

三种策略

  1. 立即刷新 - 无缓冲(不常见);
  2. 行刷新 - 行缓冲(显示器就是使用行缓存);
  3. 缓冲区满 - 全缓冲(磁盘文件就是全缓冲);

以显示器为例,显示器采用行缓冲,因为显示器需要满足人的阅读需求(及时高效反馈),使用行缓存可以在满足人的需求的同时,保证缓冲效率不会太低。

以磁盘文件为例,IO时采用全缓冲,等待磁盘响应的次数最少,可以保障最大的效率。

两种特殊情况

  1. 用户强制刷新;
  2. 进程退出 - 一般都要进行缓冲区刷新;

为什么有些程序在没有强制刷新要求的情况下,数据迟迟不刷新到显示器上?很大可能就是这个程序的进程还没有退出,缓冲区的数据还没有刷新。

———— 我是一条知识分割线 ————

3.缓冲区在哪里?

再次回到我们一开始的那个代码问题的运行结果:

hello write
hello printf
hello fwrite
hello printf
hello fwrite

告知:上面这个结果的出现,一定和缓冲区有关。

那么这个缓冲区是谁提供的呢?

通过对我们一开始的那个代码运行结果的分析,我们可以得出,缓冲区不在内核中,因为如果缓冲区在内核中,write也应该要被打印两次。

结论:我们之前谈论的缓冲区,都是用户语言层面(语言库)给我们提供的缓冲区。C语言的缓冲区是C标准库提供的。

缓冲区在哪里?

在我们进行文件读写过程中,涉及到的调用接口(stdout、stdin、stderr),都是 *FILE 类型的,*FILE会被 FILE结构体管理,在这个结构体中,就包含了 fd(文件描述符) & 一个缓冲区

总结:这个缓冲区,在 stdout、stdin、stderr -> *FILE -> FILE结构体 -> fd & 一个缓冲区

因此,我们强制刷新时,需要使用 fflush(文件指针),关闭文件时,需要 fclose(文件指针)

———— 我是一条知识分割线 ————

4.文件写入的底层逻辑(1)

如果有兴趣,可以看看FILE结构体:

typedef struct _IO_FILE FILE;/usr/include/stdio.h
/ usr / include / libio.h
struct _IO_FILE {
	int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

 //缓冲区相关
 /* The following pointers correspond to the C++ streambuf protocol. */
 /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
	char* _IO_read_ptr; /* Current read pointer */
	char* _IO_read_end; /* End of get area. */
	char* _IO_read_base; /* Start of putback+get area. */
	char* _IO_write_base; /* Start of put area. */
	char* _IO_write_ptr; /* Current put pointer. */
	char* _IO_write_end; /* End of put area. */
	char* _IO_buf_base; /* Start of reserve area. */
	char* _IO_buf_end; /* End of reserve area. */
	/* The following fields are used to support backing up and undo. */
	char* _IO_save_base; /* Pointer to start of non-current get area. */
	char* _IO_backup_base; /* Pointer to first valid character of backup area */
	char* _IO_save_end; /* Pointer to end of non-current get area. */

	struct _IO_marker* _markers;
	struct _IO_FILE* _chain;

	int _fileno; //封装的文件描述符

#if 0
	int _blksize;

#else
	int _flags2;

#endif
	_IO_off_t _old_offset; /* This used to be _offset but it's too small. */

#define __HAVE_COLUMN /* temporary */
 /* 1+column number of pbase(); 0 is unknown. */
	unsigned short _cur_column;
	signed char _vtable_offset;
	char _shortbuf[1];

	/* char* _save_gptr; char* _save_egptr; */
	_IO_lock_t* _lock;

#ifdef _IO_USE_OLD_IO_FILE
};

通过观察,我们知道了 FILE* 指向地结构体内部包含缓冲区相关的读写接口 和 封装的文件描述符。

就此我们可以得出文件写入的底层逻辑:fprintf、fwrite等接口的数据,都会被写入我们自己写的 FILE* 类型指针所指向结构体内部的缓冲区中,由于结构体内部封装了 fd(文件描述符),所以我们的C语言(其他语言以此类推)会自动地帮我们在合适的时间将我们地数据刷新到外设里。

其中,文件的写入真的那么简单吗?实际上文件写入中间还有一些细节,我们会在 三、理解缓冲区和OS的关系 中进行进一步的挖掘。

———— 我是一条知识分割线 ————

5.回答库函数输出两次,系统函数输出一次的问题

通过对上面知识的理解,现在我们终于可以理解为什么会出现第一章中的代码问题了。

  1. 回到我们一开始的代码问题,stdout默认使用的是行刷新,在我们的代码 fork之前,由于 \n的存在,3条函数已经将数据都打印到显示器上(外设)了,此时我们的 FILE结构体内部,已经不存在对应的数据了。

  2. 此时如果我们再进行 >,写入的文件就不是显示器,而是普通文件,采用的是全缓冲策略,之前的3条函数虽然都带有 \n,但是不足以写满缓冲区,最终的数据并没有被我们刷新!

  3. 执行 fork的时候,stdout属于父进程,创建子进程后,紧接着就是退出。符合第二种进程缓冲的特殊情况:进程退出。最后,无论父子进程谁先退出,都需要刷新缓冲区,而缓冲的本质就是修改。因此会发生写时拷贝!!数据会显示两份。

  4. write为什么没有显示呢?因为上面的过程与 write无关,write为系统调用,没有 FILE结构体,而是直接使用 fd(文件描述符),没有C提供的缓冲区

#include 
#include 
int main()
{
	const char* msg0 = "hello printf\n";
	const char* msg1 = "hello fwrite\n";
	const char* msg2 = "hello write\n";
	
	//C接口
	printf("%s", msg0);
	fwrite(msg1, strlen(msg0), 1, stdout);
	
	//系统接口
	write(1, msg2, strlen(msg2));
	
	fork();
	return 0;
}
hello write
hello printf
hello fwrite
hello printf
hello fwrite

二、缓冲区的封装实现

创建 myStdio.h、myStdio.c、main.c、Makefile四个文件

1.myStdio.h

#include 
#include  //malloc申请堆空间
#include 
#include 
#include 
#include 
#include 
#include 

#define SIZE 1024 //缓冲区默认大小。OS实际上是动态申请的
#define SYNC_NOW    1 //立即同步刷新
#define SYNC_LINE   2 //行刷新
#define SYNC_FULL   4 //全刷新

typedef struct _FILE {
    int flags; //刷新方式
    int fileno; //文件描述符
    int cap; //buffer的总容量
    int size; //buffer当前的使用量
    char buffer[SIZE];
}FILE_;


FILE_* fopen_(const char* path_name, const char* mode);
void fwrite_(const void* ptr, int num, FILE_* fp); //写入源文件不可被修改,加const
void fclose_(FILE_* fp);
void fflush_(FILE_* fp);

2.myStdio.c

#include "myStdio.h"

FILE_* fopen_(const char* path_name, const char* mode)
{
    int flags = 0; //默认标记位
    int defaultMode = 0666; //默认起始权限

    if (strcmp(mode, "r") == 0) //字符串比较,根据不同的情况设置标志位
    {
        flags |= O_RDONLY;
    }
    else if (strcmp(mode, "w") == 0)
    {
        flags |= (O_WRONLY | O_CREAT | O_TRUNC);
    }
    else if (strcmp(mode, "a") == 0)
    {
        flags |= (O_WRONLY | O_CREAT | O_APPEND);
    }
    else
    {
        //TODO
    }
    int fd = 0;

    if (flags & O_RDONLY) fd = open(path_name, flags); //打开文件
    else fd = open(path_name, flags, defaultMode);

    if (fd < 0) //打开失败,向屏幕打印原因
    {
        const char* err = strerror(errno); //获取错误信息
        write(2, err, strlen(err));
        return NULL; // 打开文件失败会返回NULL
    }
    FILE_* fp = (FILE_*)malloc(sizeof(FILE_)); //申请堆空间
    assert(fp); //检查

    fp->flags = SYNC_LINE; //默认设置成为行刷新,这里的flags和上面open的flags不同
    fp->fileno = fd;
    fp->cap = SIZE;
    fp->size = 0;
    memset(fp->buffer, 0, SIZE); //将缓冲区清0

    return fp; // 这就是为什么你们打开一个文件,就会返回一个FILE *指针
}

void fwrite_(const void* ptr, int num, FILE_* fp)
{
    // 1. 写入到缓冲区中 - 这里我们不考虑缓冲区溢出的问题
    memcpy(fp->buffer + fp->size, ptr, num); //拷贝,拷贝目标、拷贝源、拷贝的字节数
    fp->size += num;

    // 2. 判断是否刷新
    if (fp->flags & SYNC_NOW) //判断刷新策略
    {
        write(fp->fileno, fp->buffer, fp->size);
        fp->size = 0; //清空缓冲区
    }
    else if (fp->flags & SYNC_FULL)
    {
        if (fp->size == fp->cap) //判断缓冲区满没满
        {
            write(fp->fileno, fp->buffer, fp->size);
            fp->size = 0;
        }
    }
    else if (fp->flags & SYNC_LINE)
    {
        if (fp->buffer[fp->size - 1] == '\n') // 有\n,则行刷新,abcd\nefg , 不考虑
        {
            write(fp->fileno, fp->buffer, fp->size);
            fp->size = 0;
        }
    }
    else {

    }
}

void fflush_(FILE_* fp)
{
    if (fp->size > 0) write(fp->fileno, fp->buffer, fp->size); //文件,文件内容,文件大小
    fsync(fp->fileno); //将数据,强制要求OS进行外设刷新!
    fp->size = 0;
}

void fclose_(FILE_* fp)
{
    fflush_(fp);
    close(fp->fileno);
}

3.main.c

#include "myStdio.h"
#include 

int main()
{
    FILE_* fp = fopen_("./log.txt", "w");
    if (fp == NULL)
    {
        return 1;
    }
    int cnt = 10;
    const char* msg = "hello world "; //1.没有带\n; 2.有带\n;
    while (1)
    {
        fwrite_(msg, strlen(msg), fp);
        //fflush_(fp); //写一条就刷新一条
        
        sleep(1);
        printf("count: %d\n", cnt); //count会在屏幕中打印,文件写入我们是看不到的
        //if(cnt == 5) fflush_(fp); //强制刷新实验
        
        cnt--;
        if (cnt == 0) break; //10秒过后,屏幕中才会打印,这是因为数据在缓冲区中,进程退出才刷新
    }                        // 如果写入的内容带有 \n,会进行行刷新,每秒刷新一次
    fclose_(fp);

    return 0;
}

4.Makefile

main:main.c myStdio.c
    gcc - o $@ $ ^ -std = c99
.PHONY:clean
clean :
    rm - f main

三、理解缓冲区和OS的关系

1.文件写入的底层逻辑(2)

  1. 缓冲区的内容不会直接写入到磁盘,它会将数据先写入到 struct file的内核缓冲区中,再由内核缓冲区刷新到磁盘(外设);
  2. 缓冲区分为语言层面的缓冲区操作系统层面的内核缓冲区
  3. 这个真正将数据从内存刷新到磁盘的动作,由 OS自主决定;
  4. 在操作系统看来,并不是像行、全这样简单的缓冲刷新。总之,操作系统会权衡自己的内存使用情况,来对数据做刷新

【Linux初阶】基础IO - FILE结构体中的缓冲区_第2张图片

文件是如何刷新到外设的呢?我们以C语言为例,先用C语言的接口,将数据拷贝到C语言的缓冲区里,C语言根据对应的缓冲策略,通过 struct file的接口,将数据拷贝到内核缓冲区中,然后再由操作系统定期刷到外设中

如果将操作系统定期刷到外设也理解为一次拷贝,那么文件刷新到外设的这个动作需要经历三次拷贝!它们分别为:内存->语言层缓冲区->内核缓冲区->外设

2.OS宕机对缓冲区的影响

OS宕机会对缓冲区刷新造成影响,会导致数据丢失。

那假如是一个对数据问题 0容忍的一个机构(银行),遇到这种问题该怎么办呢?我们不是无法决定内核缓冲区对磁盘(外设)的刷新策略吗?

不用担心,操作系统早就为我们想好啦!操作系统给用户层提供了将内核缓冲区的数据强制刷新到磁盘的接口,它就是 fsync。

fsync(fp->fileno); //将数据,强制要求OS进行外设刷新!

相信大家在上面二、缓冲区的封装实现的 fflush代码部分已经看过,这里就不对其应用再做赘述了。


结语

基础IO - FILE结构体中的缓冲区 的知识大概就讲到这里啦,博主后续会继续更新更多C++ 和 Linux的相关知识,干货满满,如果觉得博主写的还不错的话,希望各位小伙伴不要吝啬手中的三连哦!你们的支持是博主坚持创作的动力!

你可能感兴趣的:(Linux初阶,linux,运维,服务器)