Linux 基础IO (三) (用户缓冲区/内核缓冲区深刻理解)

目录

一、观察以下现象

现象一

​编辑

​编辑

现象二

现象三

现象四

现象五

​编辑

​编辑

现象六

二、深入理解缓冲区

几个问题

缓冲区的刷新模式(缓冲类型)都有哪些?

[1. 行缓冲](#1. 行缓冲)

[2. 全缓冲](#2. 全缓冲)

[3. 无缓冲](#3. 无缓冲)

进程结束时的缓冲区刷巡逻辑

三、解释现象五

​编辑

数据刷新的本质:

为什么要有用户缓冲区?

那么这个用户缓冲区存在于哪里

四、现象六解释

五、现象四解释

六、现象三解释

七、对比实验三和实验四

深入理解写时拷贝

八、总结:


一、缓冲区

1.1 什么是缓冲区

缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。

1.2 为什么要引入缓冲区机制

读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。

为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。

又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。

1.3 缓冲类型

标准I/O提供了3种类型的缓冲区。

    1. 全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
    1. 行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作,默认行缓冲区的大小为1024。
    1. 无缓冲区:无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。

除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:
1. 缓冲区满时;
2. 执行flush语句;
3. 进程结束

1.4 FILE

因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。所以C库当中的FILE结构体内部,必定封装了fd。

可以看看FILE结构体:

bash 复制代码
typedef struct _IO_FILE FILE; //在/usr/include/stdio.h
 
 
在 /usr/include/libio.h 
 
c
  
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
};

二、观察以下现象

现象一

下面我们分别通过四个接口输出"hello xxx"到显示器:三个C语言文件接口(printf对应stdout、fprintf对应stdout、fwrite对应stdout)和一个系统调用write(对应1号文件描述符)。

运行结果:

  1. printf :标准库函数,默认输出到标准输出(stdout)
  2. fprintf :标准库函数,指定输出流(此处为stdout)
  3. fwrite :标准库函数,以二进制方式向流写入数据(stdout)
  4. write :系统调用,直接向文件描述符(1对应stdout)写入数据

依次调用上述4个函数,向标准输出打印对应字符串,最终运行结果与代码逻辑一致。

补充一下关于fwrite和write的返回值的区别

下面补充一下关于fwrite和write的区别,尤其是返回值的区别

fwrite是基于系统调用封装的C标准库函数,作用是向指定的文件流( FILE 对象)写入数据,会使用标准库的用户态缓存,提升IO效率。

  1. ptr是待写入的数据地址;
  2. size是单个数据单元的字节数;
  3. nmemb是数据单元的数量;
  4. stream是目标文件流(如 stdout )。

返回值:返回成功写入的数据单元数量( size_t 类型)。

write是操作系统提供的系统调用,功能是直接向文件描述符(内核管理的IO对象)写入数据,无用户态缓存,是更底层的IO操作。

  1. fd是目标文件描述符(如 1 对应标准输出 stdout );
  2. buf是待写入的数据地址;
  3. count是待写入的字节总数。

返回值:成功写入的字节数( ssize_t 类型,可返回负数表示错误)。

运行结果:

  1. fwrite 的返回值是成功写入的数据单元数量(对应函数参数里的 nmemb),因此返回 1 表示成功写入了 1 个完整的数据单元。
  2. write 的返回值是成功写入的字节总数,因此返回 12 表示成功写入了12个字节。

现象二

和现象一同样的代码,只不过这里我们进行了重定向操作到文件log.txt中

运行结果:

问题 :

将打印内容重定向到 log.txt 文件中,为什么打印顺序发生了变化? 系统调用的接口函数 write 为什么被最先打印了?

  • 这是因为write是系统调用,它能直接将数据写入内核缓冲区,而不像C语言的printf/fprintf/fwrite等接口那样需要经过用户缓冲区的全缓冲策略。内核缓冲区有独立的刷新机制,当数据写入内核缓冲区后,就认为已经传递到文件描述符1对应的硬件设备上了,因此log.txt中第一条出现的就是write写入的信息。

现象三

那么接下来使用fork函数创建子进程,观察现象

运行结果:

  1. 从运行结果可以看出打印结果包括打印顺序都和现象一一样,当 fork 在四个接口(其中三个是 C 语言文件接口:printf、fprintf、fwrite 对应 stdout,另一个是系统调用 write 对应 1 号描述符)向显示器输出 "hello xxx" 信息之后,创建的子进程实际上不会执行任何操作。这一点很好理解,因为子进程会从父进程的执行位置继续运行。由于父进程已经执行了四个接口函数完成了屏幕输出,且父进程的执行流在 fork 之后就会返回,所以子进程只会执行 fork 的返回值处理就结束了。
  2. 这时很多读者可能会产生疑问:这些演示现象和本文要讲解的用户缓冲区有什么关联呢?况且这些现象都很容易理解,又能说明什么问题呢?请大家稍安勿躁,相信接下来的第四个现象会给大家带来一些意想不到的发现。

现象四

和现象三同样的代码,只不过这里再次进行了重定向操作到文件log.txt中

运行结果:

  1. 奇怪,为什么会出现这种现象?我们只是简单地将fork后的输出重定向到文件中,文件里却出现了7行信息。观察现象发现:C语言接口函数(printf、fprintf、fwrite)各输出了两条信息,共六条;而系统调用接口write却只输出了一条。
  2. 更奇怪的是,虽然write是最后打印到显示器的,但在重定向文件中,write对应的信息却最先被写入(这种结果和现象二一样)。对比现象二(无fork)和现象四(有fork)的代码与重定向结果,发现两者输出不同,这说明问题肯定与fork操作密切相关。
  3. 接下来我将详细讲解文件缓冲区的机制。不过在正式讲解之前,还需要先介绍两种关键现象。

现象五

在仅使用C语言的printf、fprintf和fwrite三个接口向显示器输出信息的情况下,当通过文件系统调用close关闭显示器对应的文件描述符1后,观察到所有输出信息末尾均带有换行符\n的现象。

运行结果:

此时尽管我们添加了close(1),此时信息可以打印,很好理解

但是如果我们再将信息中的换行\n去掉呢?此时信息还可以打印吗?

运行结果:

此时信息一个却都没有打印到屏幕上,什么原因呢?

  • 这里简单解释一下:我们知道**'\n'可以刷新缓冲区** 。C语言的三个输出函数printf、fprintf和fwrite会将信息暂存到缓冲区,等待合适的时机输出,而遇到'\n'就是触发刷新的条件之一。但在本例中,输出内容没有包含'\n',而且在**进程结束前还调用了close函数关闭了文件描述符1(对应显示器文件),导致缓冲区未被刷新,所以信息未能显示出来。**这只是一个初步的解释,后续会进行更详细的讲解。

我们继续注释掉 close(1),观察现象

运行结果:

注释 close(1) 后有输出的原因是因为程序正常结束时,系统会自动触发标准库缓存的刷新操作,将缓存中的数据写入已打开的文件描述符(此处为标准输出),因此内容会被打印(但因无 \n ,输出会紧跟在命令提示符后)。

现象六

当调用文件系统的write操作向显示器输出信息时,每条信息末尾都带有换行符\n。随后使用close系统调用关闭显示器文件对应的文件描述符1(标准输出),可以观察到输出行为的变化。

运行结果:

此时信息可以正常被打印,这很好理解

但是如果我们继续将信息中的换行\n去掉呢?此时信息还可以打印吗?

运行结果:

此时尽管去掉了换行\n,使用了close将显示器文件对应的文件描述符1关闭,但是此时的文件系统调用仍然可以打印出信息,又该如何理解,又该怎么解释?
接下来,我们将深入探讨用户缓冲区与内核缓冲区的运行原理。理解这些基础知识后,就可以很好的将上述的六种现象逐个很好的理解了

三、深入理解缓冲区

几个问题

缓冲区的刷新模式(缓冲类型)都有哪些?

1. 行缓冲
  • 适用:标准输出(stdout,如显示器
  • 规则:数据先存用户缓冲区遇 \n 、缓冲区写满,或进程正常结束时,从用户缓冲区刷入内核缓冲区。
  • 只要是写给stdout(比如 printf 、 fprintf(stdout, ...) ),默认就是行缓冲;
  • 例子:在终端里执行 printf("hello world"); 写给 stdout 标准输出时,因为没 \n ,内容会先存在用户缓冲区;若补充 printf("hello world\n"); ,遇 \n 会立刻把用户缓冲区的内容刷到内核,终端实时显示。
2. 全缓冲
  • 适用:磁盘文件 (如用fopen打开的文件)
  • 规则:数据先存用户缓冲区,仅当缓冲区写满、主动调用 fflush ,或进程正常结束时,才刷入内核缓冲区;优先追求I/O效率, \n 不触发刷新。
  • 要是写给磁盘文件(比如 fopen("a.txt", "w") ),默认是全缓冲;
  • 例子:用 FILE *f = fopen("test.log", "w"); fprintf(f, "log1"); , log1 会存在用户缓冲区;哪怕写了 \n ( fprintf(f, "log1\n"); )也不会刷,要等缓冲区写满,或调用 fflush(f) ,数据才会刷到内核再写入文件。
3. 无缓冲
  • 适用:标准错误输出(stderr)
  • 规则:无用户缓冲区,数据直接写入内核缓冲区;优先保证实时性,调用即输出,不用等任何触发条件。
  • 要是写给stderr(比如 fprintf(stderr, ...) ),默认是无缓冲。
  • 例子:执行 fprintf(stderr, "error: file not found"); ,不管有没有 \n ,数据会直接进内核缓冲区,终端立刻显示错误信息;哪怕程序马上崩溃,这条报错也能及时输出。

进程结束时的缓冲区刷巡逻辑

  1. 进程结束后刷新到内核缓冲区,是C标准库的"资源清理逻辑",和缓冲类型无关,属于"进程退出时的兜底操作"。
  2. 简单说:不管是行缓冲、全缓冲,只要进程正常结束(比如调用 exit 、 return 0 ),C标准库会自动触发「用户缓冲区→内核缓冲区」的刷新(本质是隐性调用了 fflush 清理所有用户层缓冲);哪怕是全缓冲没写满、行缓冲没遇到 \n ,都会被强制刷到内核------避免进程带着用户缓冲区的数据"跑路",导致数据丢失。
  3. 只有无缓冲因为没有用户缓冲区,不需要这一步,数据早就在内核里了。

数据刷新到屏幕上的统一逻辑

  1. 用户缓冲区--->用户缓冲区--->硬件
  2. 所有缓冲类型的统一最终路径------用户层的缓冲逻辑只是"数据何时进内核 "的前置规则,最终都要走「内核缓冲区→硬件」这一步,没有例外。
  3. 行缓冲/全缓冲:先在用户缓冲区"攒数据",按规则刷到内核,再等内核调度到硬件;
  4. 无缓冲:直接跳过用户缓冲区,数据直奔内核,再由内核送硬件。
  5. 简单说:用户缓冲区是"前端收纳站"(可有无/可按规则发车),用户缓冲区是"必经中转站",硬件是"最终目的地",所有数据都得经内核缓冲区这一站才能到硬件。

四、解释现象五

解释现象五,现在我们重新看现象五这三幅图:

第一幅图(写了 \n + 关闭文件描述符1):

能正常打印
第二幅图(没写 \n + 关闭文件描述符1):

无法打印
第三幅图(没写 \n + 没关闭文件描述符1)

能正常打印但是没换行

解释:

  1. 先确定缓冲类型:这三幅图里的输出( printf/fprintf/fwrite )都是写给 stdout (标准输出,对应显示器),所以默认是行缓冲
  2. 行缓冲的刷新规则:行缓冲的核心是「遇 \n /缓冲区满/进程正常结束」时,把用户缓冲区→内核缓冲区,再由内核送硬件显示。
  3. 文件描述符1(fd=1)的作用:fd=1是 stdout 对应的"用户层→内核层"的唯一通路------用户缓冲区的数据要刷到内核,必须通过fd=1这个"门";内核也通过fd=1识别"数据要送到显示器"。
  4. 第一幅图(写了 \n + 关1):首先执行 printf/fprintf/fwrite 时,数据先进用户缓冲区;因为有 \n ,触发行缓冲的刷新规则,此时fd=1还没关,数据通过fd=1刷到内核缓冲区;之后 close(1) 关闭fd=1,但数据已经在核内了,最终能显示。
  5. 第二幅图(没写 \n + 关1):数据进用户缓冲区,但没 \n ,行缓冲没触发刷新;提前 close(1) 拆了"用户→内核"的通路进程结束时,C标准库想刷用户缓冲区,但fd=1已经失效,数据没法进内核,直接丢失,所以没显示。
  6. 第三幅图(没写 \n + 没关1):数据进用户缓冲区,没 \n 所以没主动刷;进程结束时,C标准库触发"兜底刷新",此时fd=1是存活的,数据通过fd=1刷到内核,最终显示。

在fd1失效那块和close(1)后资源被释放导致找不到对应的文件描述符1,这个过程我们再梳理一下:

  1. 首先程序启动时 ,系统会自动给进程分配3个默认文件描述符,fd=0(stdin,标准输入),fd=1(stdout,标准输出),fd=2(stderr,标准错误),此时,fd=1对应一个"内核级的文件打开对象",用户层的 printf 等函数,都通过fd=1和这个对应的文件进行连接。
  2. 后来执行 close(1) 时close(1) 做了两件关键事:1. 标记fd=1为"无效" :从进程的"文件描述符表"里把fd=1删掉;2. 释放对应的内核文件对象:系统会回收fd=1绑定的"内核文件对象"。此时,用户层再想通过fd=1传数据,就彻底和对应的文件断开连接了,即找不到这个文件了。
  3. 最后到进程结束尝试刷用户缓冲区, 进程结束时,C标准库会遍历所有用户缓冲区,执行"兜底刷新":它会尝试调用 write(1, 数据, 长度) ;但此时fd=1已经被标记为"无效",内核会返回"文件描述符不存在"的错误;最终导致打印失败。

数据刷新的本质:

用户刷新的本质其实就是将数据通过未关闭的文件描述符+write将数据写入内核中

  • 这是操作系统的"分层隔离"设计决定的------用户层程序没有权限直接操作内核/硬件,必须通过"文件描述符+系统调用"这层"安全接口"来传递数据。
  • 操作系统把"用户层"和"内核层"分成了两个隔离的区域:1. 用户层:只能操作自己的内存(比如用户缓冲区),没有权限碰内核内存、硬件设备;2. 内核层:垄断了硬件的操作权,只有它能把数据写到显示器/磁盘。
  • 所以,用户层的"刷新"本质是:通过 write 这个系统调用(用户层→内核层的"合法通行证"),把用户缓冲区的数据,以文件描述符为"标识"(告诉内核"这数据要写给谁"),传递到内核管理的缓冲区里。

为什么要有用户缓冲区?

  1. 提升效率的关键在于优化数据写入流程。当使用C语言的文件接口时,频繁调用write函数会带来性能损耗:每次调用都需要查找未关闭的文件描述符对应的文件对象,并将数据刷新到内核缓冲区。假设调用100次write函数处理100份数据,或者1000次调用处理1000份数据,这种频繁操作会导致效率显著下降。
  2. 更高效的方案是将数据暂存在用户缓冲区,待积累到一定量后,仅需一次write调用即可将所有数据写入内核缓冲区。随后操作系统会将这些数据批量写入硬件设备。这种批处理方式大幅提升了I/O效率,这也是高级语言性能优势的重要体现。
  3. 关于格式化处理,需要理解显示设备的本质特性。显示器只能呈现字符数据,而非原始数据类型。以printf("%d\n", a)为例:首先将数据格式化写入用户缓冲区,再由缓冲区转换为字节流。同理,scanf("%d", &a)从键盘接收的"100"最初也是字符序列,缓冲区负责将其转换为整型数据后存入变量a。因此,printf和scanf这类函数被称为格式化I/O函数,缓冲区的存在为数据格式化提供了必要支持。

那么这个用户缓冲区存在于哪里

  1. C语言的文件操作离不开FILE,这个FILE的本质其实就是结构体 ,这个结构体中有封装的文件描述符fd,同时这个FILE中还有对应打开文件的缓冲区字段和维护信息
  2. 这个FILE对象是属于用户呢?还是属于操作系统呢?这个缓冲区是否属于用户级的缓冲区呢?
  3. 只要是语言级别的都属于用户层,FILE对象是在C语言层次,所以FILE对象属于用户,这个缓冲区属于用户级的缓冲区
  4. FILE所以既然属于用户,并且FILE中还有对应的缓冲区字段,而C语言的文件操作离不开FILE,其实这个用户缓冲区就存在于FILE中
  5. 那么此时我们看一下C语言打开文件使用的文件接口函数fopen,返回的FILE其实就是语言层给我们malloc(FILE),所以这个用户缓冲区存在的位置更详细点讲是存在于堆上,FILE中会存储有对应的指针,存储文件缓冲区对应的堆空间的地址,所以通过FILE自然可以进行文件操作,C语言文件操作函数就可以借助FILE找到指向文件缓冲区对应堆空间的地址,此时就可以向缓冲区中写入数据了
  6. 此时我们再看一下fprintf(stdout, "hello fprintf\n"); 此时再来解释一下,其实将hello fprintf\n 写入到文件流FILE* stdout中对应的用户缓冲区对应的堆空间上,此时stdout对应的文件描述符1是显示器文件,显示器文件的刷新策略是行刷新,此时hello fprintf\n 的结尾有换行\n,所以遇到了换行\n,此时就会调用底层的write以及找到文件描述符1,向内核缓冲区中写入数据,当写入到内核缓冲区中之后,此时数据就会被写入到硬件中了,但是并不是即刻刷新,内核关于数据有自己的刷新策略,这里为了便于理解,我们目前认为只要将数据写入到内核缓冲区后,数据就会被刷新到硬件上

五、现象六解释

在现象六中,即使显示器文件采用行刷新机制且打印信息不含换行符\n,同时在使用close关闭文件描述符1后,write系统调用仍能将信息输出到显示器上,这是为什么呢?

这是因为write作为系统调用 ,本质上是操作系统为用户提供的接口**。系统调用运行在内核层** 面而非用户层,**当write获取到待输出信息和文件描述符1后,会直接将数据写入内核缓冲区。无论是进程终止还是通过close关闭文件描述符1,操作系统都会高效地将内核缓冲区中的内容刷新到硬件设备。**具体过程是:系统会根据文件描述符1找到对应的struct file对象,进而将数据写入到该文件关联的硬件设备中。因此,即使输出信息不含换行符且文件描述符被关闭,write仍能确保信息正确显示在显示器上。

六、现象四解释

需要明确的是,文件操作的缓冲区刷新策略中,向普通文件写入时采用的是全缓冲模式 。这意味着即使数据中包含换行符\n,也不会立即刷新缓冲区,而是等待缓冲区填满后才执行刷新操作。值得注意的是,当缓冲区未满但进程结束时,系统也会自动执行刷新。

虽然代码原本是向显示器文件写入,但通过重定向操作后,实际上就变成了向普通文件写入。此时刷新策略会切换为全缓冲模式。

为什么write对应的信息出现在第一位?

  1. 这是因为write是系统调用,它能直接将数据写入内核缓冲区,而不像C语言的printf/fprintf/fwrite等接口那样需要经过用户缓冲区的全缓冲策略。内核缓冲区有独立的刷新机制,当数据写入内核缓冲区后,就认为已经传递到文件描述符1对应的硬件设备上了,因此log.txt中第一条出现的就是write写入的信息。

为什么C语言接口函数(printf、fprintf、fwrite)分别打印了两次?

  1. 当执行 ./operfile > log.txt 时,输出被重定向到磁盘文件 log.txt ,按照系统规则,磁盘文件对应的缓冲类型是全缓冲(原本显示器对应的行缓冲会切换成全缓冲 )。程序执行 printf/fprintf/fwrite 这些C标准库函数时,因为是全缓冲,数据不会立刻输出,而是先存在父进程的用户缓冲区里而 write(1, ...) 是系统调用,它不经过用户缓冲区,会直接把数据写入内核缓冲区,再由内核写入 log.txt ,所以这部分内容此时已经成功写入文件。
  2. 之后执行 fork() 创建子进程, fork() 前用户缓冲区的状态因为输出被重定向到文件(全缓冲), printf/fprintf/fwrite 的内容存在父进程的用户缓冲区里。**此时父进程的用户缓冲区内存是"可读可写"的,但因为还没被修改,属于"共享状态"。fork() 后写时复制的触发,fork() 默认会让父子进程共享内存页(包括用户缓冲区),但会把这些内存页标记为"只读"------此时父子进程的用户缓冲区是共享且内容一致的。**当父子进程各自结束时,会触发全缓冲的"进程结束兜底刷新 "规则:当父子进程后续要修改/刷新用户缓冲区(进程结束时的兜底刷新),会触发"写时复制"------内核会为子进程复制一份独立的用户缓冲区内存页,此时父子进程各有一份完全相同的用户缓冲区内容。进程结束时各自刷新自己的缓冲区父子进程结束时,都会各自刷新自己的用户缓冲区(因为是全缓冲的兜底规则),相当于把"相同的内容"往文件里写了两次,所以printf/fprintf/fwrite 的内容会重复输出。
  3. 这里父子进程各自触发写时复制时,都会获得独立的内存页------不是只给某一方,而是"谁先操作,谁先复制"。fork() 后,父子进程共享父进程的内存页(包括用户缓冲区)且标记为只读 。当父进程先结束,要刷新用户缓冲区给内核缓冲区(操作 这块内存),此时内核会为父进程复制一份独立的用户缓冲区内存页;之后子进程结束,也要刷新用户缓冲区给内核缓冲区(操作共享的内存),内核会再为子进程复制一份独立的用户缓冲区内存页。最终,父子进程各自持有一份完全相同的用户缓冲区副本,会先后两次把数据刷到内核缓冲区,再由内核两次写入硬件(这里的硬件是磁盘文件log.txt),所以会看到重复输出两次。所以才会各自刷新、导致内容重复。
  4. 最终,write 的内容因为没进用户缓冲区,只输出了一次;而 printf/fprintf/fwrite 的内容因为父子进程各有一份用户缓冲区的副本,各自刷新了一次,所以在 log.txt 里出现了两次,这就是你看到的输出结果的由来。

七、现象三解释

关于现象三中同样存在fork操作但未进行重定向的情况,为什么C语言文件接口(printf/fprintf/fwrite)只输出一份数据而非两份

  1. 当不进行重定向时,输出目标是显示器文件,此时用户缓冲区的刷新策略为行缓冲模式 。由于输出信息中包含换行符\n**,C语言文件接口会通过底层封装的write系统调用(使用文件描述符1)直接将数据写入内核缓冲区**。操作系统随后通过文件描述符1找到对应的文件打开对象地址,最终将数据写入硬件设备(显示器文件)。因此,所有数据都能及时输出到硬件设备上。
  2. 同时,write系统调用会直接将数据写入内核缓冲区,同样会到达硬件设备(显示器文件)。在fork创建子进程时,用户缓冲区的数据已全部刷新完毕,缓冲区中不再残留数据。因此父进程和子进程都不会对用户缓冲区进行修改,也就不会触发写时拷贝机制。最终,C语言文件接口的输出数据只会显示一份。

八、对比实验三和实验四

实验三和实验四的区别在于有没有进行重定向

实验四进行重定向到文件(全缓冲+重复输出):
为什么"刷新用户缓冲区"会触发写时复制?

  • fork() 后,父子进程共享的用户缓冲区被标记为"只读"------当父子进程结束,要把用户缓冲区的数据刷到内核时,这个"读取用户缓冲区数据+发起刷新"的操作,属于对"只读共享内存"的写操作(广义的操作,包括读取后输出),会打破"只读共享"状态,触发写时复制。

两次刷新的具体流程:

  • 写时复制为父子进程各复制一份独立的用户缓冲区副本后,父进程先把自己的副本通过fd=1(已重定向到文件,fd有效)刷进内核缓冲区,内核写入文件;子进程再把自己的副本刷进内核缓冲区,内核再次写入文件------两次独立的"用户→内核→文件"流程,导致重复输出。

实验三未重定向到显示器(行缓冲+只输出一次):
为什么"提前刷到内核"就不会触发写时复制?

  • 行缓冲下 \n 触发的刷新,是在 fork() 之前就完成的------数据从用户缓冲区→内核缓冲区后,用户缓冲区就空了。 fork() 复制的是"空的用户缓冲区",后续父子进程结束时,没有"用户缓冲区数据要刷新"的操作,自然不会触发写时复制;而内核缓冲区属于共享资源,父子进程共享同一份已刷入的内核数据,最终内核只需要写一次到显示器,所以只输出一次。

补充: write 系统调用的特殊性

  • 不管是否重定向, write 都直接写内核,不经过用户缓冲区------所以 fork() 前后, write 的数据都只进一次内核,不会因写时复制重复,这也是它在两种场景下都只输出一次的核心原因。

深入理解写时拷贝

写时拷贝的补充说明(结合操作类型与内存特性)
1. 发生时机的精准定义:

  • "修改/操作用户层内存"不仅指"写数据",还包括"读取内存数据并用于输出(如刷新缓冲区)"------只要操作会打破 fork() 后的"只读共享内存"状态,就会触发写时复制。
  • 比如重定向场景中,"刷新用户缓冲区"是读取用户数据并发起 write 系统调用,属于"触发写时复制的操作";而如果父子进程都只读取用户内存不做输出/修改,不会触发写时复制。
  1. 针对范围的边界:
  • 明确"用户层内存"的具体内容:除了刷新用户缓冲区,还包括修改进程的全局变量、局部变量所在的栈/堆内存、代码段(若代码段被标记为可修改)等,这些都是 fork() 后会被标记为"只读共享"的区域,操作即触发写时复制。
  • 明确"内核层资源不复制"的后果:父子进程共享同一个内核文件对象(如fd=1对应的文件)和内核缓冲区------所以即使父子进程各刷一次用户缓冲区,最终都是往同一个内核缓冲区写数据,再由内核统一写入硬件(文件/显示器),不会出现"内核层数据重复",重复只来自用户层副本的两次刷新。

两种场景差异的本质闭环

两种场景输出次数不同,本质是"缓冲类型决定用户缓冲区是否残留数据 "→"残留数据决定fork()后是否有触发写时复制的操作 "→"写时复制决定是否有两份用户数据要刷新":

  1. 重定向(全缓冲)→用户缓冲区残留数据→fork()后有"刷新操作"→触发写时复制→两份数据→两次输出;
  2. 未重定向(行缓冲)→ \n 提前清空用户缓冲区→fork()后无"刷新操作"→不触发写时复制→一份数据→一次输出。

到这里,整个"缓冲-刷新-fork-写时复制"的逻辑就完全闭环

我的再次总结:

整个来说,无论有没有重定向到log.txt文件,首先系统调用write打印的内容会先进入内核缓冲区,这是因为write是系统调用接口,能将数据直接写入内核缓冲区,从而传递到硬件设备使最先被打印。

下来是这几个C语言文件接口函数,当被重定向到log.txt文件时,缓冲模式由行缓冲变为全缓冲,此时的数据只有用户缓冲区内容写满或主动调用fflush或进程结束才会被刷新到内核缓冲区,但是这里用户缓冲区写满不能作为刷新条件因为我们不知道何时能写满,并且我们也没有主动调用fflush,所以只有能进程结束后数据才会被刷新到内核缓冲区中,但是在进程结束前发生了fork,此时数据仍在用户缓冲区中,fork之前数据在父进程的用户缓冲区内,是可读可写的,当fork创建子进程后,父子进程会共享用户缓冲区并标记为只读,此时父子进程的用户缓冲区内容共享且一致,但是当父子进程结束时触发全缓冲进程结束刷新到内核缓冲区的机制,同时这个过程是用户层面的内存修改操作,此时就会触发写时拷贝------内核会为子进程复制一份独立的用户缓冲区内存页,此时父子进程各有一份相同的用户缓冲区内容,在后来当进程结束时会将父子进程分别刷新到同一内核缓冲区,最后再由内核两次写入硬件打印到屏幕上,所以此时就能看到被打印了两次。

当没有重定向到log.txt文件时,是行缓冲模式,首先数据会先刷新到用户缓冲区,遇到\n后会再被刷新到内核缓冲区,这时fork创建了子进程,当数据在内核缓冲区时就不会触发写时拷贝,所以在内核缓冲区的数据就直接传递给硬件设备进而被打印且只打印一遍。

九、总结:

本文通过六个实验现象深入探讨了用户缓冲区和内核缓冲区的工作原理。实验对比了C语言文件接口(printf/fprintf/fwrite)和系统调用write的输出差异,重点分析了缓冲区刷新策略(行缓冲、全缓冲、无缓冲)、文件描述符作用以及fork操作对缓冲区的影响。研究发现:1)C语言接口使用用户缓冲区,受缓冲类型控制;2)write直接写入内核缓冲区;3)重定向会改变缓冲策略;4)fork可能导致用户缓冲区数据重复输出。这些现象揭示了操作系统分层设计中用户层与内核层的交互机制,以及缓冲区在提升I/O效率中的关键作用。

感谢大家的观看!

相关推荐
九天轩辕2 小时前
跨平台符号表生成规则详解:Windows/Linux/macOS/OHOS
linux·windows·macos
biter down2 小时前
C++的IO流
开发语言·c++
一起搞IT吧2 小时前
Android功耗系列专题理论之十四:Sensor功耗问题分析方法
android·c++·智能手机·性能优化
无心水2 小时前
【常见错误】1、Java并发工具类四大坑:从ThreadLocal到ConcurrentHashMap,你踩过几个?
java·开发语言·后端·架构·threadlocal·concurrent·java并发四大坑
困死,根本不会2 小时前
蓝桥杯python备赛笔记之(八)动态规划(DP)
笔记·python·学习·算法·蓝桥杯·动态规划
weixin199701080162 小时前
货铺头商品详情页前端性能优化实战
java·前端·python
蜜獾云2 小时前
linux-磁盘挂载
linux·运维·服务器
whycthe2 小时前
c++动态规划算法详解
c++·算法·动态规划
清 澜2 小时前
深度学习连续剧——手搓梯度下降法
c++·人工智能·面试·职场和发展·梯度