当前位置: 代码网 > 科技>电脑产品>内存 > 3.3、缓冲区(缓存)、再谈 scanf

3.3、缓冲区(缓存)、再谈 scanf

2024年08月02日 内存 我要评论
缓冲区(Buffer)又称为缓存(Cache),是内存空间的一部分。

1、缓冲区

  • 缓冲区(buffer)又称为缓存(cache),是内存空间的一部分。
  • 计算机在内存中预留了一定的存储空间用来暂时保存输入或输出的数据,这部分预留的空间就叫缓冲区(缓存)。
    • 有时候,从键盘输入的内容(将要输出到显示器上的内容)会暂时进入缓冲区,待时机成熟,再一股脑将缓冲区中的所有内容 “倒出”,我们才能看到变量的值被刷新,或者屏幕产生变化。
    • 有时候,用户希望得到最及时的反馈,输入输出的内容就不能进入缓冲区。
  • 学习缓冲区能使你对输入输出的认识上升到一个更高的层次。

(1)缓冲区(缓存)的引入原因

  • 缓冲区是为了让低速的输入输出设备和高速的用户程序能够协调工作,并降低输入输出设备的读写次数。
    • 用户程序的执行速度可以看做 cpu 的运行速度,如果没有各种硬件的阻碍,理论上它们是同步的。
    • 硬盘的速度要远低于 cpu,它们之间有好几个数量级的差距,当向硬盘写入数据时,程序需要等待,不能做任何事情,就好像卡顿了一样,用户体验非常差。
    • 计算机上绝大多数应用程序都需要和硬件打交道,例如:读写硬盘、向显示器输出、从键盘输入等,如果每个程序都等待硬件,那么整台计算机也将变得卡顿。
  • 有了缓冲区就可以将数据先放入缓冲区中(内存的读写速度也远高于硬盘),然后程序可以继续往下执行,等所有的数据都准备好了,再将缓冲区中的所有数据一次性地写入硬盘,这样程序就减少了等待的次数,变得流畅起来。
  • 缓冲区的另外一个好处:减少硬件设备的读写次数。
    • 其实程序并不能直接读写硬件,它必须告诉操作系统,让操作系统内核(kernel)去调用驱动程序,只有驱动程序才能真正的操作硬件。
    • 从用户程序到硬件设备要经过好几层的转换,每一层的转换都有时间和空间的开销,而且开销不一定小;一旦用户程序需要密集的输入输出操作,这种开销将变得非常大,会成为制约程序性能的瓶颈。
    • 这时分配缓冲区就必不可少。每次调用读写函数,先将数据放入缓冲区,等数据都准备好了再进行真正的读写操作,这就大大减少了转换的次数。合理的缓冲区设置能成倍提高程序性能。
  • 缓冲区其实就是一块内存空间,它用在硬件设备和用户程序之间,用来缓存数据,目的是让快速的 cpu 不必等待慢速的输入输出设备,同时减少操作硬件的次数。

(2)缓冲区的类型

  • 根据不同的标准,缓冲区可以有不同的分类。
    • 根据缓冲区对应的是输入设备还是输出设备:输入缓冲区、输出缓冲区
    • 根据数据刷新(清空缓冲区、将缓冲区中的数据 “倒出”)的时机:全缓冲、行缓冲、不带缓冲

(a)全缓冲

  • 当缓冲区被填满以后才进行真正的输入输出操作。缓冲区的大小都有限制的,比如 1kb、4mb 等,数据量达到最大值时就清空缓冲区。
  • 全缓冲的典型代表是对硬盘文件的读写(在“文件操作” 章节中深入讲解)。
  • 在实际开发中,将数据写入文件后,打开文件并不能立即看到内容,只有清空缓冲区,或关闭文件/关闭程序后,才能在文件中看到内容。这种现象,就是缓冲区在作怪。

