缓冲区概念

观察现象

首先是执行以下 3 个 C 程序带来的现象:

sleep函数的程序 ( Linux 的需要展开 unistd.h ),C 程序的printf输出的字符串带\n,消息会立马刷新,且命令提示符([用户名@主机名 目录名]$)会出现在下一行。

例如这个测试程序:

#include <stdio.h>
#include <unistd.h>

int main(){
    printf("Can you see me?\n");
    sleep(2);
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
gcc a.c -o a.exe
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
Can you see me?  # 这里无法体现现象,需要自行测试
[Bjarne@VM-8-8-centos cppTest]$ 

这里会先输出语句 “Can you see me?” ,再暂停 2 秒,之后再输出下一条命令提示符[Bjarne@VM-8-8-centos cppTest],文本和图片均无法展示过程(还可以使用 gif,但这里没做),可自行测试。

而不带 \n 则会先暂停一下,再刷新消息,且命令提示符会紧随其后。这里再次修改程序:

int main(){
    printf("Can you see me?");
    sleep(2);
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
gcc a.c -o a.exe
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
Can you see me?[Bjarne@VM-8-8-centos cppTest]$ 
# 这里会先暂停2秒,再输出下一条命令提示符,文本无法展示过程

可以看到,不带 \n 和带 \n 有明显差别。

若带 fflush 函数对 stdout 进行强制刷新,则会先显示 printf 的字符串,再睡眠。

参考 man 3 fflush

#include <stdio.h>
int fflush(FILE *stream);

对于输出流 stdiofflush 通过该流的底层写函数,强制将给定输出或更新流的所有用户空间缓冲数据执行一次写入,即刷新流。

对于输入流 stdinfflush 丢弃所有已从底层文件获取但尚未被应用程序消费的缓冲数据。流的打开状态不受影响。

如果参数 streamNULLfflush 将刷新所有已打开的输出流。

\n 也是一种刷新流的策略:行刷新。

这里再次修改程序:

#include <stdio.h>
#include <unistd.h>

int main(){
    printf("Can you see me?");
    fflush(stdout);
    sleep(2);
    return 0;
}

交互界面

[Bjarne@VM-8-8-centos cppTest]$ make
gcc a.c -o a.exe
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
Can you see me?[Bjarne@VM-8-8-centos cppTest]$ 
# 这里会先输出语句,再暂停2秒,再输出下一条命令提示符,文本无法展示过程

对 3 个现象进行分析:

C 语言的函数内的语句是从上往下执行。所以不存在先执行 sleep,再执行 printf 的情况。但不带 \n 时,会看到 sleep 先运行的假象,这种情况只能是 printf 早已经运行,但字符串没有被显示出来。

所以到这里只会 C 语言已经没法解释,需要站在操作系统的视角理解。

缓冲区的概念

根据之前的学习可知,缓冲区的本质就是进程管理的一块内存空间,用于临时存储数据。

之前的现象就是 printf数据先拷贝到某个缓冲区里,然后根据是否有 \n 来决定算是先清空缓冲区还是先 sleep ,或者使用 fflush 提前刷新缓冲区。

理解为什么要有缓冲区,这里举一个生活中常见的案例。

假设甲在江苏读大学,乙在浙江读大学,甲和乙是一对好哥们。某天甲在自己城市的电脑商城买了一个仅在自己城市的电脑城出售的键盘(数据),觉得很好用,于是想让好友乙也使用,于是带着键盘骑自行车从江苏一路跑到浙江,将键盘送给乙。

这种甲亲自行动将键盘(数据)带给乙,对甲来说效率是非常低下的。所以甲可以讲键盘委托菜鸟驿站,让菜鸟驿站的工作人员将键盘给乙送去。尽管可能这个菜鸟驿站不靠谱,同样是要骑自行车送键盘,甚至很可能还要等仓库堆满了再送快递(刷新策略),但对甲来说甲只耗费了委托菜鸟驿站的时间,极大提高了甲的效率。

除此之外,甲可能会觉得菜鸟驿站送的太慢,于是催促菜鸟驿站的工作人员( fflush )让他们送快点。于是工作人员也不骑自行车摸鱼了,直接改乘高铁跑过去。

所以缓冲区扮演的就是菜鸟驿站的角色。进程想要通过 printf 将字符串数据输出到显示屏,可以先将字符串数据保存到缓冲区,缓冲区满了之后(这里没有填满,而是 sleep 加进程阻塞)再统一将数据给显示屏传输过去。

对 C/C++ 语言,会针对标准输出 stdout,Linux 会给程序提供默认的缓冲区。在 sleep 期间,字符串在输出缓冲区中。

C/C++ 程序在运行时会默认打开三个文件流:stdinstdoutstderr。其中 stdout 就是输出缓冲区(标准输出流)。同理 stdin 也有输入缓冲区(标准输入流),输入的数据中也包括 \n ,在算法竞赛中需要对这个 \n 进行处理才能正确读取数据。

缓冲区刷新策略

通俗地讲,数据在缓冲区堆积了多少,进程才将数据拷贝出去并清理缓冲区,这个模式叫刷新策略

C/C++ 程序常见的刷新策略有 3 种:

  • 全缓冲
    • 即等缓冲区满了再输出(菜鸟驿站货物堆满了再送快递);
    • 检测到 fflush 的使用(用户催发货);
    • 程序正常结束(例如菜鸟驿站倒闭了,为防止用户上门找茬,将仅剩的快递送完再散伙)。
  • 行缓冲检测到 \n 时再输出。其他情况例如缓冲区满、强制刷新、程序结束时可能也会触发行缓冲。
  • 无缓冲立即输出,无需缓冲区。

因为内存和外部设备显示屏的交流速度很慢(指几毫秒,和 CPU 的纳秒级别的数据处理无法比较),所以全缓冲、行缓冲一定程度上提高了整体的效率,否则每输出一个字符都要访问显示屏,传输效率会特别低下。

回车\r和回车换行\n

C 语言的转义运算符 \n ,即回车换行其实是做了 2 件事:换到下一行和将光标放在下一行的最开头。

老式回车键是想通过形式来告诉用户回车的原理。
请添加图片描述

C 语言的这 2 个转义字符 \r 是只回车,\n 是 ”回车 + 换行“。

验证只回车 \r 而设计的倒计时程序:

#include<stdio.h>
#include<unistd.h>

int main(){
    int cnt = 9;
    while(cnt){
        printf("%d\r",cnt);//输出完成后,光标会回到开头
        fflush(stdout);//没有fflush不会输出
        cnt--;
        sleep(1);
    }
    return 0;
}

第7行的 printf,数据暂时被缓冲起来,直到最后一直没显示,且光标回到开头将数据清理掉,或干脆就不输出。解决方法是加 fflush 刷新 stdout 。如此便完成了一个倒计时程序。

这些 C 程序向显示器打印的并不是数字,而是字符。将各种数据转换成字符并输出是 printf的工作,所以 printf格式化控制函数

这就意味着,若之前的程序的 cnt 初始化为10,则会输出 {90,80,...,10}。这是倒计时程序不希望出现的 bug,所以可以用 %-2d 进行格式控制,- 会让输出的字符进行左对齐,不加 - 则是右对齐。

能显示 2 位数倒计时的程序如下:

#include<stdio.h>
#include<unistd.h>

int main(){
    int cnt = 10;
    while(cnt){
        printf("%-2d\r",cnt);//输出倒计时
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    return 0;
}

所以将显示器称为字符设备,因为它显示的只是字符,不是整型,也不是浮点数。

从键盘中得到的数据同理也是字符,哪怕是数字,这种读取方式是格式化输入,键盘也被称之为字符设备。

c程序在进行打印时是要将数据打印到显示器上,直接刷新到显示器上时,每次刷新本质都是访问外设的过程,效率太低,所以c语言先暂时将字符串存储在缓冲区。

类比的话是快递小哥,一次将所有的快递给小哥,小哥一次将所有快递递交给用户。强制刷新输出流相当于是给小哥压力,让小哥及时反馈信息。

C语言的缓冲区

这里运行一个程序,来进行一个测试。

#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
	printf("C: hello printf\n");
    fprintf(stdout,"C: hello fprintf\n");
    fputs("C: hello fputs\n",stdout);
	fwrite("C: hello fwrite\n", 16, 1, stdout);
	const char* str = "system call: hello write\n";
	write(1, str, strlen(str));
	fork();
	return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cTest]$ make
gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
C: hello printf
C: hello fprintf
C: hello fputs
C: hello fwrite
system call: hello write
[Bjarne@VM-8-8-centos cTest]$ ./a.exe > t.txt
[Bjarne@VM-8-8-centos cTest]$ cat t.txt
system call: hello write # 系统调用最先输出
C: hello printf
C: hello fprintf
C: hello fputs
C: hello fwrite
C: hello printf # 从这里开始,凡是C语言封装的函数都输出了第二次
C: hello fprintf
C: hello fputs
C: hello fwrite
[Bjarne@VM-8-8-centos cTest]$ 

现象解释

一般 C 库函数写入文件时是全缓冲的,而使用系统调用 write 写入显示器是行缓冲

printffprintffputsfwrite这些库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲,因为对磁盘上的文件写入时默认是全缓冲。此时放在缓冲区中的数据,就不会被立即刷新(指将缓冲区数据立马送走并将缓冲区清空)。

fork 创造进程,待进程退出之后,会统一刷新写入文件当中。但是 fork 调用时,父子进程的数据会发生写时拷贝(指两个进程指向同一缓冲区,哪怕是拷贝一份完全一样的缓冲区也不影响测试结果),所以当两个进程结束时,两个进程各自将自己的缓冲区刷新,父、子进程谁先结束没有影响。

write 没有变化,说明没有所谓的缓冲,而是直接输出到文件(在Linux,显示屏也被看成文件)。

综上

printffwrite 等 C 语言的库函数会自带缓冲区,而 write 系统调用没有带缓冲区。这里所说的缓冲区,都是用户级缓冲区,即提供给用户使用的缓冲区。

为了提升整机性能,OS也会提供相关内核级缓冲区,这里暂时不讨论。

printffwrite 是库函数, write 是系统调用,库函数在系统调用的 “上层” , 是对系统调用的封装”。但是 write 没有缓冲区,而 printffwrite 有,所以缓冲区是二次加上的,又因为是 C 语言,所以这个 “用户级缓冲区” 由 C 标准库提供。

例如 printf 的大致内容(为了处理可变参数、错误处理和线程安全,肯定会更复杂):

int printf(const char* str,...){
    //声明一段缓冲区,一般是 char buffer[SIZE]; SIZE为某个整数的标识符
    //因为缓冲区本质是暂时存放一段数据,然后根据刷新策略将数据弄走,
    //所以只要能在进程地址空间上表示的连续空间都可以作为缓冲区
    char buffer[SIZE];
    //这里处理各种%c、%d等等进行格式化
    while(*str!='\0'){
        if(*str=='%'){
            //格式化处理,例如将int型数据转换成char型
            //将识别出来的数据拷贝到buffer
        }
    }
    //将处理好的buffer通过系统调用打印到显示屏,Linux是write,Windows和其他操作系统可能是别的
    int len = strlen(buffer);
    write(1, buffer, len);
    return len;//打印输出成功的字符数
}

日常用的最多啊的是 C/C++ 提供的用户级别的缓冲区,也可以叫做语言级别的缓冲区,后者是前者的具体实现方式。

文件缓冲区

FILE 结构体:typedef struct _IO_FILE FILE;,在 /usr/include/stdio.h 可看到。

[Bjarne@VM-8-8-centos cTest]$ grep -n 'typedef struct _IO_FILE FILE' /usr/include/stdio.h
48:typedef struct _IO_FILE FILE; # -n指定出现在第几行
[Bjarne@VM-8-8-centos cTest]$ grep -Rn 'struct _IO_FILE {' /usr/include/* # -R表示递归查找
/usr/include/libio.h:246:struct _IO_FILE {
[Bjarne@VM-8-8-centos cTest]$ head -287 /usr/include/libio.h | tail -42 # 通过hean、tail和管道查看指定部分

通过head -287 /usr/include/libio.h | tail -42,在/usr/include/libio.h 可以看到的信息大概是这样:

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; //封装的文件描述符
// 省略部分信息
};

因此进程打开的文件除了具体的文件 inode文件的打开方法集,还有文件指定的缓冲区

缓冲区特性的应用:进度条的实现

进度条的应用通常是在下载、登录时使用。

进度条样式效果示例:

[###           ][xx%][\]
[==>           ][xx%][\]

这里通过在第一个 [] 内填充符号的方式实现进度条,通过修改第 2 个 [] 内的数字和第 3 个 [] 内的字符,让用户 ”感受到进度条确实是在运行“ 。

所以进度条理论上只会降低程序运行的效率,一般只有在特别耗时的情况才会通过进度条随时监视进度。但很多情况为了 “安抚用户的情绪,不得不设计。

简易进度条的实现

这里的简易进度条要求:

  1. 限制一段范围(一般是100 个字符,可自定义)。
  2. 在这段范围中推进自定义的符号(可以是 # ,也可以是 ==>,还可以个性化例如 ^v^)。
  3. 在末尾携带进度百分比。
  4. 给出旋转光标给用户反馈进度条一直在推进。

进度条样式效果:

[######        ][xx%][\]
[=====>        ][xx%][\]
[^v^^v^        ][xx%][\]

实现这个样式,可以用 printf("[%s][%d\%][%c]\r",...); 输出,因为是 \r,所以 printf 的下一句需要是 fflush(stdout),否则进程不会进行行刷新,输出的数据一直在缓冲区不会打印到显示屏。之后就是对进度条的进度操作,一般是下载的数据占整体的比例,若是图形化界面会有更直观的表示。

sleep 在 Linux 中是以秒为单位,这对进度条来说太慢,需要使用函数 usleep

参考 man 3 usleep

#include <unistd.h>
int usleep(useconds_t usec);

usleep以微秒为单位让程序挂起(或者说睡眠)一段时间,1000微秒等于1毫秒。

这里提供三个版本的进度条:

  1. 简单原理版本。
  2. 实际工程实践版本。
  3. C 语言扩展–带颜色。

简单原理版本(不进行封装和声明、定义分离,仅供参考):

#include<stdio.h>
#include<unistd.h>
#include<string.h>

const char *rotat = "|/-\\";//进度条旋转表示程序正常推进

int main(){
    int rate = 0;//进度百分比
    char bar[101] = {'\0'};//进度条本体
    int num = strlen(rotat);

    while(rate<=100){
        //%-100s 是为了打印长度为100的进度条,-是左对齐。
        printf("[%-100s][%d%%][%c]\r", bar, rate, rotat[rate%num]);
        fflush(stdout);
        usleep(1000*50);//50毫秒为单位,可修改
        bar[rate++] = '#';
    }
    printf("\n");
    return 0;
}

工程实践版本

任何进度条一定是和某种任务关联。例如下载、压缩、解压、删除文件等。

所以整理进度条函数的逻辑:

  1. 给进度 rate,进度条函数会根据进度 rate 刷新进度条,之后在调用进度条函数的地方进行任务记录、任务进行。
  2. 先执行任务时,再打印进度条时,循环可设置进度为 100 % 100\% 100% 时停止。
  3. 可能会出现任务暂停在某个进度的情况(例如网络卡顿),这种情况可以通过操控进度的值让最后的光标进行旋转,“迷惑用户
  4. 这里采用 101 个长度的 char 数组模拟进度条,floor(rate*100) 作为进度条的填充,则有效进度条范围是 [0,99],当进度达到 99 % 99\% 99% 时理应填满整个进度条。
  5. 进度有可能从 0.99 跳到 1,或者 1.1 等,所以当进度大于 100 % 100\% 100% 时直接输出 100 % 100\% 100% 。因此需要特殊处理。
  6. 可选择自定义的进度条,例如这里的模拟实现采用 ==>

工程实践版本的大致参考程序如下:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

typedef void (*callshow_v)(double); // 进度条展示版本

void process(double rate) {
    const char *rotat = "|/-\\";   // 进度条旋转表示程序正常推进
    static char bar[101] = {'\0'}; // 进度条本体
    static size_t cnt = 0;         // 控制进度条的旋转,无符号数无需担心溢出
    int rlen = strlen(rotat);
    if (rate < 100.0) { // 决定采用浮点数时,要考虑不同下载速度的情况
        if (rate < 99.0) {
            bar[(int)rate] = '=';
            bar[(int)rate + 1] = '>';
            int tmp = (int)rate - 1;
            while (tmp >= 0 &&
                   bar[tmp] != '=') { // 一次完成几个百分比时向前填充
                bar[tmp] = '=';
                --tmp;
            }
        } else {
            bar[99] = '=';
            bar[100] = '\0'; // 第101处填充`\0`
        }
        printf("[%-100s][%5.1lf%%][%c]\r", bar, rate, rotat[cnt % rlen]);
        ++cnt;
        fflush(stdout);
    } else { // 任务完成的情况
        rate = 100.0;
        bar[99] = '=';
        bar[100] = '\0';
        int tmp = 98; // 假设用的是更先进的通信技术例如4G网,1秒钟流量7MB
        while (tmp >= 0 && bar[tmp] != '=') { // 向前填充
            bar[tmp] = '=';
            --tmp;
        }
        printf("[%-100s][%5.1lf%%][%c]\n", bar, rate, rotat[0]);
        memset(bar, '\0', sizeof(bar)); // 手动清空迎接下个任务
    }
}

void download(callshow_v cs) {
    int aim = 1024 * 1024 * 10; // 10MB 待下载数据
    int v = 1024;               // 下载速度为1KB(原始人的网络)
    int ret = 1024 * 1024 * 9;  // 下载进度记录,初始90%,可设为0
    // int cnt = 500;
    while (ret < aim) { // 达到目标时应该停止,因为这里是先下载再记录
        ret += v;       // 正在下载
        usleep(1000 * 10);
        double rate = ret * 100.0 / aim;
        // while(cnt&&rate>=66.0){//模拟网络卡顿的情况
        //     ret-=v;
        //     rate = ret*100.0/aim;
        //     --cnt;
        // }
        cs(rate); // 刷新进度条
    }
}

int main() {
    download(process);    //上传函数名作为参数
    // download(process); //模拟多次下载
    // download(process);
    return 0;
}

C语言可以使用格式化语句输出简单的彩色代码片段。例如,蓝底红字:

printf("[\033[31;44m%-100s\033[0m][%5.1lf%%][%c]\r",bar,rate,rotat[cnt%rlen]); 

具体参考【C语言】C语言基础——printf带颜格式化输出(Linux)_printf格式化输出文本颜色-CSDN博客

\r被解释成回车加换行的情况

部分拓展屏的终端驱动程序或 xshell 在不同的拓展屏可能会将 \r 解释成回车加换行,这时可通过循环输出 \b 即一直输出退格直到回退到行首。这个方案适用所有终端显示屏。

void process(double rate){
    //保留原代码不变
    if(rate < 100.0){//决定采用浮点数时,要考虑不同下载速度的情况
        //保留原代码不变
        int outlen = printf("[%-100s][%5.1lf%%][%c]",bar,rate,rotat[cnt%rlen]);
        for(int i=0;i<outlen;i++)//循环输出\b退格
            printf("\b");
        ++cnt;
        fflush(stdout);
    }
    else{//任务完成的情况
		//保留原代码不变
    }
}

也可以使用 ANSI 专业序列 \033[2K\r 。这个序列的功能是检测到这个序列时,终端设备会将光标移到开头。这个方案相对于输出等量的 \b 能显著提升性能,但要求目标终端支持 ANSI 。

void process(double rate){
    //保留原代码不变
    if(rate < 100.0){
        //保留原代码不变
        //检测到\033[2K\r,将光标移动到开头,再输出后续字符
        printf("\033[2K\r[%-100s][%5.1lf%%][%c]",bar,rate,rotat[cnt%rlen]);
        ++cnt;
        fflush(stdout);
    }
    else{
        //保留原代码不变
        printf("\033[2K\r[%-100s][%5.1lf%%][%c]\n",bar,rate,rotat[0]); 
        memset(bar,'\0',sizeof(bar));//手动清空迎接下个任务
    }
}

除了进度条,标准库 stdio.h 的部分库函数例如 printffputsfopen 等可以自己模拟实现。其中结构体 FILE 内部存在缓冲区,这个缓冲区可以是栈上开辟,也可以用 malloc 库函数申请,根据自己的需求还可以添加文件描述符 fd 接收系统调用 open 的返回值、flag 接收文件的打开模式。

例如简易版:

#pragma once

#define SIZE 4096
#define FLUSH_NONE 1
#define FLUSH_LINE (1<<1)
#define FLUSH_ALL  (1<<2)

typedef struct _myFILE
{
    int fileno;
    int flag;
    char buffer[SIZE];
    int end;
}myFILE;

其他的 fopenfwritefflushfclose 等都可以模拟实现。这里暂时省略。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