024 基础 IO —— 缓冲区

缓冲区

相关文章 | CSDN

1. 为什么需要缓冲区?(核心原因)

先出结论:格式化 ➔ 拼接成大数据块 ➔ 缓冲 ➔ 统一输出 ➔ 保证数据连贯,减少系统调用,提高效率!

1. 提高 I/O 效率!

硬件设备(尤其是磁盘、网络、终端)操作 很慢很慢,每次读写都直接操作设备 ➔ 太耗时 ➔ 系统负担重。

所以:

  • 少量多次 ➔ 聚集成大量一次。
  • 把「很多小的写操作」放到内存中,攒到一定量再「统一批量」写入磁盘/屏幕。
  • 减少系统调用(syscall)次数 ➔ 提高整体程序运行效率。

简单比喻:你买菜如果每买一根葱就跑一次超市,累不累?当然要一次性买一堆,装个购物袋带回来!(这就是缓冲思想)

2. 配合格式化

printf("名字:%s, 年龄:%d\n", name, age); 这种格式化输出,本质上做了两件事:

  1. 格式化处理(Format)
    • %s%d 等占位符 ➔ 根据数据类型,把 nameage 这两个变量 格式化成字符串
    • 例如:把整数 23 转成 "23",字符串 "Tom" 保持原样。
  2. 统一输出(Buffer)
    • 把格式化好的整个大字符串(比如 "名字:Tom, 年龄:23\n"统一写入 缓冲区
    • 最后一次性刷出去(flush 到终端/文件)

如果没有缓冲区,结果会很糟糕:

  • 每遇到一个小块格式化的数据就立刻 write() 系统调用 ➔ 系统调用开销非常大(context switch)!
  • 输出内容 碎片化 ➔ 终端打印时出现撕裂、乱码、数据乱序。
  • 多线程程序中可能出现 打印穿插错乱

所以:必须先格式化 → 再统一放入缓冲区 → 再统一刷新输出!这样可以:

  • 保证输出内容的原子性(一整块输出,保持顺序一致性)
  • 提高I/O效率(少系统调用)
  • 减少设备压力

2. C 语言的缓冲区(库缓冲区)

在 C 语言中,当使用 printffprintfscanffread标准 I/O 函数 时,默认是有自己的 「用户态缓冲区」 的!这些缓冲区存在于内存(堆/栈)里,由 glibc(标准 C 库)管理。注意:printf() 输出不一定立刻 write(),是因为它先写入 C 标准库的缓冲区。

3. 缓冲区分类(死记!)

类型 触发条件 典型应用
无缓冲(unbuffered) 直接写入设备,不缓存 stderr、低层 read/write
行缓冲(line buffered) 遇到换行符 \n 或者缓冲区满就刷新 stdout(连接终端时)
全缓冲(fully buffered) 缓冲区满才刷新 文件流(比如 fopen

具体解释:

1. 无缓冲(Unbuffered)

  • 直接 write 到设备。例如:stderr(标准错误)。
  • 因为错误信息要 第一时间输出,不允许缓存延迟!

2. 行缓冲(Line Buffered)

  • 只有遇到换行符 \n,或者缓冲区满了,才 flush(刷新到设备)。例如:stdout,而且是 连接到终端(屏幕) 时。
  • 为什么这么设计? ➔ 人习惯一行一行看输出,比如提示、菜单。

3. 全缓冲(Fully Buffered)

  • 要等到缓冲区塞满了(比如 4KB),才刷新到设备。例如:往文件写数据(文件 I/O)
  • 为什么这么设计? ➔ 文件操作频繁,小块数据浪费资源,聚集起来一次性写最省!

4. 缓冲区刷新的时机

刷新操作 说明
遇到换行符 行缓冲场景
缓冲区满 全缓冲、行缓冲场景
手动调用 fflush(FILE *fp) 主动要求刷新
程序正常结束时 exit() 会自动 flush 所有打开的流
文件流关闭时 调用 fclose() 会刷新并关闭流

示例代码

1. 标准输出行缓冲示例:
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main()
{
printf("Hello, "); // 暂存在行缓冲区
sleep(2); // 暂停2秒,屏幕还没输出!
printf("World!\n"); // 遇到换行符,flush 输出

return 0;
}
2. 手动刷新缓冲区示例:
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main()
{
printf("处理中..."); // 没有 \n,屏幕不会立刻看到
fflush(stdout); // 手动刷新,立刻输出到屏幕
sleep(3); // 继续做别的事情
printf("完成!\n");

return 0;
}

5. 后续语言(C++等)流式封装

C++ 的流(iostream):coutcin 就是对标准输入输出的面向对象封装。本质上也是自带缓冲区的!

流对象 缓冲模式
cout 行缓冲
cin 行缓冲

例如:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;

int main()
{
cout << "Hello"; // 存在缓冲区,暂时不会马上输出
cout << " World\n"; // 遇到换行符,flush,全部一起输出
return 0;
}

C++标准库自动帮我们管理了:

  1. 格式化处理(重载 << 操作符)
  2. 缓冲区管理(什么时候 flush)

所以,不管是 C、C++、Python、Java……只要涉及到「格式化输出」+「IO 效率」的问题,背后一定都有缓冲区!只是封装层次不同,隐藏细节不同而已。

缓冲区=效率神器,格式化=顺序保障,流=高级封装。三者互相配合,共同提升程序性能和输出正确性!


6. 缓冲区在哪?(内存位置)

  • 缓冲区是在 用户态内存程序进程的虚拟内存空间里)。
  • 由标准 C 库(glibc)自己在后台维护,比如 FILE 结构体内部就有指针指向缓冲区。

简要结构(示意):

1
2
3
4
5
6
struct _IO_FILE
{
unsigned char* _IO_buf_base; // 缓冲区起始地址
unsigned char* _IO_buf_end; // 缓冲区结束地址
...
};

7. 系统级缓冲区(内核缓冲区)

除了上面讲的「C 语言库缓冲区」,Linux 内核 也有自己的「内核缓冲区」:

  • 当使用 write(fd, buf, len)
    • 其实只是把数据拷贝到 内核态缓冲区(Page Cache)。
    • 并不是立刻真正落到磁盘。
  • 真正落盘(Flush 到磁盘) ➔ 要靠 fsync()sync() 或者内核自己异步刷盘。

8. C 语言缓冲区 vs 内核缓冲区

特点 C 语言库缓冲区(用户态缓冲) 系统内核缓冲区(内核态缓冲)
属于 用户空间 内核空间
负责 提高用户态小块 I/O 效率 提高系统磁盘 I/O 效率
刷新操作 fflush(FILE*) fsync(int fd)
刷新时机 行满/换行/手动/程序结束 写缓存异步、fsync 手动同步
举例 printf 的缓存、scanf 缓存 write 到文件、read 从文件
说明 属于标准库 stdio 由操作系统控制

C 语言缓冲区 是为了 减少用户态到内核态的系统调用次数
内核缓冲区 是为了 减少磁盘操作的次数

两者分工明确,各司其职,一起大大提高了 程序和系统整体性能


9. 重定向后缓冲策略如何变化?

当我们将标准输出 stdout 重定向到一个 文件 时,缓冲模式会从行缓冲变为全缓冲

1. 实验证明

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
printf("打印到屏幕(终端)前先睡5秒...\n");
sleep(5); // 打印了才 sleep,说明是“行缓冲”
return 0;
}

