C语言I/O缓冲

buffered I/O 和 unbuffered I/O

我们可以简单的认为系统I/O调用是unbuffered I/O,而C语言标准库I/O函数(即stdio函数)是buffered I/O。

但是需要注意的是:认为使用unbuffered I/O读写磁盘时没有使用缓冲是一个很大的错误认识,实际上,内核是通过高速缓存(kernel buffer cache)来进行实际的磁盘读写的。也就是说,read()和write()系统调用在操作磁盘文件时通常也不会直接发起磁盘访问,而是仅仅在用户空间缓冲区(程序中申请的内存空间)与内核高速缓存之间复制数据。采用这一设计的目的是使read()和write()调用更为快速,减少实际的磁盘访问,因为磁盘访问是很费时的。

而buffered I/O提供的缓冲区工作在用户空间,其存在的目的无疑也是加快程序的效率,主要体现在两个方面

  1. 减少read()和write()的调用次数 -- 因为频繁地调用系统调用对应用程序来说是很大的效率损失。
  2. 可以块对齐(block-align)地操作磁盘, -- 因为现在的文件系统和磁盘大都是以“块”为操作单位,所以遵循对齐原则肯定能加速程序速度

因此C语言标准库提供了stdio函数,这些函数提供了stdio缓冲区,那么使用这些函数即提高了程序的效率,又帮助我们免了自行处理数据缓冲的麻烦。

下图是从《The Linux Programming Interface》13.4节中截出的,非常完美。

从图片中可以很清晰明了地看出,buffered I/O库函数都是调用相关的unbuffered I/O系统调用实现的,我们在使用buffered I/O库函数时,整个过程大致如下:

在用户空间维护一块stdio缓冲区(可以是库函数自行申请维护的,也可以是用户自己申请的一块内存空间),第一次调用库函数fread()读取文件内容的时候,其实会调用read()系统调用,内核会一次性尽可能多的从磁盘文件读取数据到内核高速缓冲,并且读取数据到stdio缓冲区,因此下次再次读取数据的时候,就直接在stdio缓冲区中读取数据,而不需要调用read()了。同样的,fwrite()写数据的时候,先将数据写入stdio缓冲区,等stdio缓冲区满的时候,再调用write()系统调用将数据写入内核高速缓冲,最终,内核会发起磁盘操作,将数据真正地写入磁盘。

本文的重点放在buffered I/O,通过实例来探究stdio缓冲的诸多特性!

 

buffered I/O的分类

标准I/O提供了三种类型的stdio缓冲:

    全缓冲(fully buffered):在这种缓冲模式下,只有在填满stdio缓冲区后才会进行实际的I/O操作(即调用read()或者write()系统调用),也就是说单次读、写数据的大小与stdio缓冲区大小相同。通常打开的文件流是全缓冲的(文件位于磁盘上,而磁盘是块设备)。

    行缓冲(line buffered):在这种缓冲模式下,当在输入和输出流遇到换行符时,标准I/O库执行I/O操作(即调用read()或者write()系统调用)。通常情况下,stdin和stdout都是涉及的键盘显示器这些字符设备,所以是行缓冲的

    无缓冲(unbuffered):这种缓冲模式很好理解了,就是不存在stdio缓冲区,每次I/O操作就直接调用read()或者write()系统调用。stderr通常是不带缓冲的,这就使得出错信息可以尽快显示出来,而不管它们是否含有一个换行符。

关于这三种stdio缓冲,标准是如何描述的呢?

When a stream is unbuffered, characters are intended to appear from the source or at the destination as soon as possible. Otherwise characters may be accumulated and transmitted to or from the host environment as a block. When a stream is fully buffered, characters are intended to be transmitted to or from the host environment as a block when a buffer is filled. When a stream is line buffered, characters are intended to be transmitted to or from the host environment as a block when a new-line character is encountered. Furthermore, characters are intended to be transmitted as a block to the host environment when a buffer is filled, when input is requested on an unbuffered stream, or when input is requested on a line buffered stream that requires the transmission of characters from the host environment.  Support for these characteristics is implementation-defined, and may be affected via the setbuf and setvbuf functions.(SO/IEC 9899:1999 7.19.3/3)

At program startup, three text streams are predefined and need not be opened explicitly — standard input (for reading conventional input), standard output (for writing conventional output), and standard error (for writing diagnostic output). As initially opened, the standard error stream is not fully buffered; the standard input and standard output streams are fully buffered if and only if the stream can be determined not to refer to an interactive device.(SO/IEC 9899:1999 7.19.3/7)

 

接下来,我们具体看看glibc中对FILE对象的定义,在/usr/include/stdio.h中可以找到如下:

类型FILE被称为opaque type(不透明数据类型)。在/usr/include/libio.h中有struct _IO_FILE的具体定义:

咋一看有些复杂,不过我们这里只关注前面的几个结构体元素就足矣。

_IO_read_base --> 读缓冲区首地址

_IO_read_end --> 读缓冲区末尾

那么_IO_read_end - _IO_read_base的值就是读缓冲区长度了

 

_IO_write_base --> 写缓冲区首地址

_IO_write_end --> 写缓冲区末尾

那么_IO_write_end - _IO_write_base的值就是写缓冲区长度了

 

_IO_buf_base --> 缓冲区首地址

_IO_buf_end --> 缓冲区末尾

那么_IO_buf_end - IO_buf_base的值就是缓冲区长度了

通过下文的调试我们可以发现,其实_IO_read_base、_IO_write_base和_IO_buf_base都指向同一个缓冲区,并且只有通过第一次I/O操作才会分配stdio缓冲区

特别需要指出的是其中的_fileno就是FILE流与之关联的文件描述符,函数fileno返回的就是_fileno.

此外,其中的_flags代表的是该FILE流的各种标志,同样的在/usr/include/libio.h中可以找到宏定义:

其中的_IO_UNBUFFERED代表无缓冲,_IO_LINE_BUF代表行缓冲,_IO_IS_FILEBUF代表全缓冲

接下来,我们使用《Advanced Programming in the UNIX Environment》中5.12节中的程序实际调试一下。程序如下:

开始调试:

可以看出,在第一次I/O操作之前,系统都是没有为每个流分配缓冲区的。接着,stdin、stdout和stderr各操作一次,看看效果如何:

 

 

在每一个I/O操作后,可以看到都分配了相应的缓冲区。再看下最后程序的打印结果如下:

分别重定向stdin,stdout和stderr后程序输出如下:

结论:

在我们的系统上,默认的stdin和stdout是连接终端的(即键盘和显示器),它们是行缓冲的。行缓冲的长度是1024字节。

当重定向stdin和stdout重定向到普通文件时,他们变成全缓冲的了。全缓冲的的长度一般是系统参照文件系统的优先选用的I/O长度来决定的,也就是stat结构中成员st_blksize的值,在man文档中可以找到对该成员的描述如下:

此外,我们也看到,stderr不管有没有重定向,它都是无缓冲模式。最后,普通文件系统默认是全缓冲的。

 

改变默认缓冲模式

我们可以调用以下几个函数来显示地改变stdio缓冲的模式。

setvbuf的第二个参数mode可以设置为_IONBF、_IOLBF_IOFBF,分别对应无缓冲行缓冲全缓冲

setbuf       相当于 setvbuf(stream, buf, buf  ?  _IOFBF : _IONBF,  BUFSIZ);

setbuffer   相当于 setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);

setlinebuf  相当于 setvbuf(stream, NULL, _IOLBF, 0);

 

stdio缓冲区什么时候flush

所谓flush就是调用wirte()强制stdio缓冲区中的数据写入内核高速缓冲区,就这个问题可以分解成两小块:

1.  行缓冲什么时候被flush? 

2.  全缓冲什么时候被flush?

 

先总结如下:

行缓冲被flush的条件

  1. 缓冲区已满
  2. 遇到换行符
  3. 需要从一个无缓冲的流中读取数据,或者从行缓冲的流中(需要从内核读取数据)读取数据
  4. 手动调用fflush函数
  5. 显示地调用fclose关闭流,或者程序结束时调用exit

除了上述5条之外,在包括GLIBC库在内的许多C函数库实现中,若stdin和stdout指向终端,那么无论何时从stdin中读取输入时,都将隐含调用一次fflush(stdout)函数,这将刷新写入stdout的任何数据。然后这并不是标准,要保证程序的可移植性,应该显示地调用fflush(stdout)。

 

全缓冲被flush的条件

  1. 缓冲区已满
  2. 手动调用fflush函数
  3. 显示地调用fclose关闭流,或者程序结束时调用exit

 这里需要强调的是函数exit和_exit、_Exit的区别:

 

  1. exit() -- 调用exit()函数,exit则先执行一些清理工作(包括调用执行各终止处理程序,关闭所有标准I/O流)。
  2. _exit()和_Exit() -- 调用_exit和_Exit则立即进入内核

 

在《Advanced Programming in the UNIX Environment》7.3小节中Figure 7.2非常形象地展示了它们的区别:

 

stdio缓冲区问题释疑

好,在我们了解了stdio缓冲的基本知识后,现在就平时经常遇到的貌似“奇怪”现象释疑。