(b)行缓冲

  • 当在输入或者输出的过程中遇到换行符时,才执行真正的输入输出操作。
  • 行缓冲的典型代表就是标准输入设备(键盘)和标准输出设备(显示器)。
  • 讲解 printf() 时在 linux 或者 mac os 平台测试了如下的代码:
    • 运行程序后会发现第一个 printf() 并没有立即输出,而是等待 5 秒以后,和第二个 printf() 一起输出。
    • 究其原因,就是 printf() 带有行缓冲区,"c语言"这几个字符要先放入缓冲区中,而不是立即显示到屏幕上。放入缓冲区以后,程序又暂停了 5 秒,然后执行第二个 printf(),又将"www.yuque.com/jiayue888\n"放入了缓冲区。注意最后的换行符\n,它会使得缓冲区刷新,将缓冲区中的所有内容都输出到显示器上,所以才看到两个 printf() 一起输出。
    • 如果将第一个 printf() 的最后加上换行符\n,此时第一个 printf() 会先输出,第二个 printf() 等待 5 秒以后才输出。这是因为,第一个 printf() 的最后有换行符\n,它会使得缓冲区刷新,所以立即就输出了,不用等着第二个 printf()。
#include<stdio.h>
#include<unistd.h>
int main()
{
    printf("c语言");
    sleep(5);  //程序暂停5秒钟
    printf("www.yuque.com/jiayue888\n");
    return 0;
}
  • 对于 scanf(),不管用户输入多少内容,只要不按下回车键,就不进行真正的读取。这是因为 scanf() 是带有行缓冲的,用户输入的内容会先放入缓冲区,直到用户按下回车键,产生换行符\n,才会刷新缓冲区,进行真正的读取。

(c)不带缓冲

  • 不带缓冲区,数据就没有地方缓存,必须立即进行输入输出。
  • getche()getch() 就不带缓冲区,输入一个字符后立即就执行了,根本不用按下回车键。
  • windows 下的 printf() 也不带缓冲区,不管最后有没有换行符\n,都会立即输出,所以对于类似的输出代码,windows 和 linux、mac os 会有不同的表现。
  • 错误信息输出函数 perror() 也没有缓冲区。错误信息必须刻不容缓、立即、马上显示出来。

(3)c 语言标准的模棱两可

  • c 语言标准规定,输入输出缓冲区要具有以下特征:
    • 当且仅当输入输出不涉及交互设备时,它们才可以是全缓冲的
    • 错误显示设备不能带有缓冲区
      • 现代计算机已经没有了专门的错误显示设备,所有的信息都显示到一个屏幕上,这里的错误显示设备只能是计算机的显示器。
      • 上面提到的 perror() 其实就是向错误显示设备上输出信息,但是现代计算机已经把显示器作为了错误显示设备,所以 perror() 也是向显示器上输出内容。
  • c 标准虽然规定交互设备(显示器和键盘)不能是全缓冲的,但并没有规定它们到底是行缓冲还是不带缓冲,这就导致不同的平台有不同的实现。

(a)输入设备

  • scanf()getchar()gets() 就是从输入设备(键盘)上读取内容。
  • 对于输入设备,没有缓冲区将导致非常奇怪的行为。
    • 如:本来想输入一个整数 947,没有缓冲区的话,输入 9 就立即读取了,根本没有机会输入 47,所以,没有输入缓冲区是不能接受的。
  • windows、linux、mac os 在实现时都给输入设备带上了行缓冲,所以 scanf()、getchar()、gets() 在每个平台下的表现都一致。
  • 但是在某些特殊情况下,我们又希望程序能够立即响应用户按键。
    • 如:在游戏中,用户按下方向键人物要立即转向,而且越快越好,这肯定就不能带有缓冲区了。windows 下特有的 getche()getch() 就是为这种特殊需求而设计的,它们都不带缓冲区。

(b)输出设备

  • printf()puts()putchar() 就是向输出设备(显示器)上显示内容。
  • 对于输出设备,有没有缓冲区其实影响没有那么大,顶多是晚一会看到内容,不会有功能性的障碍,所以 windows 和 linux、mac os 采用了不同的方案:
    • windows 平台下,输出设备是不带缓冲区的
    • linux 和 mac os 平台下,输出设备带有行缓冲区
  • 讲解 printf() 时在 windows 平台测试了如下的代码:
#include<stdio.h>
#include<windows.h>
int main()
{
    printf("c语言");
    sleep(5000);  // 程序暂停 5 秒钟
    printf("www.yuque.com/jiayue888\n");
    return 0;
}
  • 运行程序后,会发现第一个 printf() 首先输出(程序运行后立即输出),等待 5 秒以后,第二个 printf() 才输出,第一个 printf() 不会等待第二个 printf(),原因就是 windows 下的输出设备没有缓冲区,遇到 printf() 立即就输出了,不会有延迟。

(4)缓冲区的刷新(清空)

  • 刷新缓冲区:将缓冲区中的内容送达到目的地。缓冲区的刷新遵循以下的规则:
    • 不管是行缓冲还是全缓冲,缓冲区满时会自动刷新
    • 行缓冲遇到换行符\n时会刷新
    • 关闭文件时会刷新缓冲区
    • 程序关闭时一般也会刷新缓冲区,这个是由标准库来保障的
    • 使用特定的函数也可以手动刷新缓冲区(后续章节会详细讲解)。

(5)总结

  • 缓冲区位于用户程序和硬件设备之间,用来缓存数据,目的是让快速的 cpu 不必等待慢速的输入输出设备,同时减少操作硬件的次数。对于 io 密集型的网络应用程序(如网站、数据库、dns、cdn 等),缓冲区的设计至关重要,它能十倍甚至一百倍得提高程序性能。
  • 关于缓冲区还有更多的内容,将在 “文件操作” 一章中深入讲解。

2、结合缓冲区谈 scanf

  • scanf() 从标准输入设备(键盘)读取数据,带有行缓冲区,这让 scanf() 具有了一些独特的特性,如:可以连续输入、可以输入多余的数据等。
  • 同时,scanf() 也出现了一些奇怪的行为,如:有时两份数据之间有空格会读取失败,而有时两份数据之间又必须有空格。
  • scanf() 的这些特性都是有章可循的,其根源就是行缓冲区。当遇到 scanf() 函数时,程序会先检查输入缓冲区中是否有数据:
    • 如果没有,就等待用户输入。用户从键盘输入的每个字符都会暂时保存到缓冲区,直到按下回车键,产生换行符\n,输入结束,scanf() 再从缓冲区中读取数据,赋值给变量。
    • 如果有,那就看是否符合控制字符串的规则:
      • 如果能够匹配整个控制字符串,就直接从缓冲区中读取、不用等待用户输入。
      • 如果缓冲区中剩余的所有数据只能匹配前半部分控制字符串,那就等待用户输入剩下的数据。
      • 如果不符合,scanf() 还会尝试忽略一些空白符,如:空格、制表符、换行符等:
        • 如果这种尝试成功(可以忽略一些空白符),那么再重复以上的匹配过程。
        • 如果这种尝试失败(不能忽略空白符),那么读取失败。
  • scanf() 并不是直接让用户从键盘输入数据,而是先检查缓冲区,处理缓冲区中的数据。

(1)scanf() 连续输入

#include <stdio.h>
int main()
{
    int a, b, c;
    scanf("%d", &a);
    scanf("%d", &b);
    scanf("%d", &c);
    printf("a=%d, b=%d, c=%d\n", a, b, c);
    return 0;
}