现在改一下输出到文件:

1
./a.out > temp.txt

你会发现:程序 sleep 完后,temp.txt 才会出现内容! 因为是“全缓冲”,printf 输出被缓存在内存中,直到程序结束才 flush 到文件!

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <unistd.h>

int main()
{
printf("准备输出...\n");
sleep(5);
printf("Hello, File!\n");
// fflush(stdout); // 去掉这行试试!
}

// 运行方式:./a.out > test.txt

观察:

  • 如果不加 fflush(),在 sleep 前的输出内容是否立即出现在 test.txt
  • 加了 fflush() 是否立刻输出?

2. 为什么会发生这种变化?

标准 I/O 的缓冲策略是由库函数(如 printf 所依赖的 stdio 层)自动根据 输出目标类型 决定的:

输出目标 默认缓冲模式
终端(屏幕) 行缓冲(line-buffered)
普通文件 全缓冲(fully-buffered)
管道 / socket 全缓冲(fully-buffered)
标准错误 stderr 无缓冲(unbuffered)

3. 为什么屏幕默认用行缓冲?

  • 因为用户在终端交互时,希望立刻看到输出,不能等太久。
  • 所以只要遇到换行符 \n 或缓冲区满了,就会刷新。

4. 为什么输出到文件变成全缓冲?

  • 写文件不需要实时响应,频繁刷写磁盘 开销很大
  • 所以库会自动采用 更高效的方式
    • 多次 printf() 的内容先拼到缓冲区。
    • 缓冲区满或手动调用 fflush() 时才统一写入磁盘。

