13 文件 I/O 缓冲

C 语言标准库的缓冲

C 语言输入输出函数会将数据缓冲到用户区域,从而减少了系统调用次数

可以用函数 setvbuf 来改变一个 FILE * 的缓冲方式:

int setvbuf(FILE *restrict stream, char buf[restrict .size],
           int mode, size_t size);

其中 mode 有三种:

  • _IONBF: unbuffered
  • _IOLBF: line buffered
  • _IOFBF: fully buffered

buf 可以是 NULL,这时 stdio 库会给 buf 自动分配一块缓冲区;buf 也可以是一块持久分配的内存。经过测试,以下代码会出现内存泄漏:

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

int main() {
    for (int i = 0; i < 895463893; ++i) {
        setvbuf(stdout, (char *)malloc(4096), _IOFBF, 4096);
    }
    printf("setvbuf has been called so many times!\n");
}

而把 (char *)malloc(4096) 换成 NULL 就不会。我猜 bufNULL 时,可能指向的是一块 threadlocal 的内存?

刷新 stdio 的缓冲

使用 fflush 可以刷新 stdio 的写缓冲。这个操作会将用户缓冲区的数据复制到内核缓冲区,但还不是刷新到磁盘。对于一般应用程序来说,这个操作已经足够了,因为当程序退出的时候内核缓冲区的数据还是会被内核写到磁盘上的;对于数据库等程序来说,这个操作还不够。

如果 fflush 的参数为 NULL,那么将刷新所有的缓冲区(这个和 cudaStreamSynchronize 有点相似)。

Note

在包括 glibc 库在内的许多 C 函数库实现中,若 stdin 和 stdout 指向一终端,那么无论何时从 stdin 中读取输入时,都将隐含调用一次 fflush(stdout) 函数。这个特性在 SUSv3 和 C99 中都没有,因此为了确保兼容需要手动调用 fflush

若打开一个流同时用于输入和输出,则 C99 标准中提出了两项要求。首先,一个输出操作不能紧跟一个输入操作,必须在二者之间调用 fflush()函数或是一个文件定位函数(fseek()fsetpos() 或者 rewind())。其次,一个输入操作不能紧跟一个输出操作,必须在二者之间调用一个文件定位函数,除非输入操作遭遇文件结尾。

内核 I/O 缓冲

系统调用 read / write 所执行的操作是将数据在用户缓冲区和内核缓冲区之间复制,并不会真正发起磁盘请求,从而减少了磁盘请求次数

write 操作在将数据复制到内核缓冲区之后就返回,数据真正写入到磁盘则是由内核随后处理。别的进程读取文件时,如果文件在内核缓冲区有修改没有写入到磁盘,就直接从内核文件缓冲区读取数据,从而保证了文件内容的一致性。

内核 2.4 之前,Linux 维护了一个单独的缓冲区高速缓存;现在,文件 I/O 缓冲区在页面高速缓存中

同步内核 I/O 缓冲

SUSv3 将同步 I/O 的完成(synchronized I/O completion)定义为:某 I/O 操作要么已经将数据送到了磁盘,要么被诊断为不成功。

同步 I/O 的完成有两种类型,一种是数据完整性的完成(synchronized I/O data integration completion),另外一种是文件完整性的完成(synchronized I/O data integration completion)。前者只关注对读需要的那些部分数据的写入,因此,文件内容需要被写入磁盘,文件长度(写入内容之后文件可能会变长)这些和读有关的元信息也要写入磁盘,但是文件修改日期等信息不必写入磁盘。后者则是不管读文件需不需要,都要将元信息写入磁盘。

一些相关的系统调用包括:

  • int fsync(int fd) 完成的是同步 I/O 文件完整性。仅在对磁盘设备(或者至少是其高速缓存,而且现在大多数时候都是只写到高速缓存)的传递完成之后,fsync 才会返回。
  • int fdatasync(int fd) 完成的是同步 I/O 数据完整性。如果文件对于读取内容来说重要的那些元数据(比如文件长度)没有改变,那么 fdatasync 可以少一次磁盘写入,即不同步元数据。Linux 内核 2.2 版本之前,fdatasync 被实现为和 fsync 相同。
  • 非标准的系统调用 sync_file_range,控制范围比 fdatasync 更加精确。
  • void sync(void) 请求内核所有的文件缓冲区都刷新,包括数据和元信息。根据 Linux/UNIX 系统编程手册,Linux 尽在所有文件都传输到磁盘或其高速缓冲区时返回,但是 SUSv3 允许 sync 在同步完成之前就返回。

Tip

fdatasync 看上去只是减少了一点点数据同步,实则对性能提升还是有效果的,因为元数据往往是和文件内容存放在不同区域的,不利于缓存局部性。

Note

若内容发生变化的内核缓冲区在 30 秒内未经显式方式同步到磁盘上,则一条长期运行的内核线程会确保将其刷新到磁盘上。这一做法是为了规避缓冲区与相关磁盘文件内容长期处于不一致状态(以至于在系统崩溃时发生数据丢失)的问题。在 Linux 2.6 版本中,该任务由 pdflush 内核线程执行。(在 Linux 2.4 版本中,则由 kupdated 内核线程执行。)文件 /proc/sys/vm/dirty_expire_centisecs 规定了在 pdflush 刷新之前脏缓冲区必须达到的“年龄”(以 1% 秒为单位)。位于同一目录下的其他文件则控制了 pdflush 操作的其他方面。

在我的电脑上查看,/proc/sys/vm/dirty_expire_centisecs 文件中的内容是 3000。

在文件打开标志中指定同步方式

在使用 open 系统调用时指定 O_SYNC 等标志,这样每次调用 write 的时候都会按照给定的标志完成磁盘同步。

其中,O_SYNC 对应文件完整性,O_DSYNC 对应数据完整性。O_RSYNC 是和另外两者配合使用的(需要在指定 O_SYNC 或者 O_DSYNC 的同时指定 O_RSYNC),表示在遇到读文件的情况时,按照给定的同步语义将在这个文件上挂起的写入操作先完成。就也是读前先写。

来自 man page

Linux implements O_SYNC and O_DSYNC, but not O_RSYNC.

其他内容

进程使用 posix_fadvise() 函数,可就进程对特定文件可能采取的数据访问模式向内核提出建议。内核可籍此来优化对缓冲区高速缓存的应用,进而提高 I/O 性能。

在 Linux 环境下,open() 所特有的 O_DIRECT 标识允许特定应用跳过缓冲区高速缓存。

在对同一个文件执行 I/O 操作时,fileno()fdopen() 有助于系统调用和标准 C 语言库函数的混合使用。给定一个流,fileno() 将返回相应的文件描述符,fdopen() 则反其道而行之,针对指定的打开文件描述符创建一个新的流。