文件缓冲区

文件缓冲区

七月 18, 2025 次阅读

首先来看一段代码:

#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种类型的缓冲区:

  1. 全缓冲区

    • 这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。
    • 对于磁盘文件的操作通常使用全缓冲的方式访问。
  2. 行缓冲区

    • 在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。
    • 当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。
    • 因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作。
    • 默认行缓冲区的大小为1024。
  3. 无缓冲区

    • 无缓冲区是指标准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
╰─➤

再看开头代码

开头的代码为什么会出现这种现象呢?

情景再现:

标准输出行缓冲

首先,当我们让直接让数据打印到标准输出的时候,C/C++采用的是行刷新策略,因为数据打印都有空行,导致每次打印的数据会直接被语言刷新出去,因此现象符合你的预期

文件输出全缓冲

当我们将标准输出重定向到普通磁盘文件的时候,数据的缓冲方式由行缓冲变成了全缓冲,而我们放在缓冲区的数据就不会被立即刷新,即便是在 fork 之后,这些数据
会在进程退出之后统一被刷新写到文件当中

但是 fork 的时候,父子数据会发生写时拷贝,所以当父进程或子进程任意一方刷新缓冲区(进程退出)的时候触发写时拷贝,另一方在退出进程的时候同样会刷新缓冲区(另一方
不会发生写时拷贝,因为这段缓冲区数据在页表当中引用计数为1),而 write 不会等进程退出时才刷新缓冲区,这样我们就会发现 write 先被写入,而用户层的接口在进程退出时
才依次被写入文件

缓冲模式差异

输出方式 缓冲类型(终端) 缓冲类型(重定向到文件) 刷新时机
printf/fprintf 行缓冲 全缓冲 行缓冲:遇到 \n 或缓冲区满;全缓冲:缓冲区满或程序退出
fwrite 行缓冲 全缓冲 同上
cout 行缓冲 全缓冲 同上(C++ 的 ostream 默认与 stdout 同步)
write 无缓冲 无缓冲 立即写入内核缓冲区
  • 终端设备:默认行缓冲(用户友好,逐行显示)。
  • 磁盘文件:默认全缓冲(减少频繁 I/O,提升性能)。

fork() 对缓冲区的关键影响

当程序调用 fork() 时:

  1. 用户层缓冲区(C/C++库维护)

    • 全缓冲模式下,数据仍在用户空间缓冲区中,未写入内核。
    • fork() 会复制父进程的整个地址空间(包括缓冲区),触发 写时拷贝(Copy-On-Write)
    • 父/子进程退出时:各自刷新缓冲区,导致数据被写入文件两次。
  2. 系统调用 write

    • 无缓冲,直接进入内核缓冲区,fork() 前已完成写入。
    • 不受 fork() 影响,仅输出一次。
  3. fork() 后的初始状态

    • 父进程和子进程共享相同的物理内存页(包括用户层缓冲区),内核将这些页标记为 写时拷贝(引用计数 > 1)。
    • 此时缓冲区数据(如 printf 的内容)尚未被修改,父子进程的页表指向同一物理内存。
  4. 某一方刷新缓冲区(如父进程退出)

    • 刷新操作(如 exit() 调用 fflush())会 修改缓冲区(清空内容并写入文件)。
    • 内核检测到该内存页是 COW 的,触发 写时拷贝
      • 为父进程分配新的物理页,复制原数据。
      • 父进程修改新页(清空缓冲区),子进程仍引用旧页(原数据)。
      • 旧页的引用计数降为 1(仅子进程持有)。
  5. 另一方退出时(如子进程退出)**

    • 子进程刷新缓冲区时,内核检查其引用的内存页:
      • 发现该页 引用计数 = 1(无共享),无需 COW,直接修改并写入文件。
    • 因此子进程的刷新操作不会重复触发写时拷贝。

文件缓冲区的作用

1.提高效率
就好比送快递,快递员不可能只送你一个人的快递,而是等库存足够多的时候再将快递送出去,语言层次的用户级缓冲区使得数据不会频繁交给内核区,避免了两态之间的频繁交换

2.配合格式化
除了效率问题,因为 C/C++ 需要对你的数据做格式化(如 %d,%s),因此可以利用缓冲区先将未格式化字符串存入缓冲区,再根据可变参数模板中的参数进行数据格式化