每一个不曾起舞的日子,都是对生命的辜负。
缓冲区的理解
- 一. C接口打印两次的现象
- 二. 理解缓冲区问题
- 为什么要有缓冲区
- 缓冲区刷新策略的问题
- 所说的缓冲区在哪里?指的是什么缓冲区?
- 三. 解释打印两次的现象
- 四. 模拟实现
- 五. 缓冲区与OS的关系
一. C接口打印两次的现象
#include<stdio.h>
#include<string.h>
#include<unistd.h>
int main()
{
//C接口
printf("hello printf");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
//系统接口
const char* wstring = "hello write\n";
write(1, wstring, strlen(wstring));
return 0;
}
先看看这段代码的结果:
当添加一个fork()后:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
int main()
{
//C接口
printf("hello printf");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
//系统接口
const char* wstring = "hello write\n";
write(1, wstring, strlen(wstring));
// 代码结束之前,进行创建子进程
fork();
return 0;
}
直接运行仍是正常的现象,但当重定向到log.txt中,C接口的打印了两次,这是什么原因呢?带着疑问继续探讨:
二. 理解缓冲区问题
- 缓冲区本质就是一段内存
那么既然有了本质前提,那么就有这几个方面要思考:
- 缓冲区是谁申请的?
- 缓冲区属于谁?
- 为什么要有缓冲区?
为什么要有缓冲区
下面举个场景:
张三和李四是好朋友,一天张三想给李四一个包裹,但是张三在四川,李四在北京,如果张三亲自去送包裹,实际上会占用张三大量的时间,而且也不现实,所以为了不占用张三自己的时间,就把包裹送到快递公司让其送到李四那里。
现实生活中,快递行业的意义就是节省发送者的时间,而对于这个例子来说,四川就相当于内存,发送者张三相当于进程,包裹就是进程需要发送的数据,北京就相当于磁盘,李四就是磁盘上的文件,那么可以看成这样:
在冯诺依曼体系中,我们知道内存直接访问磁盘这些外设的速度是相对较慢的,即正如我们所举的例子一样,张三亲自送包裹会占用张三大量的时间,因此顺丰同样属于内存中开辟的一段空间,将我们在内存中已有的数据拷贝到这段空间中,拷贝函数就直接返回了,即张三接收到顺丰的通知就离开了。在执行你的代码期间,顺丰对应的内存空间的数据也就是包裹就会不断的发送给对方,即发送给磁盘。而这个过程中,顺丰这块开辟的空间就相当于缓冲区。
那么缓冲区的意义是什么呢?——节省进程进行数据IO的时间。这也就回答了第三个问题为什么要有缓冲区。
- 在上述的过程中,拷贝是什么,我们在fwrite的时候没有拷贝啊?因此我们需要重新理解fwrite这个函数,与其理解fwrite是写入到文件的函数,倒不如理解fwrite是拷贝函数,将数据从进程拷贝到“缓冲区”或者外设中!
那我们送的包裹何时会发送出去呢?即我们的数据什么时候会到磁盘中呢?这就涉及到缓冲区刷新策略的问题:
缓冲区刷新策略的问题
上述我们提到,张三的包裹送到了顺丰,但是当张三再次来到顺丰邮寄另一个包裹时,发现之前的包裹还在那里放着,毫无疑问,张三会去找工作人员理论:为什么这么长时间还没有发?而工作人员这时也解释:我们的快递是通过飞机运的,如果只送你这一件包裹,路费都不够!因此可以看出,快递不是即送即发,也就是说数据不是直接次写入外设的。
那么如果有一块数据A,一次写入到外设,还有一块数据B多次少批量写入外设,A和B谁效率最高呢?
一定是A最高。一块数据写入到外设,需要外设准备,如果多次写入外设,每一次外设进行的准备都会占用时间,而积攒到一定程度一次发送到外设,外设的准备次数就会大幅减少,效率也会提高。因此,为了在不同设备的效率都是最合适的,缓冲区一定会结合具体的设备,定制自己的刷新策略:
-
立即刷新,无缓冲
-
行刷新,行缓冲(显示器)\n就会刷新,比如_exit和exit
-
缓冲区满 全缓冲 (磁盘文件)
当然还有两种特殊情况
- 用户强制刷新:fflush
- 进程退出 ——>进程退出都要进行缓冲区刷新
所说的缓冲区在哪里?指的是什么缓冲区?
文章开始时我们提到了C语言接口打印两次的现象,毫无疑问,我们能够从中获得以下信息:
- 这种现象一定和缓冲区有关
- 缓冲区一定不在内核中(如果在内核中,write也应该打印两次)
因此我们之前谈论的所有的缓冲区,都指的是用户级语言层面给我们提供的缓冲区。这个缓冲区在stdout,stdin,stderr->FILE* ,FILE作为结构体,其不仅包括fd,缓冲区也在这个结构体中。所以我们自己要强制刷新的时候,fflush传入的一定是文件指针,fclose也是如此,即:fflush(文件指针),fclose(文件指针)
通过查看:vim /usr/include/libio.h
因此我们所调用的fscanf,fprintf,fclose等C语言的文件函数,传入文件指针时,都会把相应的数据拷贝到文件指针指向的文件结构体中的缓冲区中。
即缓冲区也可以看做是一块内存,对于内存的申请:无非就是malloc new出来的。
因此在这里我们也就能回答最初的三个问题:
- 缓冲区是谁申请的?用户(底层通过malloc/new)
- 缓冲区属于谁?属于FILE结构体
- 为什么要有缓冲区?节省进程进行IO的时间
三. 解释打印两次的现象
有了缓冲区的理解,现在就足以解释打印两次的现象:
由于代码结束之前,进行创建子进程:
-
如果我们不进行重定向,看到四条消息
stdout默认使用的是行刷新,在进程进行fork之前,三条C函数已经将数据进行打印输出到显示器上(外设),也就是说FILE内部的缓冲区不存在对应的数据。
-
如果进行了重定向>,写入的就不是显示器而是普通文件,采用的刷新策略是全缓冲,之前的三条C显示函数,虽然带了\n,但是不足以将stdout缓冲区写满!数据并没有被刷新,而在fork的时候,stdout属于父进程,创建子进程时,紧接着就是进程退出!无论谁先退出,都一定会进行缓冲区的刷新(就是修改缓冲区)一旦修改,由于进程具有独立性,因此会发生写时拷贝,因此数据最终会打印两份。
-
write函数为什么没有呢?因为上述的过程都与write无关,write没有FILE,用的是fd,没有C对应的缓冲区。
因此如上就是对于现象的解释。
四. 模拟实现
所以呢?缓冲区应该如何理解呢?和OS有什么关系呢?下面就通过写一个demo实现一下行刷新:touch myStdio.h
;touch myStdio.c
;touchmain.c
myStdio.h
#pragma once
#include<stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define SIZE 1024
#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);
void fclose_(FILE_ * fp);
void fflush_(FILE_ *fp);
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; //默认设置成为行刷新
fp->fileno = fd;
fp->cap = SIZE;
fp->size = 0;
memset(fp->buffer, 0 , SIZE);
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') // 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);
}
main.c
#include "myStdio.h"
int main()
{
FILE_ *fp = fopen_("./log.txt", "w");
if(fp == NULL)
{
return 1;
}
int cnt = 10;
const char *msg = "hello bit ";
while(1)
{
fwrite_(msg, strlen(msg), fp);
fflush_(fp);
sleep(1);
printf("count: %d\n", cnt);
//if(cnt == 5) fflush_(fp);
cnt--;
if(cnt == 0) break;
}
fclose_(fp);
return 0;
}
五. 缓冲区与OS的关系
我们所写入到磁盘的数据hello bit是按照行刷新进行写入的,但并不是直接写入到磁盘中,而是先写到操作系统内的文件所对应的缓冲区里,对于操作系统中的file结构体,除了一些接口之外还有一段内核缓冲区,而我们的数据则通过file结构体与文件描述符对应,再写到内核缓冲区里面,最后由操作系统刷新到磁盘中,而刷新的这个过程是由操作系统自主决定的,而不是我们刚才所讨论的一些行缓冲、全缓冲、无缓冲……,因为我们提到的这些缓冲是在应用层C语言基础之上FILE结构体的刷新策略,而对于操作系统自主刷新策略则比我们提到的策略复杂的多(涉及到内存管理),因为操作系统需要考虑自己的存储情况而定,因此数据从操作系统写到外设的过程和用户毫无关系。
所以一段数据被写到硬件上(外设)需要进行这么长的周期:首先通过用户写入的数据进入到FILE对应的缓冲区,这是用户语言层面的,然后通过我们提到的刷新的策略刷新到由操作系统中struct file*
的文件描述符引导写到操作系统中的内核缓冲区,最后通过操作系统自主决定的刷新策略写入到外设中。如果OS宕机了,那么数据就有可能出现丢失,因此如果我们想及时的将数据刷新到外设,就需要一些其他的接口强制让OS刷新到外设,即一个新的接口:int fsync(int fd)
,调用这个函数之后就可以立即将内核缓冲区的数据刷新到外设中,就比如我们常用的快捷键:ctrl + s
总结:
因此以上我们所提到的缓冲区有两种:用户缓冲区和内核缓冲区,用户缓冲区就是语言级别的缓冲区,对于C语言来说,用户缓冲区就在FILE结构体中,其他的语言也类似;而内核缓冲区属于操作系统层面,他的刷新策略是按照OS的实际情况进行刷新的,与用户层面无关。