/*
运行结果:
100 200 300↙
a=100, b=200, c=300
*/
  • 程序执行到第一个 scanf(),由于缓冲区中没有数据,所以会等待用户输入。从键盘输入100 200 300后按下回车键,输入就结束了,scanf() 开始从缓冲区中读取数据。
  • 第一个 scanf() 的控制字符串是"%d",会匹配到第一个整数 100,将其赋值给变量 a,并将内部的位置标记移动到 100 以后,此时缓冲区中剩下 200 300↙
    • 注意,换行符也是一个字符,也会进入缓冲区。
    • 位置标记:系统内部有一个专门用来记录 scanf() 读取到哪个位置的标记,随着 scanf() 的读取,该标记会向后移动,下一个 scanf() 就从这个新的位置开始读取。
  • 第二个 scanf() 的控制字符串也是"%d",需要读取一个整数,而此时缓冲区中的内容是 200 300↙,开头是一个空格,并不是一个有效的数字,不符合控制字符串的规则。空格是一个空白符,此处是可以忽略的,于是 scanf() 忽略空格后再继续匹配,就得到了数字 200。
  • 到了第三个 scanf(),缓冲区中剩下 300↙,同样会忽略开头的空格,匹配到数字 300。
  • 最终三个 scanf() 都匹配成功了,缓冲区中只留下了。等程序运行结束了,会释放缓冲区内存。

(2)scanf() 读取失败

#include <stdio.h>
int main()
{
    int a, b=999;
    char str[30];
    printf("b=%d\n", b);
    scanf("%d", &a);
    scanf("%d", &b);
    scanf("%s", str);
    printf("a=%d, b=%d, str=%s\n", a, b, str);
    return 0;
}

/*
运行结果:
b=999
100 www.yuque.com/jiayue888↙
a=100, b=999, str=www.yuque.com/jiayue888
*/
  • 程序执行到第一个 scanf() 时等待用户输入,从键盘输入100 www.yuque.com/jiayue888,按下回车键,scanf() 匹配到 100,赋值给变量 a,同时将内部的位置指针移动到 100 后面。
  • 到了第二个 scanf(),缓冲区中有数据,会直接读取。此时缓冲区中的内容为 www.yuque.com/jiayue888↙,即使忽略开头的空格也不是 scanf() 想要的整数,所以匹配失败了,不会给变量 b 赋值,b 的值保持不变,这就是两次输出变量 b 的值相同的原因。
  • 匹配失败意味着不会移动内部的位置指针,此时缓冲区中的内容仍然是www.yuque.com/jiayue888↙。执行到底三个 scanf() 时,它想要一个字符串,正好把www.yuque.com/jiayue888赋值给 str。
    • 注意:scanf()、gets() 在读取字符串时会忽略换行符,不会把换行符作为字符串的内容。

(3)不能忽略空白符的情形

#include <stdio.h>
int main()
{
    int a = 1, b = 2;
    scanf("a=%d", &a);
    scanf("b=%d", &b);
    printf("a=%d, b=%d\n", a, b);
    return 0;
}

/*
输入示例:
a=99↙
a=99, b=2
*/
  • 输入a=99,按下回车键,程序竟然运行结束了,只有第一个 scanf() 成功读取了数据,第二个 scanf() 仿佛没有执行一样,根本没有给用户任何机会去输入数据。这是为什么?
  • 第一个 scanf() 执行完后,将 99 赋值给了 a,缓冲区中只剩下一个换行符\n
  • 到了第二个 scanf(),发现缓冲区中有内容,但是又不符合控制字符串的格式,于是尝试忽略这个空白符。注意,这个时候的空白符是不能忽略的,所以就没有办法了,只能读取失败了。
  • 实测发现,空白符在大部分情况下都可以忽略,但当控制字符串不是以格式控制符 %d%c%f 等开头时,空白符就不能忽略,它会参与匹配过程,如果匹配失败,就意味着 scanf() 读取失败。
  • 本例中第二个 scanf() 的开头并不是格式控制符,而是写死的b字符,所以不会忽略换行符,而换行符和b又不匹配,于是读取失败。
  • 如果换成如下输入方式呢?
a=99 b=200↙
a=99, b=2
  • 第二个 scanf() 也读取失败了。执行到第二个 scanf() 时,缓冲区中剩下 b=200↙,开头的空格依然不能忽略,然而又和控制字符串不匹配,所以只能读取失败了。
  • 两种输入方式都不行,究竟该如何输入?很简单,不要让两份数据之间有空白符:
