1. 文件缓冲区的引出
如上现象,在学习完文件缓冲区之后即可解释
2. 认识缓冲区
缓冲区的本质就是内存当中的一部分,那么是谁向内存申请的? 是属于谁的? 为什么要存在缓冲区呢?
道理是如此,在之前的学习过程中我们也确实知道操作系统中存在缓冲区的概念,但是我们在编写代码时,并未执行将数据拷贝到缓冲区的代码呀,操作系统是怎么将数据拷贝到缓冲区的呢?
– 与其将fwrite函数理解成是写入到文件当中的函数,不如将其理解为fwrite是拷贝函数,将数据从进程拷贝到"缓冲区"或者外设当中
3. 缓冲区的刷新策略
如果此时存在一些数据需要写入到外设(磁盘文件)当中,是一次性写入效率高还是多次少量写入效率高?
答:当然是一次性写入效率高,数据写入到外设需要对外设进行请求,对于CPU来说写入是非常高效的,但是对于外设响应CPU请求非常低效,所以一次性写入就只需要请求一次,而多次分批写势必效率更低
缓冲区一定会结合具体的设备来定制自己的刷新策略,通常有以下3种刷新策略:
- 立即刷新 – 无缓冲
- 行刷新 – 行缓冲
- 缓冲区满 – 全缓冲
通常针对磁盘文件而言,采用的是全缓冲(最高效)
对于显示器而言,采用的是行缓冲,因为显示器是给用户显示的。
若是全缓冲,一次性将数据全部刷新出来,用户难以阅读数据,而无缓冲又太低效了,所以采用的行缓冲(方便用户)
同样的刷新策略也存在特殊情况:
- 当用户进行强制刷新时(调用fflush接口)
- 进程退出 – 一般情况下都需要进行缓冲区刷新
4. 解释现象
最开始我们引出文件缓冲区是通过fork函数后将运行结果重定向到文件当中发现调用C接口的数据会打印两遍,而调用系统接口的数据只打印一遍,该现象与缓冲区存在什么关系呢?
首先,我们要明确一点该现象一定与缓冲区有关,还有一点就是缓冲区一定不存在于内核当中,不然系统接口也应该打印两遍
我们之前所谈论的所有缓冲区都是用户级语言层面给我们提供的缓冲区
该缓冲区存在于stdout,stderr,stdin当中,这三者都被文件指针所指向(FILE*) 而FILE结构体是经过C语言封装过后,其中包含fd(文件描述符)和一个缓冲区
所有我们想要立即获取数据就要强制刷新(fflush(文件指针)),在关闭文件时也需要传入文件指针(fclose(文件指针))
4.1 C语言库中的源代码
从源码来看,可以看出FILE结构体当中不仅封装文件描述符,文件的打开方式还封装了缓冲区
基于以上认识,我们就可以解释该现象了
在代码结束之前 fork创建子进程
- 如果未进行重定向,只打印4行信息
stdout默认采用的是行刷新,
在进程fork之前就已经将数据进行打印输出到外设(显示器)上,所以在FILE内部(或者称为进程内部)不存在对应的数据 - 如果进行了重定向,写入文件不再是显示器而是普通文件,采用的刷新策略就是全缓冲
而之前的3条C打印函数虽然结尾带上\n,
但是**并不足以将stdout缓冲区写满,**那么数据也就不会被刷新
此时再执行fork函数,stdout是属于父进程的,创建子进程,子进程会对父进程的代码和数据进行拷贝
fork之后紧接着就是退出,谁先退出就一定会进行缓冲区的刷新(也就是修改)
修改会导致写时拷贝,导致数据会显示两份 - write为啥没有显示两份呢?
因为上面的过程都与write无关 ,write没有FILE结构体而是采用的文件描述符fd,也就不存在C提供的缓冲区啦!
5. 深刻理解缓冲区
缓冲区到底应该怎么理解呢? 我们通过尝试自己将文件描述符,缓冲区封装成FILE来实现对缓冲区的深刻理解
5.1 功能需求实现
先将文件描述符,缓冲区封装起来,再实现文件操作的基本功能
- 写入数据:_fwrite
- 刷新数据:_fflush
- 关闭文件:_fclose
- 打开文件:_fopen
暂时就先实现这几个简单模块,主要还是针对缓冲区的理解
5.2 基本框架搭建
[hx@hx my_stdio]$ ll
total 4
-rw-rw-r-- 1 hx hx 78 Jun 7 18:33 Makefile
-rw-rw-r-- 1 hx hx 0 Jun 7 18:33 myStdio.c
-rw-rw-r-- 1 hx hx 0 Jun 7 18:33 myStdio.h
[hx@hx my_stdio]$ cat Makefile
main:main.c myStdio.c
gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
rm -f main
5.3 封装成_FILE
// 在myStdio.h文件当中定义
1 #pragma once
2
3 #include <stdio.h>
4
5 #define SIZE 1024
6
7 typedef struct _FILE
8 {
9 int flags; // 刷新方式:(无/行/全缓冲)
10 int fileno; // 文件描述符
11 int capacity; // buffer的总容量
12 int size; // buffer当前的使用量
13 char buffer[SIZE]; // SIZE字节的缓冲区
14 } _FILE;
15
16 // 文件打开需要文件打开路径和权限
17 _FILE * fopen_(const char* path_name,const char *mode);
18
19 // ptr是要写入文件的数据 num是数据字节数 _FILE* 文件指针
20 void fwrite_(const char* ptr,int num,_FILE* fp);
21
22 //传文件指针关闭文件
23 void fclose_(_FILE* fp);
24
25 //传文件指针强制刷新缓冲区
26 void fflush_(_FILE* fp);
5.4 fopen_的实现
[hx@hx my_stdio]$ cat 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
{
// 目前就简单实现这3种文件操作
}
// 文件描述符
int fd = 0;
// 以只读的方式打开文件
if(flags & O_RDONLY) fd = open(path_name,flags);
// 写入文件 若文件不存在 需要以defaultMode的权限创建
else fd = open(path_name,flags,defaultMode);
// 文件打开失败
if(fd < 0)
{
// 记录下错误信息
const char* err = strerror(errno);
// 将错误信息写入到标准错误(2/stderr)当中
write(2,err,strlen(err));
// 这也就是为啥文件打开失败要返回NULL(C语言底层就是这样实现的)
return NULL;
}
// 下面就是文件打开成功
// 在堆上申请空间
_FILE * fp = (_FILE*)malloc(sizeof(_FILE));
// 暴力检查 未申请成功直接报断言错误
assert(fp);
// 默认设置为行刷新
fp->flags = SYNC_LINE;
// 文件描述符置为fd
fp->fileno = fd;
fp->capacity = SIZE;
fp->size = 0;
// 将缓冲区数据置为0 保证后续往缓冲区写入数据正确
memset(fp->buffer,0,SIZE);
// 这也就是为啥打开文件要返回FILE*的指针(C语言底层的实现方式)
return fp;
}
5.5 头文件的引用 刷新方式的定义
[hx@hx my_stdio]$ cat myStdio.h
#pragma once
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#define SIZE 1024
// 无缓冲
#define SYNC_NOW 1
// 行缓冲
#define SYNC_LINE 2
// 全缓冲
#define SYNC_FULL 4
5.6 fwrite_的实现
void fwrite_(const void * ptr,int num,_FILE *fp)
{
// 将数据写入到缓冲区
// 这里的fp->buffer+fp->size 若缓冲区当中存在数据 往后追加
// 这里不考虑缓冲区溢出的问题
memcpy(fp->buffer+fp->size,ptr,num);
// 缓冲区数据增加num个字节
fp->size += num;
// 判断刷新方式
// 无刷新
if(fp->flags & SYNC_NOW)
{
// 将缓冲区数据写入文件
write(fp->fileno,fp->buffer,fp->size);
// 将缓冲区置为0 惰性清空缓冲区
fp->size = 0;
}
// 全刷新
else if(fp->flags & SYNC_FULL)
{
//当缓冲区满了才刷新
if(fp->size == fp->capacity)
{
write(fp->fileno,fp->buffer,fp->size);
fp->size = 0;
}
}
// 行缓冲
else if(fp->flags & SYNC_LINE)
{
//当最后1个字符为\n时,刷新数据
//这里不考虑 "abcd\nefg" 这种情况
if(fp->buffer[fp->size-1] == '\n')
{
write(fp->fileno,fp->buffer,fp->size);
fp->size = 0;
}
}
else
{
// 不执行任何操作
}
}
5.7 fclose_和fflush_的实现
void fflush_(_FILE *fp)
{
//若缓冲区内存在数据 将缓冲区的数据写入对应的文件描述符(可能是磁盘文件,也可能是显示器)
if(fp->size > 0)
write(fp->fileno,fp->buffer,fp->size);
}
void fclose_(_FILE *fp)
{
// 文件关闭前要进行数据的强制刷新
fflush_(fp);
// 关闭对应的文件描述符
// 文件描述指向的就是文件(关闭文件)
close(fp->fileno);
}
5.8 实例测试
1. 情况1
2. 情况2
3. 情况3
6. 理解文件刷新后的整个过程
用户在往文件当中写入"hello linux\n" ,先调用的C语言接口fwrite, fwrite会1将数据先写入到C语言封装的FILE缓冲区当中,再采取对应的刷新策略 进过write接口(底层接口)根据文件描述符将数据拷贝到内核缓冲区当中,最后由OS定期刷到外设(磁盘)当中。
数据要写入到外设当中要经历3次拷贝,第一次拷贝到C语言的缓冲区当中,第二次拷贝到内核缓冲区当中,第三次拷贝到外设当中
(所以fwrite/write接口的实质其实就是拷贝函数)
怎么证明该过程呢? – 无法证明,但是可以看到接口
用户调用fwrite将数据交到C语言的缓冲区当中,C语言调用操作系统底层的write接口将数据交给内核缓冲区,如果在这个过程当中OS宕机了怎么办?
操作系统宕机也就意味着缓冲在内核缓冲区的数据还未刷新到外设当中,那么就会造成数据丢失,如果用户对数据丢失0容忍怎么办(假设用户是银行机构,数据丢失影响重大),那该怎么办?
操作系统当中存在接口 fsync – 强制刷新
7. 对强制刷新的深刻理解
[hx@hx my_stdio]$ man 2 fsync
FSYNC(2) Linux Programmer's Manual FSYNC(2)
NAME
fsync, fdatasync - synchronize a file's in-core state with storage device
SYNOPSIS
#include <unistd.h>
int fsync(int fd);
调用该接口告知操作系统别在按照自己的刷新策略刷新数据,只要拿到数据就立马刷新到外设当中
7.1 实例
在fflush_ 当中强制刷新接口 fsync才是真正的强制刷新 fwrite只能算拷贝
void fflush_(_FILE *fp)
{
//若缓冲区内存在数据 将缓冲区的数据写入对应的文件描述符(可能是磁盘文件,也可能是显示器)
if(fp->size > 0)
write(fp->fileno,fp->buffer,fp->size);
// 强制要求操作系统对外设进行刷新
fsync(fp->fileno);
// 刷新完 将size置为0 表示此时缓冲区内无数据
fp->size = 0;
}