首先,我们来看这样一段代码。
#include
#include
//这段代码的运行结果是什么呢?
int time=5;
int main()
{
printf("before:time=%d\n",time);
printf("hello world");
while(time--)
{
sleep(1);
}
printf("\tsleep after:time=%d\n",time);
return 0;
}
由于不好展示效果,所以这里有兴趣的读者可以在自己的Linux机器上试一试这段代码。可以明显发现,hello world和最后的sleep after一起出来,从代码执行的逻辑来看,hello world早就被打印了,但是为什么最后的时候才显示出来呢? 这里我们就可以大胆的猜测,这个hello world确实被打印出来了没错,但是并没有立刻被刷新到显示器,而是被暂时存储到了一个地方中。最后进程要退出了,把这些暂存的数据统一刷新了出来! 而这个暂存数据的地方,就是我们先前在学习C语言的时候的缓冲区。 那么,接下来,读者肯定有如下的一些疑问:
1.如何理解缓冲区的存在
2.这个缓冲区是操作系统提供的吗?
3.缓冲区在源代码层面如何体现?
别着急,下面我们就来为各位读者揭开缓冲区的神秘面纱。
那么,首先我们先对缓冲区有一个感性的认识。所谓的缓冲,说到底就是缓和速度的差异。而这里的速度,就是磁盘和cpu之间的速度差。 你可以在这里暂且把缓冲区认为是一种缓解cpu和IO设备数据交互速度差异的一种手段,也就是一层软件层。
那么,为什么会有缓冲区这么一层软件层呢? 不妨联系一下下面这么一种生活场景:
以点外卖为例:假设一个餐馆的外卖员要送外卖,他可以有如下的两种方式:
1.一有一件外卖单,马上就马不停蹄去送
2.积累一定的订单数量,然后去送
那么对于上述两种方式,对于订单量庞大的时候,显然第二种方式是比较合理的。同样类比到计算机世界,也是第二种方式相对而言传输数据的方式会高效一点!因为磁盘IO的速度和cpu来比实在是太慢了,能够减少IO的次数就减少IO的次数! 所以简单来说,缓冲区的存在:就是为了提高IO的效率! 那么这时候就又会有一个问题,那么这个缓冲区是谁给我的呢?难道是操作系统吗?
下面我们就来探索一下缓冲区究竟是谁给我们提供的,针对上面的代码,我们仅仅只是把printf函数换成了系统调用write,其他部分不变,看一看会发生什么:
#include
#include
#include
#include
#include
#include
int times=5;
int main()
{
printf("before time : %d\n",times);
const char* msg="hello world";
write(1,msg,strlen(msg));
while(times--)
{
sleep(1);
}
printf("\t finish\n");
return 0;
}
由于效果不好演示,所以读者可以尝试在自己的Linux环境下演示。那么实验的现象就是:write调用很快就把对应的数据打印出来了!那么这个现象可以说明:缓冲区不是操作系统提供的!那么对应的,我们所说的缓冲区就是C语言给我们封装的!
那么,在对应的源代码里面,缓冲区是如何体现的呢?
//FILE结构体的源代码:
struct _iobuf {
char *_ptr; // 文件输入的下一个位置
int _cnt; // 当前缓冲区位置
char *_base; // 文件起始位置
int _charbuf; // 当前缓冲区状况
int _bufsiz; // 缓冲区大小
};
typedef struct _iobuf FILE; //别名FILE,可以直接用
可以看到,对应的FILE结构体里面确实包含了描述缓冲区的信息。那么接下来我们也来模拟封装一个和C语言FILE结构体类似的结构体。
模拟实现C语言的FILE结构,目的是为了更好的体会缓冲区,而不是写一个更好的FILE结构体。模拟的FILE结构体应该具有如下的功能:
fopen ---->这里以 研究w方式为主,r方式较为简单,其中w方式打开文件时,如果文件不存在则会创建,默认每次都会把文件清空。而如果是以"a"方式打开文件,默认就是追加文件内容
2.fwrite: 往文件内写入对应的内容,而如果是向stdout写入,那么数据会暂存于缓冲区,直到遇到’\n’或者是fflush强制刷新,或者是进程关闭文件的时候刷新
3.fflush:强制刷新缓冲区的数据到文件
4.fclose:关闭文件
接下来,我们就对上述的一些接口进行封装模拟。首先最重要的就是,我们需要先把FILE结构体先定义出来,首先这个结构体一定要有如下的信息:
1. 文件描述符
2.缓冲区刷新策略
3.文件缓冲区的相关信息
综合上述,定义如下的FILE结构体还有对应的缓冲区刷新策略
//对应的缓冲区刷新策略--->无缓冲,行缓冲,全缓冲
#define FLUSH_NONE 0x0
#define FLUSH_LINE 0x1
#define FIUSH_ALL 0x2
//默认缓冲区的大小
#define DEFAULT_SIZE 1024
/*
* FILE结构体
* 1.文件描述符
* 2.缓冲区刷新策略
* 3.缓冲区相关信息
* */
typedef struct _file_struct
{
int _fd;
int _fflush;
char* _begin;
size_t _size;
size_t _capacity;
}MYFILE;
接下来就是对应封装的fopen函数了,对应的注释都写好放在代码里面了
//打开文件
MYFILE* myfopen(const char* filename,const char* pattern)
{
assert(filename);
assert(pattern);
int flags=O_RDONLY;
//默认就是读取方式,无需特殊处理
if(strcmp("r",pattern)==0)
{
}
//写入方式--->不存在就创建,每次打开时清空
else if(strcmp("w",pattern)==0)
{
flags=O_WRONLY |O_CREAT | O_TRUNC;
}
//写入方式--->不存在就创建,每次打开时追加内容
else if(strcmp("a",pattern)==0)
{
flags=O_WRONLY |O_CREAT | O_APPEND;
}
//打开文件,如果没有使用这种方式,文件不存在时创建的新文件权限是受限制的!
int fd=open(filename,flags,0666);
if(fd<0)
{
perror("open fail\n");
return NULL;
}
MYFILE* pf=(MYFILE*)malloc(sizeof(MYFILE));
if(!pf)
{
return pf;
}
//初始化一下
memset(pf,0,sizeof(MYFILE));
//开始填充数据
pf->_fd=fd;
//默认考虑的刷新方式就是行缓冲,类比于C语言设计
pf->_fflush |= FLUSH_LINE;
pf->_begin =NULL;
pf->_size=pf->_capacity=0;
return pf;
}
接下来就是fflush强制刷新函数。其实这个函数比较简单,就是在这个函数内部调用write系统调用即可,不过write调用写入的数据仅仅只是往内核中写,如果要同步到磁盘,需要使用syncfs函数。这个函数的说明如下。
myfflush函数模拟实现如下:
//myfflush
//检查函数内部缓冲区大小的函数
static void check(MYFILE* pf)
{
assert(pf);
char* tmp=NULL;
if(pf->_size==pf->_capacity)
{
size_t newSize=pf->_size==0 ? DEFAULT_SIZE : 2*pf->_size;
tmp=(char*)realloc(pf->_begin,sizeof(char)*newSize);
if(!tmp)
{
perror("NO space for buff!\n");
exit(2);
}
pf->_begin=tmp;
pf->_capacity=newSize;
}
}
//myfflush
void myfflush(MYFILE* pf)
{
assert(pf);
//直接调用write写入,注意这时候是写入内核
write(pf->_fd,pf->_begin,pf->_size);
//强制刷盘
syncfs(pf->_fd);
}
接下来就是对应的fwrite,那么写fwrite函数需要注意如下的几点:
1.数据都应该往_begin指向的空间里先写入
2.当遇到’\n’或者是fflush时,调用write写入内核,并强制刷盘
3.缓冲区刷新完成后,需要调整对应的参数
接下来就是fwrite的模拟实现代码:
//myfwrite
void myfwrite(MYFILE* pf,const char* msg,size_t len)
{
assert(pf);
assert(msg);
assert(len>0);
check(pf);
//写入数据到内核
strncpy(pf->_begin+pf->_size,msg,len);
pf->_size+=len;
//行缓冲
if(pf->_fflush & FLUSH_LINE)
{
//刷新数据
if(pf->_size>0 && pf->_begin[pf->_size-1]=='\n')
{
//刷新数据到内核
write(pf->_fd,pf->_begin,pf->_size);
//刷新完成后重新归位
pf->_size=0;
//强制刷盘
syncfs(pf->_fd);
}
}
}
最后,我们就需要模拟的就是fclose函数,负责关闭文件描述和释放缓冲区申请的空间
//myfclose
void myfclose(MYFILE* pf)
{
assert(pf);
//强制刷新缓冲区
myfflush(pf);
//关闭文件描述符
close(pf->_fd);
//释放内部缓冲区
free(pf->_begin);
pf->_size=pf->_capacity=0;
free(pf);
}
接下来,我们写出如下的测试用例:
int main()
{
MYFILE *fp = myfopen("demo.txt", "w");
if(fp == NULL)
{
printf("my_fopen error\n");
return 1;
}
const char* a="hello one\n";
myfwrite(fp,a,strlen(a));
printf("消息立即刷新\n");
sleep(3);
const char* b=" hello two ";
myfwrite(fp,b,strlen(b));
printf("写入了一个不符合条件的字符串\n");
sleep(3);
const char* c=" hello three ";
myfwrite(fp,c,strlen(c));
printf("写入了一个不符合条件的字符串\n");
sleep(3);
const char* d=" new line\n";
myfwrite(fp,d,strlen(d));
printf("写入一个符合条件的字符串\n");
sleep(3);
const char* e=" hello four ";
myfwrite(fp,e,strlen(e));
printf("写入一个不符合条件的字符串\n");
sleep(3);
const char* f=" hello five ";
myfwrite(fp,f,strlen(f));
printf("写入一个不符合条件的字符串\n");
sleep(3);
const char* g=" hello six ";
myfwrite(fp,g,strlen(g));
printf("写入一个不符合条件的字符串\n");
myfflush(fp);
sleep(3);
myfclose(fp);
return 0;
}
测试的结果如下:
可以发现这里的遇到了换行符才把我们先前写入到缓冲区的信息刷新到了出来。行缓冲已经验证出来了,接下来我们验证一下fflush强制刷新一下内容。
显然我们的模拟的fflush也达到了刷新缓冲区的作用,到这里,目前我们的简单模拟实现的FILE结构体已经基本完成了,而fread的实现相对比较简单,感兴趣的读者可以自行模拟实现。
#include
#include
#include
#include
#include
#include
int main()
{
MYFILE *fp = myfopen("demo.txt", "w");
if(fp == NULL)
{
printf("my_fopen error\n");
return 1;
}
//下面这段代码会发生什么
const char* msg="hello world";
myfwrite(fp,msg,strlen(msg));
fork();
myfclose(fp);
return 0;
}
运行结果:
对应的结果就是往demo.txt文件里面打印了两次的hello world.这个就和前面的知识相关联了!fork之后,子进程以父进程的进程控制块为模板复制了一份,而内部的缓冲区以写时拷贝的方式各自独立。因为myfclose函数内调用了myfflush函数,这个函数对应向内部申请的缓冲区做了写入的操作,而一旦发生了写入操作,数据就会以写时拷贝的方式进行独立。所以子进程以写时拷贝的方式创建了一个新的属于自己的缓冲区。最后我们就看到它往同一个文件里面写入了2次hello world
接下来,我们来讲一讲Linux中非常重要的一个操作—>重定向.从字面上理解,就是重新指定方向。而这个方向就是指的往哪里输出,或者从哪里读取。而我们通常所说的重定向,大多数针对的都是本来要打印到显示器的内容打印到我们指定的文件,或者是本来从键盘读取的数据,我们让其从指定的文件中读取的操作。
接下来我们来看命令行上如何使用重定向操作
#输入重定向,使用<
cat < demo.txt
而输出重定向的方式则有两种:
# >方式:文件不存在则会创建,每次都会默认清空原来的文件
echo "1234" > demo.txt
# >>方式:不会清空原有内容而是追加
echo "1234" >> demo.txt
命令行上使用对应的重定向就是大致如此。而Linux下一切皆文件,那么就意味着所谓的重定向操作,最终也是会被转换成文件的相关操作,而一但是文件的相关操作,那么必定是离不开文件描述符的!下面我们就来正式认识一下和重定向相关的系统调用接口---->dup2
首先我们来看一看手册里面关于dup2系统调用的说明:
尤其是要注意dup2这个地方的说明:
makes newfd be the copy of oldfd, closing newfd first if necessary
这句话的意思是:使得新的文件描述符是旧的文件描述符的一份拷贝,如果可能还会关闭旧的文件描述符。
什么意思呢?拿到就是把这两个文件描述符(整数)相互拷贝吗?显然不是!两个整数之间的拷贝没有什么实际价值。
我们应该从内核数据结构的角度来考虑dup2究竟做了什么
在重定向之前,一个进程维护的文件关系图如下:
而调用dup2系统调用以后,我们希望原来往stdout打印的内容全都打印到new file中,也就是,array[1]实际存储的地址是new file的,下面的进程维护文件的关系就变成了这样
从手册里面的描述说明,我们会发现最后会只剩下了原来的fd指向的文件,1号文件描述符的指针已经被狸猫换太子指向了new file,所以把1号文件描述符重定向到新的文件fd的调用传参是dup2(fd,1) ---->也就是最后你想重定向到哪一个文件,第一个参数就传递哪一个文件描述符。
#include
#include
#include
#include
#include
#include
#include
extern int syncfs(int fd);
void test()
{
int fd=open("demo.txt",O_WRONLY |O_CREAT,0666);
//把原本打印到stdout的内容打印到demo.txt中
dup2(fd,1);
printf("hello Linux");
}
int main()
{
MYFILE *fp = myfopen("demo.txt", "w");
if(fp == NULL)
{
printf("my_fopen error\n");
return 1;
}
test();
return 0;
}
可以看到,这里我们直接运行myfile并没有直接把printf信息打印到了我们的显示器里面去,接下来我们来看一看对应的demo.txt文件是否有hello Linux
可以看到,这里的demo.txt确实是已经把我们原本写入stdout的内容写入到了对应的demo.txt文件,关于dup2系统调用的介绍就暂时到这里了。
我们知道stdout是向标准输出流打印消息,而stderr是向标准错误打印东西,但是就是从效果来看,最后都打印到了显示器里面。那么可以说这两个流都是作用到同一个文件吗?答案并不是,接下来我们来看这么一段代码
void test()
{
//printf就是默认打印到stdout的
printf("printf to stdout\n");
//fputs往stdout里面打印
fputs("fputs to stdout\n",stdout);
//perror往stderr打印
perror("perror to stderr\n");
fputs("fputs to stderr\n",stderr);
}
从目前的表现来看,确实两者都是往显示器打印。那么接下来我们进行一个重定向操作:
./myfile > out.txt
这时候,我们会发现只剩下了对应的写入stderr的内容依旧被打印到了显示器上!事实上,虽然stdout和stderr默认都是打印到显示器,但是本质上这是两个文件描述符,指向的文件是不一样的!
而实际上我们默认使用的重定向,对应的都是对1号文件描述符重定向,本质上重定向还可以这么写
./myfile 1>out.txt
#对应重定向对应的stderr的内容就可以如下的写法
./myfile 2>err.txt
而如果想要把所有的内容都重定向到一个文件里面去,那么就要这么做
./myfile > total.txt 2>&1
1 ./myfile ---->执行可执行程序
2. > total.txt 默认把打印到stdout的文件重定向到total.txt,此时1号文件描述符的位置已经指向了total.txt
3. >2&1 表示把2号文件描述符的内容指向了1号文件描述符,底层调用了dup2(1,2) —>让2成为1的一份拷贝,由于1已经指向了total.txt,所以最后对应的2号文件描述符也指向了total.txt!
而如果是下面这种写法呢,就是大错特错了
./myfile 2>&1 >total.txt
这个表示把2号文件描述符指向1号文件描述符,由于本身就是都指向显示器,所以并没有起到实际的作用,最后再把1号文件重定向到total.txt,1号文件确实指向了total.txt,可是2号文件描述符指向的还是被改变指向之前的stdout,也就是屏幕。所以这系列操作下来,本质上还是没把2号文件描述符重定向到total.txt中。
顺便提一提:C++使用的cout和cerr也是分别打印到stdout和stderr里面去。
以上就是这篇文章的全部内容,如有不足的地方,还望可以指出。希望大家一起进步。