[!NOTE]

了解内容:手动控制缓冲行为

setvbuf() 可以手动设置缓冲区行为:

1
2
3
setvbuf(stdout, NULL, _IONBF, 0);      // 设置 stdout 为无缓冲(unbuffered)
setvbuf(stdout, NULL, _IOLBF, 0); // 设置 stdout 为行缓冲(line-buffered)
setvbuf(stdout, NULL, _IOFBF, 0); // 设置 stdout 为全缓冲(full-buffered)

注意:setvbuf() 必须在第一次输出之前调用,否则无效!示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main()
{
// 设置 stdout 为无缓冲
setvbuf(stdout, NULL, _IONBF, 0); // 立即刷新,适用于日志输出

// 设置 stdout 为行缓冲(默认终端下行为)
// setvbuf(stdout, NULL, _IOLBF, 0);

// 设置 stdout 为全缓冲(适用于文件等)
// setvbuf(stdout, NULL, _IOFBF, BUFSIZ);
}

5. 常见情况

  1. 输出内容看不到?
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <unistd.h>

int main()
{
printf("日志输出:程序开始执行...\n"); // 重定向后不会立刻写入文件(全缓冲)
sleep(10); // 程序“暂停”10 秒,但日志还未写入
printf("日志输出:程序即将退出。\n");
return 0;
}
// 注意:查看重定向文件时:在程序运行期间它是空的,只有等程序结束后才出现内容。

解决办法:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <unistd.h>

int main()
{
printf("日志输出:程序开始执行...\n");
fflush(stdout); // 手动刷新,确保写入文件
sleep(10);
printf("日志输出:程序即将退出。\n");
fflush(stdout); // 最好在关键日志后都刷新一次
return 0;
}
  1. 程序崩溃、日志文件为空?
1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main()
{
printf("准备执行崩溃逻辑...\n");
int* p = NULL;
*p = 42; // 故意造成段错误,程序崩溃
return 0;
}

解决办法:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main()
{
printf("准备执行崩溃逻辑...\n");
fflush(stdout); // 手动刷新,日志及时写入
int* p = NULL;
*p = 42;
return 0;
}
  1. 直接设置为无缓冲模式
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <unistd.h>

int main()
{
setvbuf(stdout, NULL, _IONBF, 0); // 设置 stdout 为无缓冲模式
printf("程序正在执行...\n"); // 会立刻写入
sleep(10);
printf("程序执行结束。\n");
return 0;
}

10. 简易版本的 stdio 库的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 头文件
//#pragma once // 可选,防止头文件重复包含
#ifndef __MYSTDIO_H__ // 防止头文件重复包含(include guard)
#define __MYSTDIO_H__

#include <string.h> // 用于处理字符串函数,strcmp(), memcpy()

#define SIZE 1024 // 缓冲区大小:1KB

// 缓冲刷新策略(flush strategy)标志
#define FLUSH_NOW 1 // 每次写入立即刷新(立即写入文件)
#define FLUSH_LINE 2 // 按行刷新(检测到换行符时刷新)
#define FLUSH_ALL 4 // 缓冲区满了才刷新(默认)

// 自定义文件结构体,模拟 FILE 类型
typedef struct IO_FILE
{
int fileno; // 文件描述符
int flag; // 刷新策略
//char inbuffer[SIZE]; // 输入缓冲区(未实现)
//int in_pos; // 输入缓冲位置指针
char outbuffer[SIZE]; // 输出缓冲区(output buffer)
int out_pos; // 当前写入缓冲区的位置
}_FILE;

// 函数声明部分
_FILE* _fopen(const char* filename, const char* flag); // 打开文件(模拟 fopen)
int _fwrite(_FILE* fp, const char* s, int len); // 写入字符串到缓冲区(模拟 fwrite)
void _fclose(_FILE* fp); // 关闭文件(模拟 fclose)

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// 函数文件
#include "mystdio.h"
#include <sys/types.h> // 基本系统数据类型定义 size_t
#include <sys/stat.h> // 文件权限模式常量 S_IRWXU、S_IRWXG、S_IRWXO
#include <fcntl.h> // 文件控制选项 O_CREAT、O_WRONLY、O_APPEND、O_RDONLY
#include <stdlib.h> // malloc()、free()
#include <unistd.h> // read()、write()、close()
#include <assert.h> // assert() 断言检查

