文章目录
- 问题引入
- 如何理解缓冲区
- 缓冲区刷新策略
- 问题解释
- 模拟一个文件缓冲区
问题引入
首先看一段代码:
#include <stdio.h>
#include <string.h>
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;
}
运行代码,结果如下:
如果此时将输出结果重定向一下:
会发现printf
、fwrite
都打印了两次。
究其原因,就要谈到缓冲区和缓冲区刷新策略的概念了。
如何理解缓冲区
假设你在青岛,你要从网上买一件商品,商家所在地是北京。你不会跑去北京自提,商家也不会跑到青岛亲自送货,因为成本又高效率又低。实际做法是商家通过寄快递的方式把商品寄到你所提供的地址附近的菜鸟驿站。
我们把北京比作外设,把商品比作信息,把青岛比作内存,把你比作进程,所以现在的情景就变成了位于内存的进程要从外设中读取一段信息。那此时菜鸟驿站是个什么角色呢?答案是缓冲区。
我们都有一个共识,那就是访问外设的效率是很低的。
为了解决这个问题,内存中会单独开一块空间作为缓冲区,当你想往外设输出数据的时候,很多时候并不会来一条输出一条,而是等缓冲区中数据达到一定数量之后再刷新到外设。同样地,当从外设中读取数据的时候,很多时候也不会只读取你想要的那一条,而是前前后后多读取一部分数据,你下次要读取的数据很大可能就在这部分数据中。这样一来就减少了io的次数,效率也就提高了。
这里就有几个问题需要理解一下:
-
将数据刷新到缓冲区或将数据从缓冲区刷新出来的本质是什么?
按照正常的理解,刷新应该是要把这部分东西挪到另一个地方去,但挪动数据属实有点复杂,一要删除二要拷贝,拷贝数据不可避免,但是删除可以简化。比如可以记录一下缓冲区的当前大小,需要刷新缓冲区的时候,先把缓冲区的数据拷贝出去,然后把缓冲区的大小 置零,此时就完成了惰性删除。如果要把数据刷新到缓冲区,只需要将数据拷贝到缓冲区,然后原数据都不需要动。所以将数据刷新到缓冲区或者从缓冲区中刷新出来的本质是拷贝。更进一步,再加上等待数据刷新的这部分时间,不就是 一次完整的io了么。所以我们还可以得出io的本质就是等待+拷贝。
-
缓冲区的本质是什么?
缓冲区的本质就是一段内存。
-
缓冲区是谁提供的 ?
像我们使用
printf
、fwrite
这类C语言接口进行输入输出时,都是对文件进行输入输出,而文件在C语言中是以FILE
的形式描述组织的,所以对文件输入输出,实际上是向FILE
中提供的缓冲区进行拷贝。 -
缓冲区是谁申请的?
缓冲区是用户申请的。缓冲区本质是一段内存,当我们创建
FILE
对象时其实就会创建一段缓冲区,本质就是用malloc
去申请,只不过我们看不到这个过程。
我们可以看一下FILE
中的相关字段:
/usr/include/libio.h
有一句typedef struct _IO_FILE FILE
,
我们可以在/usr/include/stdio.h
中看到struct _IO_FILE
的相关代码,下面是与缓冲区有关的部分字段:
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
};
缓冲区刷新策略
现在有一个缓冲区,是来一条数据就刷新一条数据,还是等数据达到一定数量才刷新呢?这就跟缓冲区的刷新策略有关了。
在效率方面,上面两种刷新策略无疑是后者效率最高,对于相同数量的数据刷新次数最少,访问外设的次数更少,效率自然更高。那是不是就采用这种刷新策略呢?非也,这还要看对应的场景。
对于显示器这种比较特殊的外设,我们需要时刻获取屏幕上的信息,所以数据从缓冲区刷新到显示器上的频率是会更高的。此时采用的刷新策略就是行缓冲,遇到\n
就刷新。这也叫做行缓冲。
当然还有更为激进的刷新策略,就是即使刷新,来一点数据就刷一点。这也叫做无缓冲。
还有一种常见的刷新策略就是全缓冲,等到缓冲区满了再刷新。
所以常见的刷新策略就三条:
- 行缓冲,遇到换行符就刷新。
- 无缓冲,来一条数据刷新一条数据。
- 全缓冲,缓冲区满了才刷新。
当然还有特殊情况,比如进程退出时会把缓冲区刷新一遍,还有就是用户强制刷新,比如调用fflush
。
问题解释
现在应该能回答最初的问题了。
fprintf
和fwrite
都是向stdout
中写入,在没有重定向的前提下就是向显示器输出,此时采取的刷新策略是行缓冲,所以会正常打印两条信息。
write
是系统调用接口,不存在C语言提供的用户级缓冲区,所以就直接向文件写入了,打印出一条信息。
而一旦进行了重定向,此时输出目标是普通文件,普通文件采取的刷新策略是全缓冲。fprintf
和fwrite
向stdout
的缓冲区写入的数据不足以填满缓冲区,所以在两条语句都执行结束之后缓冲区并没有及时刷新。而write
由于没有这层缓冲区的缘故直接就写入了。此时创建一个子进程,因为是完全继承的父进程,自然而然地继承了父进程的缓冲区和缓冲区中的数据。下一步子进程和父进程都结束,在两个进程退出之前会先刷新缓冲区,所以两个进程的缓冲区中的数据都被刷新出来,也就是两组相同的字符串,每组两条。也就出现了最终的结果。
模拟一个文件缓冲区
知道了上面的原理,我们可以自己模拟实现一个建议的文件缓冲区及文件操作相关接口,封装一个简易的文件操作,加深对文件操作以及缓冲区的理解。
首先我们需要定义一个简易的FILE
结构,因为是一个demo级别的,就封装几个核心字段:
#define SIZE 1024
#define SYNC_NOW 1 << 0 //无缓冲
#define SYNC_LINE 1 << 1 //行缓冲
#define SYNC_FULL 1 << 2 //全缓冲
typedef struct FILE_{
int flags; //刷新策略
int fileno; //文件描述符
int cap; //buffer的总容量
int size; //buffer当前的使用量
char buffer[SIZE];//缓冲区,这里大小是写死的,实际缓冲区的大小是可变的
}FILE_;
此外我们实现四个基本接口,分别是fopen_
、fclose_
、fwrite_
、fflush_
。
仿照C语言库里函数的参数设计:
FILE_ *fopen_(const char *path_name, const char *mode);
// path_name是要打开文件的路径
// mode是打开文件时的方式,是读、写还是追加
void fwrite_(const void *ptr, int num, FILE_ *fp);
// ptr是要写入的数据的地址
// num是要写入的数据的字节数
// fp是要向哪个文件中写入
void fclose_(FILE_ * fp);
// fp是要关闭的文件
void fflush_(FILE_ *fp);
// 立即刷新fp的缓冲区
对于fopen_
函数,我们直接调用系统调用接口open
,在调用之前只要根据mode
设置好open
的flag
参数即可,然后需要给文件创建相关的FILE
结构对象并设置相关信息:
FILE_ *fopen_(const char *path_name, const char *mode)
{
int flags = 0;
int defaultMode=0666;
if (strcmp(mode, "r") == 0)
{
// O_RDONLY -- 只读
flags |= O_RDONLY;
}
else if (strcmp(mode, "w") == 0)
{
// O_WRONLY -- 只写,O_CREAT -- 文件不存在时创建文件,O_TRUNC -- 打开文件时先清空文件
flags |= (O_WRONLY | O_CREAT | O_TRUNC);
}
else if (strcmp(mode, "a") == 0)
{
// O_APPEND -- 追加
flags |= (O_WRONLY | O_CREAT | O_APPEND);
}
else
{
// ...
}
int fd = 0;
// 如果是以只写的方式打开,则默认文件已经存在,直接打开即可
if (flags & O_RDONLY)
fd = open(path_name, flags);
// 不然则可能需要创建文件,一旦flags中有O_CREAT信息则需要传创建文件时的默认权限
else
fd = open(path_name, flags, defaultMode);
// 打开文件失败,设置错误信息并返回NULL,这也是为什么打开文件时会返回NULL的原因
if (fd < 0)
{
const char *err = strerror(errno);
write(2, err, strlen(err));
return NULL;
}
// 打开文件成功,创建相关的的FILE结构对象
FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));
assert(fp);
// 设置FILE对象的字段信息
fp->flags = SYNC_LINE; // 默认设置成为行刷新
fp->fileno = fd; // 设置文件描述符
fp->cap = SIZE; // 设置缓冲区容量
fp->size = 0; // 初始未进行写入
memset(fp->buffer, 0 , SIZE);// 给缓冲区申请空间
return fp; // 将fp返回
}
对于fclose_
,我们也是直接调用系统调用接口close
,只不过在关闭文件之前先刷新一下文件的缓冲区。
注意,C语言中的close
函数只负责关闭文件,也就是断掉文件和文件描述符直接的连接关系。
而FILE
结构的生命周期由标准库管理。当程序终止时,所有仍然打开的文件都会被自动关闭,相应的资源和FILE
结构会被释放。这也包括在main
函数返回之前打开的文件。所以我们不需要显式地释放FILE
结构。
void fclose_(FILE_ *fp)
{
fflush_(fp);
close(fp->fileno);
}
对于fwrite_
,很简单,直接把要写入的数据拷贝到文件的缓冲区即可,此时缓冲区中可能已经有部分数据,所以写入的时候要注意细节。然后需要根据当前文件的刷新策略判断一下是否需要刷新。而数据从缓冲区刷新出去的本质就是调用write
接口将数据从缓冲区中写入到文件对应的文件描述符中,然后将缓冲区的size
置零进行惰性删除缓冲区内的数据:
void fwrite_(const void *ptr, int num, FILE_ *fp)
{
// 写入到缓冲区中
memcpy(fp->buffer + fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题
fp->size += num;
// 无缓冲,数据读进来之后立即刷新
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)
{
// 这里只是为了简单地理解原理,不考虑诸如"abcd\nef"这类较为复杂的场景
if(fp->buffer[fp->size-1] == '\n')
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
}
最后就是fflush_
,直接调用系统调用接口write
将缓冲区的数据写到文件中,然后将缓冲区置空就好了。不过此时还有一个问题,我们用write
将数据写入到文件,实际中间还有OS
的内核缓冲区,可能不会直接刷新到硬盘中的文件,所以可以通过系统调用接口fsync
强制要求OS将数据刷新到外设:
void fflush_(FILE_ *fp)
{
if(fp->size > 0)
write(fp->fileno, fp->buffer, fp->size);
fsync(fp->fileno); // 强制要求OS将数据刷新到外设
fp->size = 0; // 将缓冲区置空,无需对缓冲区原有的数据进行操作
}
最后我们整合一下,把所有相关头文件包含和声明放到头文件stdio_.h
中:
#pragma once
#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 << 0 //无缓冲
#define SYNC_LINE 1 << 1 //行缓冲
#define SYNC_FULL 1 << 2 //全缓冲
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);
// path_name是要打开文件的路径
// mode是打开文件时的方式,是读、写还是追加
void fwrite_(const void *ptr, int num, FILE_ *fp);
// ptr是要写入的数据的地址
// num是要写入的数据的字节数
// fp是要向哪个文件中写入
void fclose_(FILE_ * fp);
// fp是要关闭的文件
void fflush_(FILE_ *fp);
// 立即刷新fp的缓冲区
相关方法的实现整合在stdio_.c
文件中:
#include "myStdio.h"
FILE_ *fopen_(const char *path_name, const char *mode)
{
int flags = 0;
int defaultMode=0666;
if (strcmp(mode, "r") == 0)
{
// O_RDONLY -- 只读
flags |= O_RDONLY;
}
else if (strcmp(mode, "w") == 0)
{
// O_WRONLY -- 只写,O_CREAT -- 文件不存在时创建文件,O_TRUNC -- 打开文件时先清空文件
flags |= (O_WRONLY | O_CREAT | O_TRUNC);
}
else if (strcmp(mode, "a") == 0)
{
// O_APPEND -- 追加
flags |= (O_WRONLY | O_CREAT | O_APPEND);
}
else
{
// ...
}
int fd = 0;
// 如果是以只写的方式打开,则默认文件已经存在,直接打开即可
if (flags & O_RDONLY)
fd = open(path_name, flags);
// 不然则可能需要创建文件,一旦flags中有O_CREAT信息则需要传创建文件时的默认权限
else
fd = open(path_name, flags, defaultMode);
// 打开文件失败,设置错误信息并返回NULL,这也是为什么打开文件时会返回NULL的原因
if (fd < 0)
{
const char *err = strerror(errno);
write(2, err, strlen(err));
return NULL;
}
// 打开文件成功,创建相关的的FILE结构对象
FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));
assert(fp);
// 设置FILE对象的字段信息
fp->flags = SYNC_LINE; // 默认设置成为行刷新
fp->fileno = fd; // 设置文件描述符
fp->cap = SIZE; // 设置缓冲区容量
fp->size = 0; // 初始未进行写入
memset(fp->buffer, 0 , SIZE);// 给缓冲区申请空间
return fp; // 将fp返回
}
void fclose_(FILE_ *fp)
{
fflush_(fp);
close(fp->fileno);
}
void fwrite_(const void *ptr, int num, FILE_ *fp)
{
// 写入到缓冲区中
memcpy(fp->buffer + fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题
fp->size += num;
// 无缓冲,数据读进来之后立即刷新
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)
{
// 这里只是为了简单地理解原理,不考虑诸如"abcd\nef"这类较为复杂的场景
if(fp->buffer[fp->size-1] == '\n')
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
}
void fflush_(FILE_ *fp)
{
if(fp->size > 0)
write(fp->fileno, fp->buffer, fp->size);
fsync(fp->fileno); // 强制要求OS将数据刷新到外设
fp->size = 0; // 将缓冲区置空,无需对缓冲区原有的数据进行操作
}