案列一

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

int main(int argc, char *argv[])
{
    char buf1[10], buf2[10];
    int c;

    memset(buf1, 0, sizeof(buf1));
    memset(buf2, 0, sizeof(buf2));

    fgets(buf1, sizeof(buf1), stdin);
    fgets(buf2, sizeof(buf2), stdin);
    printf("buf1: %s\n", buf1);
    printf("buf2: %s\n", buf2);

    exit(EXIT_SUCCESS);
}

程序原意是通过两次输入分别给buf1和buf2赋值,但是我们发现当第一次输入超出10个字符时,多余的字符会自动赋给buf2,根本不需要第二次输入字符串,程序就结束了。

知道了stdio缓冲区的存在就很好理解这样的行为了:程序会一次性将我们的输入全部读入stdio缓冲区,当第二次fgets时程序发现缓冲区中还有数据,则立刻去读取数据了。

那么解决的唯一办法就是手动清除多余的字符,这样的话,程序发现缓冲区中没有数据可读,则会等待我们的二次输入。那么如何清空stdin呢?fflush(stdin)可不可以?答案是不确定,经测试发现在linux平台上是不可以的,在windows上却是可以的。查阅C标准,在描述这个函数时只说明其对输出流的作用,而对输入流怎样只字未提,因此应该是平台实现相关的东西了。在MSDN中对fflush函数的描述如下:

因此fflush(stdin)的方法不行!最通用的方法如下:

    int c;
    while ((c = getchar()) != '\n' && c != EOF);

注意,我们并不是真正地清除stdio缓冲中的数据,而是简单的跳过了多余的字符。等同于下句代码:

stdin->_IO_read_ptr = stdin->_IO_read_end;

 

案列二

 该案列曾经在酷壳讨论过(一个FORK的面试题):

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(int argc, const char *argv[])
{
        int i;

        for (i = 0; i < 2; i++) {
                fork();
                printf("-");
        }
        wait(NULL);
        wait(NULL);

        exit(EXIT_SUCCESS);
}

在那篇文章中,陈皓解释得非常清楚,文章最后还通过画图来解释,我觉得唯一的缺陷是图中没有标出哪些“-”是进程自己打印的,哪些“-”是从父进程那继承来的,我修改的图如下所示,其中黑色的“-”代表是进程自己打印的红色的“-”代表是从父进程那继承来的

 

如果把程序中的printf("-");改到fork()之前会是怎样呢? 绘图如下:

案列三

程序如下:

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

int main(int argc, const char *argv[])
{
    printf("hello world\n");
    write(STDOUT_FILENO, "write str\n", 10);
    fork();

    exit(EXIT_SUCCESS);
}

 程序运行结果如下:

可以发现,当重定向标准输出到一个文件时,printf()的输出行出现了两次,并且write()的输出先与printf()。原因如下:

当标准输出重定向到文件时,标准输出的缓冲模式由行缓冲转变成全缓冲,所以当调用fork()时,printf()输出的字符串仍在父进程的stdout缓冲区中,并随子进程的创建产生一份副本。父子进程最后都会调用exit()函数刷新各自的stdio缓冲区,从而导致重复的输出结果。write()的输出结果先于printf()而出现,是因为write()会将数据立即传给内核高速缓冲中,而printf()的输出则需要等到调用exit()刷新stdio缓冲时。

 

本文的测试程序地址:https://github.com/astrotycoon/buffered-IO

 

参考链接:

http://blog.sina.com.cn/s/blog_69708ebe0100raxm.html(浅谈无缓存I/O操作和标准I/O文件操作区别 )

http://bbs.chinaunix.net/thread-982604-1-1.html(标准I/O(buffered I/O)浅析)

http://blog.chinaunix.net/uid-26833883-id-3198114.html(linux 标准IO缓冲机制探究)

http://blog.csdn.net/pakko/article/details/8779110(磁盘IO:缓存IO、直接IO、内存映射)

Pipes, Forks, & Dups: Understanding Command Execution and Input/Output Data Flow

流缓冲影响父子进程通讯的问题

文件描述符 流 流缓冲的一些概念与问题

stdio 的 buffer 问题

Stdout Buffering

Does reading from stdin flush stdout?

从一个fork()实例理解全缓冲与行缓冲

In C, what does buffering I/O or buffered I/O mean?

buffering in standard streams

设置 linux 命令缓冲模式

Buffered and Unbuffered inputs in C

setbuf(stdin,NULL) can't work, why?

從非緩衝輸入流到 Linux 控制檯的歷史

玩转 SHELL 脚本之:Shell 命令 Buffer 知多少?

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值