#define FILE_MODE 0666 // 默认文件权限 rw-rw-rw-

// 打开文件:支持 "w"(写入)、"a"(追加)、"r"(只读)
_FILE* _fopen(const char* filename, const char* flag)
{
assert(filename); // 确保文件名不为空
assert(flag); // 确保模式不为空

int f = 0; // 打开文件使用的标志位(flags)
int fd = -1; // 文件描述符初始化为非法值

if (strcmp(flag, "w") == 0)
{
f = (O_CREAT | O_WRONLY | O_TRUNC); // 创建+只写+截断旧内容
fd = open(filename, f, FILE_MODE);
}
else if (strcmp(flag, "a") == 0)
{
f = (O_CREAT | O_WRONLY | O_APPEND); // 创建+只写+追加写入
fd = open(filename, f, FILE_MODE);
}
else if (strcmp(flag, "r") == 0)
{
f = O_RDONLY; // 只读模式
fd = open(filename, f);
}
else
{
return NULL; // 不支持的模式,返回空指针
}

if (fd == -1)
{
return NULL; // 打开失败
}

_FILE* fp = (_FILE*)malloc(sizeof(_FILE)); // 为 _FILE 结构体分配内存
if (fp == NULL)
{
return NULL; // 内存分配失败
}

fp->fileno = fd; // 设置文件描述符
//fp->flag = FLUSH_LINE; // 可选行刷新模式
fp->flag = FLUSH_ALL; // 默认采用缓存满再写入
fp->out_pos = 0; // 输出缓冲区指针置 0

return fp;
}

int _fwrite(_FILE* fp, const char* s, int len) // 写入函数:将字符串写入到自定义缓冲区中
{
// 将数据从字符串拷贝到输出缓冲区(假设缓冲足够)
memcpy(&fp->outbuffer[fp->out_pos], s, len); // 无边界检测(简化实现)
fp->out_pos += len;

if (fp->flag & FLUSH_NOW) // 判断刷新策略
{
write(fp->fileno, fp->outbuffer, fp->out_pos); // 立即写入所有缓冲数据到文件
fp->out_pos = 0;
}
else if (fp->flag & FLUSH_LINE) // 按行刷新
{
if (fp->outbuffer[fp->out_pos - 1] == '\n') // 如果最后一个字符是换行符,则刷新
{
write(fp->fileno, fp->outbuffer, fp->out_pos);
fp->out_pos = 0;
}
}
else if (fp->flag & FLUSH_ALL) // 按缓存满刷新
{
if (fp->out_pos == SIZE) // 缓冲区满时刷新(一次写入 SIZE 字节)
{
write(fp->fileno, fp->outbuffer, fp->out_pos);
fp->out_pos = 0;
}
}

return len; // 返回写入的长度
}

// 手动刷新函数(模拟 fflush)
void _fflush(_FILE* fp)
{
if (fp->out_pos > 0)
{
write(fp->fileno, fp->outbuffer, fp->out_pos); // 写入缓冲数据
fp->out_pos = 0;
}
}

// 关闭文件(释放资源)
void _fclose(_FILE* fp)
{
if (fp == NULL) return;
_fflush(fp); // 关闭前确保缓冲区已写入
close(fp->fileno); // 关闭文件描述符
free(fp); // 释放分配的内存
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 测试文件
#include "mystdio.h"
#include <unistd.h> // 用于 sleep() 函数

#define myfile "test.txt" // 要写入的测试文件名称

int main()
{
// 打开文件,以追加模式 ("a") 打开
_FILE* fp = _fopen(myfile, "a");
if (fp == NULL)
{
return 1; // 打开失败则退出
}

const char* msg = "hello world\n"; // 写入的字符串内容
int cnt = 10; // 循环次数

while (cnt)
{
_fwrite(fp, msg, strlen(msg)); // 每次写入一行
// _fflush(fp); // 如需每次立即写入可手动刷新
sleep(1); // 每 1 秒写一次
cnt--;
}

_fclose(fp); // 写入完毕后关闭文件,释放资源

return 0;
}