欢迎光临
我们一直在努力

缓冲区、FILE 与“一切皆文件”

前两篇已经从两个层次学习了 Linux 文件 I/O:

第一层:
fopen / fread / fwrite / fclose

第二层:
open / read / write / close

这一天继续向下研究,我遇到了一个非常经典的问题:

为什么同样是输出,printf、fwrite 和 write 的行为有时候不一样?

这背后就涉及:

缓冲区


一、为什么需要缓冲区?

假设程序不断读取磁盘:

read
read
read
read
read

如果每一次操作都直接发起一次系统调用,就会频繁发生:

用户态

内核态

返回用户态

再次进入内核态

这种用户态和内核态之间的切换是有成本的。

而磁盘等 I/O 设备本身速度相对较慢。

所以如果每次只读一点点数据,效率会很低。

引入缓冲区的主要目的就是:

减少系统调用次数,提高 I/O 效率。


二、缓冲区到底是什么?

其实缓冲区就是:

内存中的一块空间。

用于临时保存输入或者输出的数据。

例如:

程序

缓冲区

磁盘

程序不一定每写一点数据就立即访问磁盘。

而是可以:

先积累

达到一定条件

再一次性写入

这样可以明显减少 I/O 次数。


三、标准 I/O 的三种缓冲方式

1. 全缓冲

当缓冲区填满后才进行实际 I/O。

磁盘文件通常使用这种方式。

2. 行缓冲

遇到换行符时,就可能进行 I/O。

终端相关的标准输入输出通常采用这种方式。

3. 无缓冲

不进行额外缓存,直接进行系统调用。

stderr 通常是不带缓冲的。


四、这能解释一个非常经典的问题

为什么:

printf("hello");

有时候屏幕上马上看不到?

因为:

printf

标准 I/O 缓冲区

还没有满足刷新条件

数据暂时没有真正写出去

而:

fflush(stdout);

可以主动刷新。

所以在上一篇进度条的学习中,我们经常看到:

fflush(stdout);

现在终于知道它真正的意义了:

把标准输出缓冲区中的数据主动刷出去。


五、重定向以后,缓冲方式还会发生变化

正常输出到显示器:

stdout

终端

通常采用:

行缓冲

但是如果:

./myfile > log.txt

发生重定向:

stdout

普通文件

那么缓冲方式可能变成:

全缓冲

于是:

printf("hello world\\n");

虽然带有换行,也不一定马上写进文件。


六、为什么程序结束后文件里可能什么都没有?

例如:

close(1);

int fd = open("log.txt",
O_WRONLY | O_CREAT | O_TRUNC,
0666);

printf("hello world: %d\\n", fd);

close(fd);
return 0;

此时可能出现:

log.txt

存在,但是里面没有预期的内容。

为什么?

因为:

stdout

已经变成全缓冲

数据还在用户缓冲区

缓冲区没有填满

没有自动刷新

这种情况下可以使用:

fflush(stdout);

强制刷新缓冲区。


七、stderr 为什么又不一样?

如果改成:

close(2);

int fd = open("log.txt",
O_WRONLY | O_CREAT | O_TRUNC,
0666);

perror("hello world");

因为 stderr 通常是不带缓冲的,所以数据可以直接写到文件。

所以可以先形成一个简单认识:

stdout
通常有缓冲

stderr
通常无缓冲


八、最经典的问题:fork + printf 为什么会重复输出?

这一部分是我觉得整个文件 I/O 学习中最有意思的地方。

看这样一段代码:

printf("hello printf\\n");
fwrite("hello fwrite\\n", ...);
write(1, "hello write\\n", ...);

fork();

当正常输出到终端时,可能看到:

hello printf
hello fwrite
hello write

但如果:

./hello > file

进行重定向,结果可能变成:

hello write
hello printf
hello fwrite
hello printf
hello fwrite

为什么:

printf
fwrite

输出了两次,而:

write

只输出一次?


九、真正原因:用户级缓冲区 + fork

关键在这里:

printf / fwrite

属于 C 库函数。

它们有自己的:

用户级缓冲区。

而:

write

是系统调用,并不使用这个 C 标准库缓冲区。

当:

stdout → 普通文件

以后,标准 I/O 采用全缓冲。

所以:

printf
fwrite

写入的内容可能还停留在缓冲区里面。

然后:

fork

发生。

因为 fork 存在写时拷贝:

父进程

缓冲区一份

子进程

缓冲区也得到一份

于是:

父进程退出

刷新一次

子进程退出

再刷新一次

结果:

printf
fwrite

的数据就被写了两遍。

而:

write

没有这层用户级缓冲,所以只写了一次。


十、这也解释了为什么 printf 和 write 不一样

现在我们可以建立:

printf

C标准库

用户级缓冲区

write等系统调用

而:

write

直接进入系统调用

所以:

printf/fwrite
→ 有用户级缓冲

write
→ 没有这层C库缓冲

这也是理解 FILE 和文件描述符关系的关键。


十一、FILE 里面到底有什么?

前面一直在使用:

FILE *fp;

现在终于可以继续深入。

下面是Glibc 中 _IO_FILE 的结构。

其中可以看到:

_IO_read_ptr
_IO_read_end
_IO_write_base
_IO_write_ptr
_IO_write_end
_IO_buf_base
_IO_buf_end

这些字段都和缓冲区有关。

同时还有一个非常关键的字段:

_fileno

也就是:

文件描述符。


十二、所以 FILE 和 fd 是什么关系?

现在可以总结为:

FILE

C标准库中的高级抽象

内部封装 fd

系统调用

Linux内核

也就是说:

FILE 并不是替代文件描述符。

而是:

在文件描述符的基础上增加了一层更方便的 C 标准库抽象。

因为 C 库的 I/O 最终还是通过文件描述符完成,所以 FILE 内部必然封装了 fd。


十三、重新理解“一切皆文件”

现在再回来看:

Linux 下一切皆文件。

这个概念已经不再只是一个口号。

它背后的核心之一,就是 Linux 尝试使用统一的文件接口访问不同资源。

例如:

普通文件
磁盘
键盘
显示器
管道
网络相关资源
设备

很多资源都可以通过类似:

open
read
write

这样的接口访问。

不同设备可以拥有各自的 read、write 实现


十四、自己设计一个简单 libc

#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>

static MyFile* BuyFile(int fd, int flag)
{
MyFile* f = (MyFile*)malloc(sizeof(MyFile));
if(f == NULL) return NULL;
f->bufferlen = 0;
f->fileno = fd;
f->flag = flag;
f->flush_method = LINE_FLUSH;
memset(f->outbuffer, 0, sizeof(f->outbuffer));
return f;
}

MyFile* MyFopen(const char* path, const char* mode)
{
int fd = 1;
int flag = 0;
if(strcmp(mode, "w") == 0)
{
flag = O_CREAT | O_WRONLY | O_TRUNC;
fd = open(path, flag, 0666);
}
else if(strcmp(mode, "a") == 0)
{
flag = O_CREAT | O_WRONLY | O_APPEND;
fd = open(path, flag, 0666);
}
else if(strcmp(mode, "r") == 0)
{
flag = O_RDONLY;
fd = open(path, flag);
}
else
{
//TODO
}
if(fd < 0) return NULL;
return BuyFile(fd, flag);

}
void MyFclose(MyFile* file)
{
if(file->fileno < 0) return;
MyFFlush(file);
close(file->fileno);
free(file);
}
int MyFwrite(MyFile* file, void* str, int len)
{
// 1. 拷贝
memcpy(file->outbuffer + file->bufferlen, str, len);
file->bufferlen += len;
// 2. 尝试判断是否满足刷新条件
if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen 1] == '\\n')
{
MyFFlush(file);
}
return 0;
}
void MyFFlush(MyFile* file)
{
if(file->bufferlen <= 0) return;
int n = write(file->fileno, file->outbuffer, file->bufferlen);
(void)n;
fsync(file->fileno);
file->bufferlen = 0;
}


十五、学习总结

现在回头把整个 Linux I/O 链条串起来:

应用程序

printf / fwrite / fread

FILE

C标准库缓冲区

文件描述符 fd

read / write / open / close

Linux系统调用

内核中的 struct file

file_operations

具体文件 / 设备

这个关系一旦理解,之前很多看似零散的知识就全部串起来了。

以前我对 Linux I/O 的理解只是:

打开文件

读取

写入

关闭

现在开始知道:

一个简单的 printf(),背后其实隐藏着标准库、缓冲区、文件描述符、系统调用以及 Linux 内核中的文件对象。

而这可能就是学习 Linux 系统编程最有意思的地方:

不断从一个简单的 API 往下挖,直到看到它背后的操作系统机制。

赞(0)
未经允许不得转载:171主机测评 » 缓冲区、FILE 与“一切皆文件”
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址