文件缓冲区
首先来看一段代码:
#include <iostream>
#include <cstring>
#include <unistd.h>
using namespace std;
int main()
{
const char *str1 = "fwrite: hello, linux!\n";
const char *str2 = "write: hello, linux!\n";
// C/C++提供的文件接口
printf("printf: hello, linux!\n");
fprintf(stdout, "fprintf: hello, linux!\n");
fwrite(str1, strlen(str1), 1, stdout);
cout << "cout: hello, linux!\n";
// Linux 系统接口
write(1, str2, strlen(str2));
fork();
return 0;
}
当我们直接输出的时候结果是这样的:
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ ./file_buf.o
printf: hello, linux!
fprintf: hello, linux!
fwrite: hello, linux!
cout: hello, linux!
write: hello, linux!
但当我们将输出重定向到文件中去是这样的:
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ ./file_buf.o > log.txt
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ cat log.txt
write: hello, linux!
printf: hello, linux!
fprintf: hello, linux!
fwrite: hello, linux!
cout: hello, linux!
printf: hello, linux!
fprintf: hello, linux!
fwrite: hello, linux!
cout: hello, linux!
我们会发现,有些数据不仅输出了两遍,而且 write 函数明明是后面才打印的,现在却变成了最先开始被打印出来
如果你不是很清楚原因的话,这篇文章将会对你有帮助
理解文件缓冲区
什么是缓冲区?
缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间用来缓冲输入或输出的数据,
这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区
那么,我们为什么要引入缓冲区机制呢?
读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进⾏操作(读、写等),那么每次对文件进行⼀次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行⼀次系统调用,执行⼀次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。
为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以⼀次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提⾼计算机的运行速度。
又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它用在输⼊输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调⼯作,避免低速的输⼊输出设备占用CPU,解放出CPU,使其能够高效率工作。
不论是在语言层还是系统层都存在缓冲区,系统层的缓冲区是为了优化硬件交互和调度,因此即使应用层直接调用write(),数据也可能先被放入内核的页缓存,由内核决定何时写入磁盘。
标准I/O的缓冲区类型
标准I/O提供了3种类型的缓冲区:
全缓冲区
- 这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。
- 对于磁盘文件的操作通常使用全缓冲的方式访问。
行缓冲区
- 在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。
- 当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。
- 因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作。
- 默认行缓冲区的大小为1024。
无缓冲区
- 无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。
- 标准出错流
stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。
除此之外,当缓冲区满了,或执行 flush 语句,或进程结束的时候,C语言都会将缓冲区的内容利用 write 接口写入内存
因此,如果我们不加换行符将数据输出到标准输出,而我们在进程退出前将标准输出文件关闭,就会导致数据无法被打印到显示屏上:
#include <iostream>
#include <cstring>
#include <unistd.h>
using namespace std;
int main()
{
printf("printf: hello, linux!");
close(1);
return 0;
}
而如果我们让数据打印时换行,但是将标准输出重定向到普通磁盘文件,就会发现结果无法被打印到文件当中,因为当我们选择输出到文件的时候,缓冲区刷新策略就是全缓冲区策略,
这意味着只有在缓冲区被写满或进程结束的时候数据才会被写入,而我们在保证数据足够短的条件下让重定向被关闭,就会发现数据无法被打印到文件当中
#include <iostream>
#include <cstring>
#include <unistd.h>
using namespace std;
int main()
{
// 仅仅多了个换行
printf("printf: hello, linux!\n");
close(1);
return 0;
}
直接打印会因为行刷新策略使得数据被打印到显示屏上:
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ ./file_buf.o
printf: hello, linux!
将输出重定向到普通文件后,因为刷新策略为全缓冲,导致文件里没有被写入任何数据:
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ ./file_buf.o > test.txt
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ cat test.txt
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤
再看开头代码
开头的代码为什么会出现这种现象呢?
情景再现:
#include <iostream>
#include <cstring>
#include <unistd.h>
using namespace std;
int main()
{
const char *str1 = "fwrite: hello, linux!\n";
const char *str2 = "write: hello, linux!\n";
// C/C++提供的文件接口
printf("printf: hello, linux!\n");
fprintf(stdout, "fprintf: hello, linux!\n");
fwrite(str1, strlen(str1), 1, stdout);
cout << "cout: hello, linux!\n";
// Linux 系统接口
write(1, str2, strlen(str2));
fork();
return 0;
}
当我们直接输出的时候结果是这样的:
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ ./file_buf.o
printf: hello, linux!
fprintf: hello, linux!
fwrite: hello, linux!
cout: hello, linux!
write: hello, linux!
但当我们将输出重定向到文件中去是这样的:
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ ./file_buf.o > log.txt
╭─ljx@VM-16-15-debian ~/linux_review/lesson4
╰─➤ cat log.txt
write: hello, linux!
printf: hello, linux!
fprintf: hello, linux!
fwrite: hello, linux!
cout: hello, linux!
printf: hello, linux!
fprintf: hello, linux!
fwrite: hello, linux!
cout: hello, linux!
标准输出行缓冲
首先,当我们让直接让数据打印到标准输出的时候,C/C++采用的是行刷新策略,因为数据打印都有空行,导致每次打印的数据会直接被语言刷新出去,因此现象符合你的预期
文件输出全缓冲
当我们将标准输出重定向到普通磁盘文件的时候,数据的缓冲方式由行缓冲变成了全缓冲,而我们放在缓冲区的数据就不会被立即刷新,即便是在 fork 之后,这些数据
会在进程退出之后统一被刷新写到文件当中
但是 fork 的时候,父子数据会发生写时拷贝,所以当父进程或子进程任意一方刷新缓冲区(进程退出)的时候触发写时拷贝,另一方在退出进程的时候同样会刷新缓冲区(另一方
不会发生写时拷贝,因为这段缓冲区数据在页表当中引用计数为1),而 write 不会等进程退出时才刷新缓冲区,这样我们就会发现 write 先被写入,而用户层的接口在进程退出时
才依次被写入文件
缓冲模式差异
| 输出方式 | 缓冲类型(终端) | 缓冲类型(重定向到文件) | 刷新时机 |
|---|---|---|---|
printf/fprintf |
行缓冲 | 全缓冲 | 行缓冲:遇到 \n 或缓冲区满;全缓冲:缓冲区满或程序退出 |
fwrite |
行缓冲 | 全缓冲 | 同上 |
cout |
行缓冲 | 全缓冲 | 同上(C++ 的 ostream 默认与 stdout 同步) |
write |
无缓冲 | 无缓冲 | 立即写入内核缓冲区 |
- 终端设备:默认行缓冲(用户友好,逐行显示)。
- 磁盘文件:默认全缓冲(减少频繁 I/O,提升性能)。
fork() 对缓冲区的关键影响
当程序调用 fork() 时:
用户层缓冲区(C/C++库维护)
- 全缓冲模式下,数据仍在用户空间缓冲区中,未写入内核。
fork()会复制父进程的整个地址空间(包括缓冲区),触发 写时拷贝(Copy-On-Write)。- 父/子进程退出时:各自刷新缓冲区,导致数据被写入文件两次。
系统调用
write- 无缓冲,直接进入内核缓冲区,
fork()前已完成写入。 - 不受
fork()影响,仅输出一次。
- 无缓冲,直接进入内核缓冲区,
fork()后的初始状态- 父进程和子进程共享相同的物理内存页(包括用户层缓冲区),内核将这些页标记为 写时拷贝(引用计数 > 1)。
- 此时缓冲区数据(如
printf的内容)尚未被修改,父子进程的页表指向同一物理内存。
某一方刷新缓冲区(如父进程退出)
- 刷新操作(如
exit()调用fflush())会 修改缓冲区(清空内容并写入文件)。 - 内核检测到该内存页是 COW 的,触发 写时拷贝:
- 为父进程分配新的物理页,复制原数据。
- 父进程修改新页(清空缓冲区),子进程仍引用旧页(原数据)。
- 旧页的引用计数降为 1(仅子进程持有)。
- 刷新操作(如
另一方退出时(如子进程退出)**
- 子进程刷新缓冲区时,内核检查其引用的内存页:
- 发现该页 引用计数 = 1(无共享),无需 COW,直接修改并写入文件。
- 因此子进程的刷新操作不会重复触发写时拷贝。
- 子进程刷新缓冲区时,内核检查其引用的内存页:
文件缓冲区的作用
1.提高效率
就好比送快递,快递员不可能只送你一个人的快递,而是等库存足够多的时候再将快递送出去,语言层次的用户级缓冲区使得数据不会频繁交给内核区,避免了两态之间的频繁交换
2.配合格式化
除了效率问题,因为 C/C++ 需要对你的数据做格式化(如 %d,%s),因此可以利用缓冲区先将未格式化字符串存入缓冲区,再根据可变参数模板中的参数进行数据格式化