阅读 165

C库的IO缓存机制

在标准的 C 库中,对 IO 有一定的缓存机制。理解这些机制或许能在分析某些问题时提供参考。

概览

缓存分类

  • 块缓存

    一般用于访问真正的磁盘文件。C库会为文件访问申请一块内存,只有当文件内容将缓存块填满或执行冲刷函数flush时,C库才会将缓存内容写入内核中。

  • 行缓存

    一般用于访问终端。当遇到一个换行符时,就会引发真正的I/O操作。需要注意的是,C库的行缓存也是固定大小的。因此,当缓存已满,即使没有换行符时也会引发I/O操作。

  • 无缓存

    C库没有进行任何的缓存。任何C库的I/O调用都会引发实际的I/O操作。
    复制代码

标准输入输出的默认缓存机制

stdio.h 中声明了 stdinstdoutstderr 的全局变量以及对应的宏:

typedef struct _IO_FILE FILE;
/* Standard streams.  */
extern struct _IO_FILE *stdin;      /* Standard input stream.  */
extern struct _IO_FILE *stdout;     /* Standard output stream.  */
extern struct _IO_FILE *stderr;     /* Standard error output stream.  */

#ifdef __STDC__
/* C89/C99 say they're macros.  Make them happy.  */
#define stdin stdin
#define stdout stdout
#define stderr stderr
#endif
复制代码

可以看出 stdin stdout stderr 其实就是文件指针, 它们的定义代码如下:

_IO_FILE *stdin = (FILE *) &_IO_2_1_stdin_;
_IO_FILE *stdout = (FILE *) &_IO_2_1_stdout_;
_IO_FILE *stderr = (FILE *) &_IO_2_1_stderr_;
复制代码
DEF_STDFILE(_IO_2_1_stdin_, 0, 0, _IO_NO_WRITES);
DEF_STDFILE(_IO_2_1_stdout_, 1, &_IO_2_1_stdin_, _IO_NO_READS);
DEF_STDFILE(_IO_2_1_stderr_, 2, &_IO_2_1_stdout_, _IO_NO_READS+_IO_UNBUFFERED);
复制代码

DEF_STDFILE 是一个宏定义,用于初始化 C 库中的 FILE 结构。从源码上就可以看到3个标准输入输出的差异:

  • stdin: 文件描述符为 0, 不可写(_IO_NO_WRITES)
  • stdout: 文件描述符为 1, 不可读(_IO_NO_READS)
  • stderr: 文件描述符为 2, 不可读(_IO_NO_READS)

通常,所有文件都是块缓冲的。当文件上发生第一个I/O操作时,将调用 malloc 并获得一个最优大小的缓冲区。 如果一个流指向一个终端(比如通常的 stdout),那么它就是行缓冲的。标准错误流 stderr 总是未缓冲的。

从源码中的定义也能看出, stderr 在定义时还追加了 IO_UNBUFFERED,表示无缓冲。

C库接口

C库提供了接口,用于修改默认的缓存行为:

void setbuf(FILE *restrict stream, char *restrict buf);
void setbuffer(FILE *stream, char *buf, int size);
int setlinebuf(FILE *stream);
int setvbuf(FILE *restrict stream, char *restrict buf, int type, size_t size);
复制代码

前3个接口内部都调用了 setvbuf 接口, 所以主要看 setvbuf 接口就行。

  • size 参数设为 0 时,代表使用默认的最优大小缓冲区分配
  • size 不为 0 时,除了未缓冲的文件,buf 参数应该指向一个至少有 size 大小的缓冲区; 如果 buf 不为空,则调用方必须在流关闭后自己释放该缓冲区
  • size 不为 0,但 bufNULL 时,则库会自动分配给定大小的缓冲区,并且自动在流关闭时释放

例子

//c_lib_io_cache.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    printf("Hello ");
    if(0 == fork()) {
        printf("child\n");
        return 0;
    }
    printf("parent\n");
    return 0;
}
复制代码

上述代码执行后:

▶ gcc c_lib_io_cache.c -o main.o && ./main.o
Hello parent
Hello child
复制代码

因为 stdout 在终端默认为行缓存,所以最开始执行 printf("Hello ") 时并没有触发真正的输出, Hello 只被写到了 父进程的 stdout 的内存缓存中,当父进程通过 fork 创建子进程之后, 子进程的内存空间也拥有和父进程一样的内容,所以子进程调用 printf("child\n") 时,连带自己 stdout 缓存空间中的 Hello 一起输出了。

下面通过两种方式去避免上述问题的出现:

  • 强制立即输出到 stdout

    在父进程 printf("Hello ") 后调用 fflush(stdout) 强制立即输出到 stdout

    //...
    printf("Hello ");
    fflush(stdout);
    //...
    复制代码
    ▶ gcc c_lib_io_cache.c -o main.o && ./main.o
    Hello parent
    child
    复制代码
  • 修改 stdout 的缓存大小

    printf("Hello ") 前调用 setbuffer 将 stdout 的缓存大小设为 1 个字节

    //...
    setbuffer(stdout, NULL, 1);
    printf("Hello ");
    //...
    复制代码
    ▶ gcc c_lib_io_cache.c -o main.o && ./main.o
    Hello parent
    child
    复制代码

虽然第2种方式也实现了目的,但是将行缓存大小设为1个字节,终究不是最优方法,没有利用到缓存机制。所以最佳应该方法应该是第一种利用 fflush 强制 IO 同步。