a=99b=200↙
a=99, b=200
  • 这样 a 和 b 都能够正确读取了。注意:a=99b=200中间是没有任何空格的。
  • 再修改一下以上代码(将第二个 scanf() 改成:scanf("%d", &b);):
#include <stdio.h>
int main()
{
    int a = 1, b = 2;
    scanf("a=%d", &a);
    //scanf("b=%d", &b);
    scanf("%d", &b);
    printf("a=%d, b=%d\n", a, b);
    return 0;
}

/*
运行结果:
a=100↙
200↙
a=100, b=200
*/
  • 此时,第二个 scanf() 的控制字符串以%d开头,就可以忽略换行符了。忽略换行符以后,缓冲区中就没有内容了,所以会等待用户输入。输入 200 以后,第二个 scanf() 就匹配成功了,将 200 赋值给变量 b。
  • 为什么只有当控制字符串以格式控制符开头时,才会忽略换行符呢?
    • 目前还不清楚,也没有资料可查,先记住这个结论即可。

3、清空(刷新)缓冲区,从根本上消除奇怪的行为

  • 缓冲区的优点:加快程序的运行速度,减少了硬件的读写次数,让整个计算机变得流畅起来
  • 缓冲区也带来一些负面影响,该如何消除这些负面影响呢?在输入输出之前清空(刷新)缓冲区即可:
    • 对于输出操作,清空缓冲区会使得缓冲区中的所有数据立即显示到屏幕上(这些数据没有地方存放了,只能输出了)。
    • 对于输入操作,清空缓冲区就是丢弃残留字符,让程序直接等待用户输入,避免引发奇怪的行为。

(1)清空输出缓冲区

  • 清空输出缓冲区:
    • fflush(stdout);:清空标准输出(显示器)缓冲区
      • fflush():一个专门用来清空缓冲区的函数
      • stdout(standard output):标准输出设备,即显示器。
  • windows 平台下的 printf()puts()putchar() 等输出函数都是不带缓冲区的,所以不用清空,以下代码演示在 linux 和 mac os 平台下清空缓冲区:
#include<stdio.h>
#include<unistd.h>
int main()
{
    printf("c语言");
    // 本次输出结束后立即清空缓冲区
    fflush(stdout);  
    sleep(5);
    printf("www.yuque.com/jiayue888\n");
   
    return 0;
}
  • 程序运行后,第一个 pirntf() 立即输出,等待 5 秒以后,第二个 printf() 才输出。
  • 如果不加fflush(stdout)语句,程序运行后,第一个 printf() 并不会立即输出,而是等待 5 秒以后和第二个 scanf() 一起输出。

(2)清空输入缓冲区

  • 没有一种既简洁明了又适用于所有平台的清空输入缓冲区的方案。
  • 只有一种很蹩脚的方案能适用于所有平台:将输入缓冲区中的数据都读取出来(但不使用)。
  • 经翻墙查阅了很多英文资料,且测试了很多平台和编译器确认,有如下两种方式。

(a)使用 getchar() 清空缓冲区

  • getchar() 是带有缓冲区的,每次从缓冲区中读取一个字符,包括空格、制表符、换行符等空白符,只要让 getchar() 不停地读取,直到读完缓冲区中的所有字符,就能达到清空缓冲区的效果:
int c;
while((c = getchar()) != '\n' && c != eof);
  • 该代码不停地使用 getchar() 获取缓冲区中的字符,直到遇见换行符\n或到达文件结尾才停止。在实际开发中按照如下形式使用即可:
#include <stdio.h>
int main()
{
    int a = 1, b = 2;
    scanf("a=%d", &a);
    
    // 在下次读取前清空缓冲区
    char c;
    while((c = getchar()) != '\n' && c != eof); 

    scanf("b=%d", &b);
    printf("a=%d, b=%d\n", a, b);
   
    return 0;
}

