文章目录
- 🦦 什么是缓冲区
- 🦦 格式化输入/输出
- 🦦 刷新策略
- 🪶 块缓冲(fully buffered)
- 🪶 无缓冲(unbuffered)
- 🪶 行缓冲(line buffered)
- 🦦 现象解释
- 🦦 exit()与_exit()
- 🦦 进程崩溃或正常结束时对用户态缓冲区的刷新
- 🦦 什么是内核态缓冲区
- 🪶 输入缓冲区与输出缓冲区
- 🦦 简单实现用户层IO接口与缓冲区(供参考)
- 🦦 缓冲区存在的意义
|
🦦 什么是缓冲区
缓冲区(Buffer),顾名思义就是一块可以用于缓存的空间;
也可以说实际上缓冲区是一种临时存储区域,一般用于在数据传输过程中对数据的缓存;
缓冲区的主要目的是协调数据产生者和消费者之间的速度差异以提高系统的效率和性能;
- 那么具体什么是缓冲区?
存在几个例子:
下文中所出现的例子都将使用两种方式(直接运行 ,重定向至文件当中)以便于区分两种情况的不同之处;
-
[例1]
int main() { const char* str1 = "hello fwrite\n"; const char* str2 = "hello write\n"; // C标准库接口 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); return 0; }
在这段代码当中分别用了 C标准库接口 与 系统调用接口 分别对不同的
massage
进行打印(不同的massage
以便于区分打印的接口);-
直接运行
$ ./mytest hello printf hello fprintf hello fwrite hello write
从结果可以看出,当直接运行程序后对应的信息将按照既定的顺序分别打印至终端;
-
重定向至文件
$ ./mytest > log.txt ; cat log.txt hello write hello printf hello fprintf hello fwrite
从结果看出,虽然程序的结果被打印了出来,但是对应的打印顺序发生了变化;
-
-
[例2]
int main() { const char* str1 = "hello fwrite\n"; const char* str2 = "hello write\n"; // 语言接口 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); fork(); return 0; }
这段代码与第一段代码的差距并不大;
唯一的区别就是是否有调用
fork()
的区别;-
直接运行
$ ./mytest hello printf hello fprintf hello fwrite hello write
运行程序后正常打印未出现其他现象;
-
重定向至文件
$ >log.txt;./mytest > log.txt ; cat log.txt hello write hello printf hello fprintf hello fwrite hello printf hello fprintf hello fwrite
而在该例子当中运行程序并将其重定向至文件当中将会出现两个现象;
分别为 打印顺序变化 以及 打印数据量发生变化 ;
-
-
[例3]
int main() { const char* str1 = "hello fwrite\n"; const char* str2 = "hello write\n"; // 语言接口 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); close(1); return 0; }
在这段代码当中,将
fork()
替换成了close(1)
;即关闭 文件描述符
1
;-
直接运行
$ ./mytest hello printf hello fprintf hello fwrite hello write
当直接运行时程序将根据既定打印顺序进行打印;
-
重定向至文件
$ >log.txt ; ./mytest > log.txt ;cat log.txt hello write
而当重定向至文件后发现真正写进文件的内容只有 “hello write\n” ;
-
-
[例4]
int main() { const char* str1 = "hello fwrite"; const char* str2 = "hello write"; // 语言接口 printf("hello printf"); fprintf(stdout, "hello fprintf"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); close(1); return 0; }
该例以 例3 为基础去除了打印时的
\n
;$ ./mytest hello write [USER]$ >log.txt;./mytest >log.txt ;cat log.txt hello write [USER]$
从结果看出,对于 例3 而言无论是直接运行还是重定向至文件当中都只会写入 “hello write” ;
而实际上上文中几个例子所出现的几种现象都与缓冲区以及其刷新策略有关;
具体的刷新策略将在下文中进行解释;
在上文当中出现的几个例子都存在一个共性,即分别都采用了系统调用接口与C标准接口进行演示;
这可以引入一个猜测:
- C标准库当中是否存在缓冲区?
答案是肯定的,在日常中利用一些高级语言在进行开发的过程当中,其对应的将会为用户提供一个缓冲区;
这个缓冲区一般用于暂时存储写入的数据;
C语言 也如此,以printf
这些格式化输出函数为例,在 『 Linux 』基础IO/文件IO (万字) 当中提到,在 Linux 当中调用printf
函数时实际上是向 文件描述符1
中对应的文件进行写入操作;
而实际上其在对对应文件进行写入时并不是直接进行写入而是需要现将对应的数据写入至缓冲区当中,而后再根据当时所需的策略将数据逐步写入至文件当中;
在 Linux 环境下使用 C语言 等高级语言在进行开发时实际上存在着两个缓冲区;
这两个缓冲区为两个不同层面的缓冲区,分别为:
- 用户态缓冲区
- 内核态缓冲区
本文将着重解释用户态缓冲区,不对内核态缓冲区进行细节解释;
🦦 格式化输入/输出
printf()
函数,是 C语言 学习当中最早接触的函数;
其的功能就是将数据打印至显示屏(终端);
在之前的博客中提到其实际上其底层是调用系统调用接口write()
对 文件描述符1
进行写入;
-
存在一行代码:
int a = 10; printf("hello world %d\n",a);
当执行这两行代码后对应的终端将会打印出hello world 10
;
- 那么这里打印出的这行内容是什么类型的内容?
- 对应的
%d
为什么会被转化为a
?
显示器(终端)只能显示文本,故输出的是字符串类型;
而对应的%d
被转化为a
的数据就是因为将对应的数据进行了一些列的格式化;
这也是 格式化输入/输出 名称的由来;
在这两行代码中,将会进行以下操作:
-
解析格式字符串
printf
将首先读取格式字符串"hello world %d\n"
,并从左向右依次遍历,直至遇到第一个 格式指定符 ,在这里为%d
; -
匹配参数
当遇到格式指定符后,将会找到对应的数据来替换这个占位符;
-
格式化输出
在该段代码当中,
printf
将会把整数10
转化成对应的字符串表示"10"
并将其插入到%d
的位置;
而在进行完上述的工作之后,printf
并不会马上将数据直接写入;
而是暂存至其用户态的缓冲区当中,直至在对应的数据他们将会被写入内核态缓冲区当中;
-
那么数据是如何从用户态缓冲区被刷新至内核态缓冲区当中的?
以
glibc
为例,对于printf()
而言,该函数在调用过程中最后将会调用write()
系统调用接口并清空用户态缓冲区中的数据;但通常这是通过一系列的层次和封装间接完成的;
在此只需要明白当用户态缓冲区中的数据需要刷新至内核态缓冲区时最终都需要直接或者间接调用
write()
函数即可;
当调用write()
对文件进行写入时OS
将通过进程的task_struct
逐步寻找至对应的文件的内核态缓冲区;
而在内核态缓冲区的刷新一般取决于当前cpu
资源的调用;
并将数据写入至其内核态缓冲区当中;
- 对于上文所提到的刷新策略又是什么?与数据的刷新有什么关联?
🦦 刷新策略
在上文当中抛出了一个未解答的问题:
- 刷新策略是什么?与数据的刷新有什么关联?
顾名思义,刷新策略是根据不同的情况对由用户态缓冲区数据刷新至内核态缓冲区的不同方案;
刷新策略主要为三种;
分别为: 无缓冲刷新策略(unbuffered) , 行缓冲刷新策略(line buffered) , 块缓冲刷新策略(fully buffered) ;
同时在C标准库当中存在一个函数为setvbuf()
;
该函数可以修改一个已经打开的文件流对应的刷新策略;
SYNOPSIS
#include <stdio.h>
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
其中mode
为更新后的刷新策略;
_IONBF unbuffered
_IOLBF line buffered
_IOFBF fully buffered
同时,用户态缓冲区当中的刷新策略将根据文件流的指向进行更新;
这里的文件流的指向具体表现在文件描述符当中,这也包括上文举的几个例子中所提到的重定向至文件的操作;
重定向具体是底层调用dup()
系统调用接口使得文件描述符的指向发生变化;
这也能够解释在上文中的[例1]至[例4]中出现的现象;
🪶 块缓冲(fully buffered)
在块缓冲(全缓冲)的刷新策略下,数据将会在缓冲区满时或者是显示调用fflush()
函数时刷新;
标准库会在缓冲区满时自动调用write()
系统调用接口将缓冲区的数据写入至内核态缓冲区当中;
一般情况下在对普通文件流的写入时将使用行缓冲的刷新策略;
存在一段代码:
#define filename "test.txt"
int main() {
FILE* fp = fopen(filename, "w");
//不考虑打开失败
fprintf(fp, "fully buffered test\n");
sleep(5);
fclose(fp);
return 0;
}
在该段代码当中以只写的方式打开一个名为test.txt
的文件;
并调用fprintf()
将对应的信息写入至文件后调用sleep(5)
使得进程睡眠5s
;
在此处可以使用shell
脚本对文件内容进行实时监测;
while :; do cat test.txt ; sleep 1;echo "--------------" ;done
运行shell
脚本并运行程序后最终结果为:
--------------
--------------
--------------
--------------
--------------
fully buffered test
从结果可以看出,在运行后的前5s
并不会将数据写入至文件当中;
而是当调用fclose()
后对应的将对用户态缓冲区进行一次刷新,同时在此时数据被写入至文件当中;
🪶 无缓冲(unbuffered)
在无缓冲模式下,每次 I/O
都会立即调用 write
系统调用接口,将数据直接写入到内核缓冲区;
不会使用使用用户态缓冲区;
一般情况下, stderr
文件流的数据将采用无缓冲的策略进行刷新;
具体原因是因为防止进程崩溃时对应的错误信息仍然被阻塞至用户态缓冲区当中;
存在一段代码:
#define filename "test.txt"
int main() {
FILE* fp = fopen(filename,"w");
//不考虑打开失败
setvbuf(fp, NULL, _IONBF, 0);
fprintf(fp,"unbuffered test\n");
sleep(50);
fclose(fp);
return 0;
}
在该程序当中以只写的方式打开了一个名为test.txt
的文件;
并调用setvbuf()
将缓冲区的刷新策略更新为无缓冲刷新策略;
再使用fprintf()
将数据进行写入后调用sleep(50)
使得进程进入睡眠状态50s
;
在进程未退出时在终端中使用cat
将test.txt
中的内容显示至终端中;
$ cat test.txt
unbuffered test
可以发现进程未退出时数据仍被写入至文件当中;
正常情况下,在对普通文件进行写入时采用的刷新策略为全缓冲的刷新策略;
而在该例子中调用了setvbuf()
后将刷新策略更新为无缓冲,从而使得其能够直接将数据写入文件且不需要显式调用fflush()
或是等待用户态缓冲区被写满;
🪶 行缓冲(line buffered)
在行缓冲模式下,数据将会在遇到换行符(\n
)或是用户态缓冲区被写满时自动刷新;
此时,标准库将会直接调用write()
系统调用接口,将缓冲区的数据写到内核态缓冲区当中;
一般情况下, stdout
文件流的数据将采用行缓冲的刷新策略;
存在一段代码:
#define filename "test.txt"
int main() {
FILE* fp = fopen(filename, "w");
// 不考虑打开失败
char* massages[] = {"line buffered test 1\n", "line buffered test 2\n",
"line buffered test 3\n", "line buffered test 4\n",
"line buffered test 5\n"};
setvbuf(fp, NULL, _IOLBF, 0);
for (int i = 0; i < sizeof(massages) / sizeof(char*);++i){
fprintf(fp,"%s",massages[i]);
sleep(1);
}
fclose(fp);
return 0;
}
在这段代码当中以只写的方式打开名为test.txt
的文件;
并定义了一个char* massages[]
数组,数组中的每个元素都存放一个类型为char*
的字符串;
由于普通文件的刷新策略为全缓冲;
故在这里调用setvbuf()
将对应的刷新策略更新为行缓冲;
在更新刷新策略后用while()
循环调用fprintf()
依次将对应的数据写入至文件当中,每次打印时都sleep(1)
;
此时运行脚本对文件test.txt
进行实时监控并运行程序;
$ > test.txt ; ./mytest & while :; do cat test.txt ; sleep 1;echo "--------------" ;done #./mytest & 为隐式执行进程
[1] 9859
line buffered test 1
--------------
line buffered test 1
line buffered test 2
--------------
line buffered test 1
line buffered test 2
line buffered test 3
--------------
line buffered test 1
line buffered test 2
line buffered test 3
line buffered test 4
--------------
line buffered test 1
line buffered test 2
line buffered test 3
line buffered test 4
line buffered test 5
[1]+ Done ./mytest
正常来说将数据写入普通文件当中其刷新策略为全缓冲的刷新策略;
而此时调用了setvbuf()
将刷新策略更新为行缓冲;
故对应的数据将会根据顺序逐条写入文件当中;
🦦 现象解释
在上文当中解释了缓冲区以及缓冲区的刷新策略;
而在本节中可以对上文中所举的四个例子进行解释;
-
[例1]现象解释
int main() { const char* str1 = "hello fwrite\n"; const char* str2 = "hello write\n"; // C标准库接口 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); return 0; }
在这段代码当中分别调用了C标准库接口与系统调用接口
write()
分别对数据打印;其对应的结果为:
$ ./mytest hello printf hello fprintf hello fwrite hello write $ > log.txt ;./mytest > log.txt ; cat log.txt hello write hello printf hello fprintf hello fwrite
-
运行程序
当运行程序后数据将以既定的顺序进行打印;
原因是无论是
printf()
,fprintf(stdout)
,fwrite(stdout)
三个调用都是将数据输出至标准输出stdout
当中;而将数据写入标准输出
stdout
时所采用的刷新策略是 行缓冲 ;在该例中
C
标准库函数所打印的字符串都带换行符\n
;而行缓冲的刷新策略为遇到
\n
时将会将数据由用户态缓冲区刷新至内核态缓冲区当中;而对于系统调用接口
write()
而言,其属于系统层的接口函数,本身与用户层的接口函数将产生一个解耦合的关系;在调用
write()
时其并不会被写入至用户态缓冲区当中;故当正常运行程序时将以正常的打印顺序进行打印;
-
重定向至文件
在上文中提到,当发生重定向时对用户态缓冲区中数据的刷新策略也将跟着更新;
当运行程序并将结果重定向至文件当中时,对应的 行缓冲 刷新策略将被更新为 块缓冲 ;
而 块缓冲 刷新条件必须满足以下其中一点:
- 用户态缓冲区被写满
- 显示调用
fflush()
对用户态缓冲区进行刷新 - 文件流被关闭
- 进程正常结束并退出
而在该例子当中并未显示调用
fflush()
,同时数据量并未达到缓冲区的最大值;故这些数据将一直被保留在用户态缓冲区当中;
而对于
write()
调用而言其数据可以直接被写入至内核态缓冲区当中并不需要经过用户态缓冲区;故最终写入至文件时其顺序将发生变化;
-
-
[例2]现象解释
int main() { const char* str1 = "hello fwrite\n"; const char* str2 = "hello write\n"; // 语言接口 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); fork(); return 0; }
这段代码与上段代码唯一的区别就是是否有调用
fork()
接口;其对应的结果为:
$ ./mytest hello printf hello fprintf hello fwrite hello write $ >log.txt;./mytest > log.txt ; cat log.txt hello write hello printf hello fprintf hello fwrite hello printf hello fprintf hello fwrite
-
运行程序
当运行程序后将以既定的顺序进行打印;
其与 [例1] 中的直接运行相同;
即在行缓冲的刷新策略时每遇到一次
\n
将会调用write()
进行一次刷新,再此不进行赘述;而当再调用
fork()
创建子进程时父子进程未执行完的只有return
;故没有任何变化;
-
重定向至文件
当重定向至文件时,该刷新策略将被更新为全缓冲 (全缓冲的概念参照上文中对全缓冲的解释) ;
而
write()
为系统调用,将直接把数据写入至内核态缓冲区并进行刷新;故在重定向至文件时
write()
调用的信息将优先进行写入;而由于进程未退出,此时用户态缓冲区当中仍存在未被刷新至内核缓冲区的数据;
此时在调用
fork()
创建子进程时;由于子进程是父进程的一个拷贝,其将继承其父进程的代码数据;
而用户态缓冲区中的数据也属于代码数据的一部分,故创建子进程后父子进程在宏观上仍具有自己的用户缓冲区,但实际上可能还未给子进程分配这部分内存空间;
当其中一个进程刷新其用户态缓冲区时,其动作即为对内存空间进行清空操作;
而清空操作本质上属于一个写入操作;
故在这里会发生 写时拷贝 ,最终父子进程都会将自己的用户态缓冲区刷新至内核态缓冲区当中并写入文件内;
故在该例子当中会出现写入重复的现象;
-
-
[例3]现象解释
int main() { const char* str1 = "hello fwrite\n"; const char* str2 = "hello write\n"; // 语言接口 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); close(1); return 0; }
这段代码与 [例1] 唯一的区别就是是否调用
close(1)
关闭 文件描述符1
;其对应的结果为:
$ ./mytest hello printf hello fprintf hello fwrite hello write $ >log.txt ; ./mytest > log.txt ;cat log.txt hello write
-
运行程序
当直接运行程序后其最终结果与上文中其他例子的结果相同;
其原理也相同;
即行缓冲策略刷新;
对应的
close(1)
并不对其造成影响; -
重定向至文件
当重定向至文件后对应的行缓冲刷新策略将被更新为全缓冲;
此时只能等待关闭文件流或是程序正常退出时才会对用户态缓冲区进行刷新;
而
write()
系统调用接口不经过用户态缓冲区,故可以直接进行写入操作;在关闭文件流前调用了
close(1)
关闭了对应的 文件描述符1
;而在重定向过后该 文件描述符
1
所对应的文件为重定向后的文件;因为 文件描述符 被关闭故无法进行写入操作;
-
-
[例4]现象解释
int main() { const char* str1 = "hello fwrite"; const char* str2 = "hello write"; // 语言接口 printf("hello printf"); fprintf(stdout, "hello fprintf"); fwrite(str1, strlen(str1), 1, stdout); // 系统调用接口 write(1, str2, strlen(str2)); close(1); return 0; }
该例与 [例3] 的唯一区别即为少了换行符
\n
;其运行结果为:
$ ./mytest hello write [USER]$ >log.txt;./mytest >log.txt ;cat log.txt hello write [USER]$
可以发现无论是直接运行还是重定向至文件当中其最终结果都相同;
-
直接运行
当直接运行时其刷新策略为行缓冲刷新策略;
而行缓冲刷新策略为在每次遇到换行符
\n
进行一次刷新;而这段打印当中并不存在换行符
\n
;对于
write()
系统调用而言其并不经过用户态缓冲区故可以直接打印;在
write()
过后调用close(1)
后,即使进程正常结束对应的用户态缓冲区的数据也无法被刷新至内核态并打印; -
重定向至文件当中
重定向至文件当中与直接运行概念完全相同,只不过被重定向至普通文件后刷新策略将更新为全缓冲;
在此不进行赘述;
-
🦦 exit()与_exit()
在c/C++
当中存在着这么一个接口exit()
;
NAME
exit - cause normal process termi‐nation
SYNOPSIS
#include <stdlib.h>
void exit(int status);
DESCRIPTION
The exit() function causes normal process
termination and the value of status & 0377
is returned to the parent (see wait(2)).
这个接口是一个 C标准库 提供的接口;
这个接口能够使得用户能够在调用该接口后正常退出进程同时做好结束的清理工作,并返回一个对应的退出码;
其中int status
即为需要返回的退出码,用户可以根据该退出码判断程序出错的位置以及原因;
一般情况下exit(0)
表示正常退出(其余退出信息参考官方文档);
-
int main() { printf("hello world"); exit(0); return 0; }
在这段代码中调用了
printf
打印一条信息;运行结果如下:
$ ./mytest hello world $
在上文中了解到当将数据写入至
stdout
文件流时采用的刷新策略为行缓冲;而行缓冲的刷新条件为 当遇到换行符
\n
或者是缓冲区被写满时将对缓冲区进行刷新 ;而在该例子中所打印的信息并不存在换行符,只能等待进程正常结束时退出;
此时即使调用了
exit()
后数据也被进行打印,说明该接口会刷新用户态缓冲区;
而在Linux
内核当中同样存在着一个类似的接口为_exit()
/_Exit()
;
NAME
_Exit, _exit - terminate a process
SYNOPSIS
#include <stdlib.h>
void _Exit(int status);
#include <unistd.h>
void _exit(int status);
_exit()
是 POSIX 标准中定义的系统调用。_Exit()
是由 ISO C99 标准引入的函数。
这是一个系统调用接口,与exit()
并不相同;
如果联系的话可以说exit()
封装了_exit()
或是_Exit()
;
//基于glibc的简要实现
extern void (*__atexit_funcs[])(void); // 通过atexit注册的函数数组
extern int __atexit_count; // 注册的函数数量
void exit(int status) {
// 调用通过atexit注册的函数
for (int i = __atexit_count - 1; i >= 0; --i) {
if (__atexit_funcs[i]) {
__atexit_funcs[i]();
}
}
// 刷新所有标准I/O缓冲区
fflush(NULL);
// 关闭所有打开的文件流
fcloseall();
// 调用系统调用_exit,立即终止程序
_exit(status);
}
其主要的功能是终止进程并立即将控制返回给操作系统;
由于该接口属于系统调用接口,与C标准库解耦合故其并不会刷新用户态缓冲区;
-
#define filename "test.txt" int main() { printf("hello world"); _exit(0); return 0; }
对应的运行结果为;
$ ./mytest $
运行无结果,原因为其并不会刷新用户态缓冲区;
🦦 进程崩溃或正常结束时对用户态缓冲区的刷新
在上文中提到,当进程正常退出时将对用户态缓冲区进行一次刷新;
- 在进程退出时是如何对用户态缓冲区进行清理的?
实际上当进程退出时会将所有被打开的文件流中的所有用户态缓冲区进行一次刷新;
- 那么当进程
return
后是如何刷新缓冲区的?
对照这个问题可以利用一个简单的程序利用gdb
进行验证;
int main() {
return 0;
}
在这个程序当中什么都不做,只进行一次return 0
返回;
将断点打至return
处并启动gdb
调试后再 逐过程 进行调试;
可以发现当return 0;
被执行过后将跳转至libc-start.c
文件中并执行exit();
可以发现,实际上当main()
函数结束时,若未显式调用exit()
时编译器将隐式调用exit()
从而达成程序的清理工作;
故对应的用户态缓冲区也会在此时被刷新;
- 进程崩溃是否会刷新用户态缓冲区?
这里存在两段代码:
-
int main() { char *ptr = NULL; setvbuf(stdout, NULL, _IOFBF, 0); printf("hello world\n"); *ptr = 'a'; return 0; }
在这段代码当中定义了一个名为
ptr
的char*
指针,并为其赋值为NULL
;调用
setvbuf()
将刷新策略更新为全缓冲,由于数据量较少无法将缓冲区写满故只能等待进程结束;而此时解引用
ptr
使其造成一个对空指针的非法解引用;最终的结果为:
$ ./mytest Segmentation fault
程序崩溃进程退出,最终的信息并未被打印至终端中;
-
int main() { char *ptr = NULL; setvbuf(stdout, NULL, _IOFBF, 0); printf("hello world\n"); assert(ptr); return 0; }
在这段代码当中同样更新刷新策略为全缓冲,同时也定义了一个
*ptr
指针并赋值为NULL
;但不同的是在这段代码当中调用了
assert()
宏进行一次断言;其运行结果为:
$ ./mytest mytest: test.c:140: main: Assertion `ptr' failed. hello world Aborted
可以观察到在这里即使进程崩溃也同样将用户态缓冲区中的数据进行了刷新;
这里有一个问题:
- 为什么同样是使程序崩溃(非正常退出),但其对用户态缓冲区刷新的结果不同?
本质上当程序崩溃时并不会去刷新用户态缓冲区;
以第一个例子中的对空指针非法解引用为例;
第一个例子中对空指针非法解引用接而崩溃的原因是,*ptr = 'a'
这个操作属于一个对物理内存的写入操作;
而用户无法直接通过对物理内存进行访问从而进行写入,只能通过 进程地址空间 与 页表映射 逐级进行写入;
MMU
将会通过 页表 中的权限信息以及其所映射的物理内存进行写入;
而NULL
空指针并不存在有效的映射(可能无映射或是映射在非法物理地址当中);
由于是一个危险操作,此时MMU
会触发到一个页面错误;
当OS
接收到这个异常时将会检查错误的地址,如果地址是无效的,OS
会认为这是一个严重的错误并发送一个信号(例SIGSEGV
)最终将进程终止;
这一系列操作通常是在系统层面,由于其与用户层解耦合,其并不关心用户层是否存在未结束的代码数据;
故此时用户层将毫无预兆的被终止,其对应的用户态缓冲区也因此无法进行刷新;
- 为什么
assert()
宏也是使程序"崩溃"但会刷新其用户态缓冲区?
首先理解一点,即 assert()
宏为C语言提供 ;
既然是C语言提供的,那么其即使最终要使程序"崩溃"也必然需要将未写入完全的数据进行刷新以保证数据的完整性;
在这里可以使用man
手册对assert()
宏进行查询;
man 3 assert
#---------------------
NAME
assert - abort the program if assertion is false
SYNOPSIS
#include <assert.h>
void assert(scalar expression);
DESCRIPTION
If the macro NDEBUG was defined at the moment <assert.h>
was last included, the macro assert() generates no code,
and hence does nothing at all. Otherwise, the macro
assert() prints an error message to standard error and
terminates the program by calling abort(3) if expression
is false (i.e., compares equal to zero).
从Otherwise, the macro assert() prints an error message to standard error and terminates the program by calling abort(3).
可以了解到,当断言失败时assert()
宏将去调用abort()
C标准库函数接口从而终止进程;
使用man
手册对abort()
进行查询;
man abort
#---------------------
NAME
abort - cause abnormal process termination
SYNOPSIS
#include <stdlib.h>
void abort(void);
DESCRIPTION
The abort() first unblocks the SIGABRT signal, and then raises
that signal for the calling process. This results in the
abnormal termination of the process unless the SIGABRT signal
is caught and the signal handler does not return (see
longjmp(3)).
If the abort() function causes process termination, all open
streams are closed and flushed.
从对abort()
的解释中可以看出,其将会利用信号从而终止进程;
但在终止进程前其将会关闭所有打开的文件流并在关闭前刷新所有用户态缓冲区;
🦦 什么是内核态缓冲区
在上文中提到,在这整个体系当中实际上存在两种缓冲区,分别为内核态缓冲区与用户态缓冲区;
用户态缓冲区指高级语言为用户所提供的一个缓冲区,其将根据不同的场景提供不同的刷新策略;
而内核态缓冲区则是为了使得能够更好的利用或节省CPU
资源而产生的,其对应的刷新策略一般根据当前CPU
资源的使用情况而定的;
实际上内核态缓冲区是一个抽象的概念;
在『 Linux 』“ 一切皆文件 “中提到,文件系统可以被看做是一个多态的现象;
对应的内核态缓冲区也是如此,可以说其可以属于文件系统体系之中,也可以看作是一种多态;
而内核态缓冲区并不像用户态缓冲区,其要比用户态缓冲区更为复杂;
在 “一切皆文件” 中提到,以OS
的视角观察来看,文件无非几种类型,而内核缓冲区已经在OS
内核中被定义(描述)好的;
OS
将根据这个文件的类型去为其分配相应类型的缓冲区以能够为其提供对应的I/O
需求;
而由用户态缓冲区刷新到内核缓冲区这里的内核缓冲区将根据数据最终到达的文件类型从而经过不同的缓冲区;
🪶 输入缓冲区与输出缓冲区
对于以往的学习而言,可能会出现 输入缓冲区 , 输出缓冲区 的概念;
而实际上无论是输入缓冲区还是输出缓冲区其都只有一个;
struct _IO_FILE {
int _flags; /* 高位字是 _IO_MAGIC; 其余是文件流状态标志 */
#define _IO_file_flags _flags
/* 与缓冲区相关的指针,遵循 C++ streambuf 协议 */
char* _IO_read_ptr; /* 当前读取操作的位置指针 */
char* _IO_read_end; /* 读取区域的结束位置指针 */
char* _IO_read_base; /* 回退缓冲区加读取区域的开始位置指针 */
char* _IO_write_base; /* 写入区域的开始位置指针 */
char* _IO_write_ptr; /* 当前写入操作的位置指针 */
char* _IO_write_end; /* 写入区域的结束位置指针 */
char* _IO_buf_base; /* 缓冲区的开始位置指针 */
char* _IO_buf_end; /* 缓冲区的结束位置指针 */
/* 以下字段支持数据回退和撤销操作 */
char *_IO_save_base; /* 备份读取区域的开始位置指针 */
char *_IO_backup_base; /* 备份区域的第一个有效字符的位置指针 */
char *_IO_save_end; /* 备份读取区域的结束位置指针 */
struct _IO_marker *_markers; /* 标记链表,用于标记流中的位置 */
struct _IO_FILE *_chain; /* 指向下一个 FILE 结构体的指针,用于形成链表 */
int _fileno; /* 文件描述符,对应底层的文件标识符 */
#if 0
int _blksize; /* 块大小,不再使用,现在使用 _flags2 */
#else
int _flags2; /* 额外的标志位,用于扩展 _flags 的功能 */
#endif
_IO_off_t _old_offset; /* 文件位置的偏移量,用于文件定位操作 */
/* 以下字段暂时使用 */
unsigned short _cur_column; /* 当前的列号,用于格式化输出,+1 表示基于1的计数 */
signed char _vtable_offset; /* 虚表偏移量,用于C++的虚函数机制 */
char _shortbuf[1]; /* 内部的短缓冲区,用于最小化流的缓冲需求 */
_IO_lock_t *_lock; /* 用于多线程同步的锁 */
#ifdef _IO_USE_OLD_IO_FILE
/* 此处可能会包含旧版 IO_FILE 结构的额外成员 */
#endif
};
该段代码为FILE
结构体的声明;
其所在位置为/usr/include/stdio.h
;
从代码中可以看到,整个结构体当中对缓冲区的空间只有char* _IO_buf_base;
与char* _IO_buf_end;
其分别表示缓冲区的开始位置以及结尾位置,此块区域即为缓冲区的有效范围;
实际上无论是输出缓冲区还是输出缓冲区所指的范围就是这个范围;
用户态缓冲区既可以用作输出缓冲区也可用作为输出缓冲区,这次取决于其的I/O
形式;
🦦 简单实现用户层IO接口与缓冲区(供参考)
-
MyFile.h
#ifndef __MYSTDIO_H__ #define __MYSTDIO_H__ /* 此处的 #ifndef #define #endif 为"包含卫士" 其作用于 #program once 相同 用于避免头文件重复包含所引起的重复编译问题 */ #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #include <unistd.h> #include <string.h> #include <stdlib.h> #include <stdio.h> //定义缓冲区刷新策略 #define NO_BUFFER 1 //无缓冲 #define ROW_BUFFER 2 //行缓冲 #define BLOCK_BUFFER 4 // 块缓冲(全缓冲) #define CREAT_FILE_MODE 0666 #define BUFFER_SIZE 1024 typedef struct _IO_FILE_{ int _fileno; char _buf[BUFFER_SIZE]; int _pos;//用于标定文件流的位置 在写入与读取时文件流的位置 int _flushmode; } _FILE; _FILE *_fopen(const char *path, const char *mode); size_t _fwrite(const void*ptr,size_t size,size_t nmemb,_FILE*fp); int _fclose(_FILE *fp); int _fflush(_FILE *fp); size_t _fread(void *ptr, size_t size, size_t nmemb, _FILE *fp); #endif
-
MyFile.c
#include "MyFile.h" /* "w" "r" "a" 三种打开方式*/ _FILE *_fopen(const char *path, const char *mode) { int fd = 0; if (strcmp(mode, "w") == 0) { fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, CREAT_FILE_MODE); } else if (strcmp(mode, "r") == 0) { fd = open(path, O_RDONLY); } else if (strcmp(mode, "a") == 0) { fd = open(path, O_CREAT | O_WRONLY | O_APPEND, CREAT_FILE_MODE); } else { return NULL; // 其他选项暂不考虑实现 } if (fd == -1) return NULL; _FILE *fp = (_FILE *)malloc(sizeof(_FILE)); fp->_fileno = fd; fp->_pos = 0; // 默认为0; // fp->_flushmode = ROW_BUFFER; // 默认刷新策略为行缓冲 fp->_flushmode = BLOCK_BUFFER; // 默认刷新策略修改为全缓冲 return fp; } size_t _fwrite(const void *ptr, size_t size, size_t nmemb, _FILE *fp) { int len = nmemb * size; memcpy(&fp->_buf[fp->_pos], ptr, len); fp->_pos += len; // 更新文件流所指位置 int ret = 0; if (fp->_flushmode & NO_BUFFER) { // 无缓冲 立即刷新 ret = write(fp->_fileno, fp->_buf, len); fp->_pos = 0; // 重置位置 } else if (fp->_flushmode & ROW_BUFFER) { // 行缓冲 当遇到\n时进行刷新 /* 在该接口当中的行缓冲刷新策略并不是做的很好 只能刷新一次\n 但本次模拟为简易模拟并不考虑 */ int flush_len = 0; for (; flush_len < fp->_pos; ++flush_len) { if (fp->_buf[flush_len] == '\n') break; } flush_len += 1; ret = write(fp->_fileno, fp->_buf, flush_len); fp->_pos = 0; // 重置位置 } else { // 全缓冲(块缓冲) 当缓冲区满时进行刷新 if (fp->_pos >= BUFFER_SIZE) { ret = write(fp->_fileno, fp->_buf, fp->_pos); fp->_pos = 0; // 重置位置 } } if (ret == -1) { perror("_fwrite\n"); return 0; } return len; } size_t _fread(void *ptr, size_t size, size_t nmemb, _FILE *fp) { // ptr为用户自行提供的缓冲区 size_t len = size * nmemb; if (len > BUFFER_SIZE) len = BUFFER_SIZE; ssize_t pos = read(fp->_fileno, fp->_buf, len); // pos为实际读取的数据 if (pos == -1) { perror("_fread -- read\n"); return 0; } memcpy(ptr, &fp->_buf[fp->_pos], pos); fp->_pos += pos; return pos; } int _fclose(_FILE *fp) { // 关闭文件时需要将对应文件流的缓冲区进行刷新 _fflush(fp); int ret = close(fp->_fileno); free(fp); if (ret == -1) { // 失败 perror("_fclose\n"); return -1; } return 1; } int _fflush(_FILE *fp) { ssize_t ret = 0; if (fp->_pos > 0) { ret = write(fp->_fileno, fp->_buf, fp->_pos); fp->_pos = 0;//更新文件流的位置 } //判断调用write是否失败 if(ret == -1){ return EOF; } return 0; }
|
🦦 缓冲区存在的意义
该博客要讲解缓冲区;
- 缓冲区存在的意义是什么?
其实缓冲区的存在最重要的意义可以从以下几点进行解释:
-
减少
I/O
次数每当从用户空间向内核空间进行
I/O
操作时,都要进行一次上下文的切换;这个过程一般涉及到保存当前进程状态,加载内核状态等操作,其消耗的资源和时间不容忽视;
而设置缓冲区过后可以通过在用户层级积累数据,直至积累到一定量后再统一进行
I/O
操作;减少系统调用的次数,从而减少上下文切换的次数,提高整体效率;
-
增加单次
I/O
数据吞吐量当
I/O
操作的数据量增加时,每次I/O
操作的时间成本被更多的数据分摊;而相对于频繁的销量数据
I/O
操作,批量处理可以提高数据处理的效率和吞吐量; -
减少内核缓冲区的频繁操作
内核缓冲区的操作同样是需要消耗资源的;
频繁的
I/O
意味着内核缓冲区需要频繁的进行读写,清空,同步等操作;这不仅增加了
CPU
的氟碳,也可能成为性能的瓶颈;而通过减少到内核的
I/O
次数可以有效降低对内核缓冲区的操作频率从而提高整体性能; -
减少硬件损耗
频繁的
I/O
操作意味着设备(磁盘,网络设备等)需要频繁进行读写,可能导致硬件频繁工作从而降低使用寿命;而通过用户态缓冲区的缓冲可以减少实际的
I/O
次数从而减少硬件损耗同时延长硬件设备的使用寿命;