/*
输入示例:
a=100↙
b=200↙
a=100, b=200
*/
  • 按下第一个回车键后,只有第一个 scanf() 读取成功了,第二个 scanf() 并没有开始读取,等再次输入并按下回车键后,第二个 scanf() 才开始读取。如果没有清空缓冲区的语句,按下第一个回车键后,两个 scanf() 都读取了,只是第二个 scanf() 读取失败了,让人觉得很怪异。
  • 改变输入方式,再次尝试一下:
a=100b=200↙
b=300↙
a=100, b=300
  • 第一次输入的多余内容并没有起作用,就是因为它们在第二个 scanf() 之前被清空了。
  • 这种方案的关键之处在于,getchar() 是带有缓冲区的,并且一切字符通吃,或者说一切字符都会读取,不会忽略。不过这种方案有个缺点,就是要额外定义一个变量 c,对于有强迫症的读者来说可能有点难受。

(b)使用 scanf() 清空缓冲区

  • scanf() 还有一种高级用法,就是使用类似于正则表达式的通配符,这样它就可以读取所有字符,包括空格、换行符、制表符等空白符,不会再忽略它们。并且,scanf() 还允许把读取到的数据直接丢弃,不用赋值给变量。
  • 示例:
    • scanf("%*[^\n]"); scanf("%*c");
    • 第一个 scanf() 将逐个读取缓冲区中\n之前的其它字符,% 后面的 *表示将读取的这些字符丢弃,遇到\n字符时便停止读取。
    • 此时,缓冲区中尚有一个\n遗留,第二个 scanf() 再将这个\n读取并丢弃,这里的星号和第一个 scanf() 的星号作用相同。
    • 由于所有从键盘的输入都是以回车结束的,而回车会产生一个\n字符,所以将\n连同它之前的字符全部读取并丢弃之后,也就相当于清除了输入缓冲区。
#include <stdio.h>
int main()
{
    int a = 1, b = 2;
   
    scanf("a=%d", &a);
    scanf("%*[^\n]"); scanf("%*c"); // 在下次读取前清空缓冲区
    scanf("b=%d", &b);
    printf("a=%d, b=%d\n", a, b);
   
    return 0;
}

/*
输入示例 1:
a=100↙
b=200↙
a=100, b=200

输入示例 2:
a=100b=200↙
b=300↙
a=100, b=300
*/
  • 相比使用 getchar(),这种方案不用额外定义一个变量,看起来更加整洁。

(c)两种不通用、不建议的方案

  • 以上两种清空输入缓冲区的方案是通用的。除此以外,有些教材和老师可能还讲解过其它的方案,常见的有两种:fflush(stdin)rewind(stdin)
  • fflush(stdin)
    • fflush(stdin) 常用于 windows 平台,在 vc 6.0、vs2010 等较老的编译器下确实能够清空缓冲区。
    • c 语言标准规定,当 fflush() 用于 stdout 时,必须要有清空输出缓冲区的作用;但是 c 语言标准并没有规定 fflush() 用于 stdin 时的作用,编译器的实现者可以自由决定,所以它的行为是未定义的。
    • 较老的微软编译器(如 vc 6.0、vs2010)进行了扩展,赋予了 fflush(stdin) 清空输入缓冲区的功能。但较新的微软编译器(如 vs2015、vs2017)又取消了这种扩展,不再支持 fflush(stdin)。
    • 较老的 gcc 是不支持 fflush(stdin) 的,但最新的 gcc 又开始支持 fflush(stdin)
    • llvm/clang 编译器始终不支持 fflush(stdin)。
    • 总之,fflush(stdin) 这种不标准的写法只适用于一部分编译器,通用性非常差,不建议使用。
  • rewind(stdin)
    • rewind() 函数并没有清空缓冲区的功能,但 rewind(stdin) 偏偏在某些编译器(如 vs2015、llvm/clang)下会导致清空缓冲区的假象。
    • 在 gcc 下,rewind(stdin) 是没有任何效果的。

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com