LinuxUNIX系统编程手册——(十三)文件IO缓冲

13.1 文件 I/O 的内核缓冲:缓冲区高速缓存

read()和 write()系统调用在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户空间缓冲区与内核缓冲区高速缓存(kernel buffer cache)之间复制数据。

例如,如下调用将 3 个字节的数据从用户空间内存传递到内核空间的缓冲区中:write(fd, "abc",3),write()随即返回。在后续某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘。(因此,可以说系统调用与磁盘操作并不同步。)如果在此期间,另一进程试图读取该文件的这几个字节,那么内核将自动从缓冲区高速缓存中提供这些数据,而不是从文件中(读取过期的内容)。与此同理,对输入而言,内核从磁盘中读取数据并存储到内核缓冲区中。read()调用将从该缓冲区中读取数据,直至把缓冲区中的数据取完,这时,内核会将文件的下一段内容读入缓冲区高速缓存。(这里的描述有所简化。对于序列化的文件访问,内核通常会尝试执行预读,以确保在需要之前就将文件的下一数据块读入缓冲区高速缓存中。)

采用这一设计,意在使 read()和 write()调用的操作更为快速,因为它们不需要等待(缓慢的)磁盘操作。同时,这一设计也极为高效,因为这减少了内核必须执行的磁盘传输次数。

Linux 内核对缓冲区高速缓存的大小没有固定上限。内核会分配尽可能多的缓冲区高速缓存页,而仅受限于两个因素:可用的物理内存总量,以及出于其他目的对物理内存的需求(例如,需要将正在运行进程的文本和数据页保留在物理内存中)。若可用内存不足,则内核会将一些修改过的缓冲区高速缓存页内容刷新到磁盘,并释放其供系统重用。

缓冲区大小对 I/O 系统调用性能的影响

无论是让磁盘写 1000 次,每次写入一个字节,还是一次写入 1000 个字节,内核访问磁盘的字节数都是相同的。然而,我们更属意于后者,因为它只需要一次系统调用,而前者则需要调用 1000 次。尽管比磁盘操作要快许多,但系统调用所耗费的时间总量也相当可观:内核必须捕获调用,检查系统调用参数的有效性,在用户空间和内核空间之间传输数据。

那么在数据的传输总量(因此招致磁盘操作的数量)是相同的情况下(如100M),采用不同的缓冲区大小时,发起 read()和 write()调用的开销大有不同。缓冲区大小为 1 字节时,需要调用 read()和 write()1亿次,缓冲区大小为4096 个字节时,需要调用read()和write() 24000 次左右,几乎达到最优性能。设置再超过这个值,对性能的提升就不显著了,这是因为与在用户空间和内核空间之间复制数据以及执行实际磁盘 I/O 所花费的时间相比,read()和 write() 系统调用的成本就显得微不足道了。

总之,如果与文件发生大量的数据传输,通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高 I / O 性能。

上文度量了一系列因素:执行 read()和 write()系统调用所需的时间内核空间和用户空间缓冲区之间传输数据所需的时间内核缓冲区与磁盘之间传输数据所需的时间。但是还有一个要素,将输入文件的内容传输到缓冲区高速缓存是不可避免的。

考虑使用不同大小的缓冲区调用 write()从用户空间向内核缓冲区高速缓存传输数据所花费的成本。缓冲区越大,输入文件的内容传输到缓冲区高速缓存所花费的时间成本越低。

13.2 stdio 库的缓冲

当操作磁盘文件时,缓冲大块数据以减少系统调用,C 语言函数库的 I/O 函数(比如,fprintf()、fscanf()、fgets()、fputs()、fputc()、fgetc())正是这么做的。因此,使用 stdio 库可以使编程者免于自行处理对数据的缓冲,无论是调用 write()来输出,还是调用 read()来输入。

13.2.1 设置一个 stdio 流的缓冲模式

调用 setvbuf()函数,可以控制 stdio 库使用缓冲的形式。

#include <stdio.h>

int setvbuf(FILE *stream, char *buf, int mode, size_t size);		/* 成功返回0,失败返回非零值 */

参数 stream 标识将要修改哪个文件流的缓冲。打开流后,必须在调用任何其他 stdio 函数之前先调用 setvbuf()。setvbuf()调用将影响后续在指定流上进行的所有 stdio 操作。

参数 buf 和 size 则针对参数 stream 要使用的缓冲区,指定这些参数有如下两种方式。

  • 如果参数 buf 不为 NULL,那么其指向 size 大小的内存块以作为 stream 的缓冲区。因为 stdio 库将要使用 buf 指向的缓冲区,所以应该以动态或静态在堆中为该缓冲区分配一块空间(使用 malloc()或类似函数),而不应是分配在栈上的函数本地变量。否则,函数返回时将销毁其栈帧,从而导致混乱。
  • 若 buf 为 NULL,那么 stdio 库会为 stream 自动分配一个缓冲区(除非选择非缓冲的I/O,如下所述)。SUSv3 允许,但不强制要求库实现使用 size 来确定其缓冲区的大小。glibc 实现会在该场景下忽略 size 参数。

参数 mode 指定了缓冲类型,并具有下列值之一。

  • _IONBF——不对 I/O 进行缓冲。每个 stdio 库函数将立即调用 write()或者 read(),并且忽略 buf 和 size参数,可以分别指定两个参数为 NULL 和 0。stderr 默认属于这一类型,从而保证错误能立即输出。
  • _IOLBF——采用行缓冲 I/O。指代终端设备的流默认属于这一类型。对于输出流,在输出一个换行符(除非缓冲区已经填满)前将缓冲数据。对于输入流,每次读取一行数据。
  • _IOFBF——采用全缓冲 I/O。单次读、写数据(通过 read()或 write()系统调用)的大小与缓冲区相同。指代磁盘的流默认采用此模式。

下面的代码演示了 setvbuf()函数的用法:

#define BUF_SIZE 1024
static char buf[BUF_SIZE];

if(setvbuf(stdout, buf, _IOFBF, BUF_SIZE) != 0)
    errExit("setvbuf");

setbuf()函数构建于 setvbuf()之上,执行了类似任务。

#include <stdio.h>

void setbuf(FILE *stream, char *buf);

setbuf(fp,buf)调用除了不返回函数结果外,就相当于setvbuf(fp, buf, (buf != NULL) ? _IOFBF: _IONBF, BUFSIZ);

要么将参数 buf 指定为 NULL 以表示无缓冲,要么指向由调用者分配的 BUFSIZ 个字节大小的缓冲区。

setbuffer()函数类似于 setbuf()函数,但允许调用者指定 buf 缓冲区大小。

#include _BSD_SOURCE
#include <stdio.h>

void setbuffer(FILE *stream, char *buf, size_t size);

对 setbuffer(fp,buf,size)的调用相当于调用 setvbuf(fp, buf, (buf != NULL) ? _IOFBF: _IONBF, size);

13.2.2 刷新 stdio 缓冲区

无论当前采用何种缓冲区模式,在任何时候,都可以使用 fflush()库函数强制将 stdio 输出流中的数据(即通过 write())刷新到内核缓冲区中。此函数会刷新指定 stream 的输出缓冲区。

#include <stdio.h>

int fflush(FILE *stream);		/* 成功返回0,失败返回EOF */

若参数 stream 为 NULL,则 fflush()将刷新所有的 stdio 缓冲区。

也能将 fflush()函数应用于输入流,这将丢弃业已缓冲的输入数据。(当程序下一次尝试从流中读取数据时,将重新装满缓冲区。)

当关闭相应流时,将自动刷新其 stdio 缓冲区。

13.3 控制文件 I/O 的内核缓冲

强制刷新内核缓冲区到输出文件是可能的。这有时很有必要,例如,当应用程序(诸如数据库的日志进程)要确保在继续操作前将输出真正写入磁盘(或者至少写入磁盘的硬件高速缓存中)。

13.3.1 同步 I/O 数据完整性和同步 I/O 文件完整性

SUSv3 将同步 I/O 完成定义为:某一 I/O 操作,要么已成功完成到磁盘的数据传递,要么被诊断为不成功。

第一种同步 I/O 完成类型是 synchronized I/O data integrity completion,旨在确保针对文件的一次更新传递了足够的信息(到磁盘),以便于之后对数据的获取。

  • 就读操作而言,这意味着被请求的文件数据已经(从磁盘)传递给进程。若存在任何影响到所请求数据的挂起写操作,那么在执行读操作之前,会将这些数据传递到磁盘。
  • 就写操作而言,这意味着写请求所指定的数据已传递(至磁盘)完毕,且用于获取数据的所有文件元数据也已传递(至磁盘)完毕。此处的要点在于要获取文件数据,并非需要传递所有经过修改的文件元数据属性。发生修改的文件元数据中需要传递的属性之一是文件大小(如果写操作确实扩展了文件)。相形之下,如果是文件时间戳发生了变化,就无需在下次获取数据前将其传递到磁盘。

另一种同步 I/O 完成类型是Synchronized I/O file integrity completion,也是上述synchronized I/O data integrity completion 的超集(Synchronized I/O file integrity completion包含synchronized I/O data integrity completion)。该 I/O 完成模式的区别在于在对文件的一次更新过程中,要将所有发生更新的文件元数据都传递到磁盘上,即使有些在后续对文件数据的读操作中并不需要。

13.3.2 用于控制文件 I/O 内核缓冲的系统调用

fsync()系统调用将使缓冲数据和与打开文件描述符 fd 相关的所有元数据都刷新到磁盘上。调用 fsync()会强制使文件处于 Synchronized I/O file integrity completion 状态。

#include <unistd.h>

int fsync(int fd);		/* 成功返回0,错误返回-1 */

仅在对磁盘设备(或者至少是其高速缓存)的传递完成后,fsync()调用才会返回。

fdatasync()系统调用的运作类似于 fsync(),只是强制文件处于 synchronized I/O data integrity completion 的状态。

#include <unistd.h>

int fdatasync(int fd);		/* 成功返回0,失败返回-1 */

fdatasync()可能会减少对磁盘操作的次数,由 fsync()调用请求的两次变为一次。例如,若修改了文件数据,而文件大小不变,那么调用 fdatasync()只强制进行了数据更新。(前面已然述及,针对 synchronized I/O data completion 状态,如果是诸如最近修改时间戳之类的元数据属性发生了变化,那么是无需传递到磁盘的。)相比之下,fsync()调用会强制将元数据传递到磁盘上。对某些应用而言,以这种方式来减少磁盘 I/O 操作的次数是很有用的,比如对性能要求极高,而对某些元数据(比如时间戳)的准确性要求不高的应用。当应用程序同时进行多处文件更新时,二者存在相当大的性能差异,因为文件数据和元数据通常驻留在磁盘的不同区域,更新这些数据需要反复在整个磁盘上执行寻道操作。

13.3.3 调用open()函数时的几个标志

使所有写入同步:O_SYNC

调用 open()函数时如指定 O_SYNC 标志,则会使所有后续输出同步(synchronous)。

fd = open(pathname, O_WRONLY | O_SYNC)

调用 open()后,每个 write()调用会自动将文件数据和元数据刷新到磁盘上(即,按照Synchronized I/O file integrity completion 的要求执行写操作)。

O_SYNC 对性能的影响

采用 O_SYNC 标志(或者频繁调用 fsync()、fdatasync()或 sync())对性能的影响极大。O_SYNC 标志使运行总用时大为增加。还要注意,以 O_SYNC 标志执行写操作时运行总用时和 CPU 时间之间的巨大差异。这是因为系统在将每个缓冲区中数据向磁盘传递时会把程序阻塞起来。

现代磁盘驱动器均内置大型高速缓存,而默认情况下,使用 O_SYNC 只是将数据传递到该缓存中。如果禁用磁盘上的高速缓存(使用命令 hdparm –W0),那么 O_SYNC 对性能的影响将变得更为极端。

O_DSYNC 和 O_RSYNC 标志

O_DSYNC 标志要求写操作按照 synchronized I/O data integrity completion 来执行(类似于fdatasync())。与之相映成趣的是 O_SYNC 标志,遵从 synchronized I/O file integrity completion(类似于 fsync()函数)。

O_RSYNC 标志是与 O_SYNC 标志或 O_DSYNC 标志配合一起使用的,将这些标志对写操作的作用结合到读操作中。如果在打开文件时同时指定O_RSYNC 和 O_DSYNC 标志,那么就意味着会遵照 synchronized I/O data integrity completion 的要求来完成所有后续读操作(即,在执行读操作之前,像执行 O_DSYNC 标志一样完成所有待处理的写操作)。而在打开文件时指定 O_RSYNC 和 O_SYNC 标志,则意味着会遵照 synchronized I/O file integrity completion 的要求来完成所有后续读操作(即,在执行读操作之前,像执行 O_SYNC 标志一样完成所有待处理的写操作)。

13.4 I/O 缓冲小结

在这里插入图片描述

上图概括了 stdio 函数库和内核所采用的缓冲(针对输出文件),以及对各种缓冲类型的控制机制。从图中自上而下,首先是通过 stdio 库将用户数据传递到 stdio 缓冲区,该缓冲区位于用户态内存区。当缓冲区填满时,stdio 库会调用 write()系统调用,将数据传递到内核高速缓冲区(位于内核态内存区)。最终,内核发起磁盘操作,将数据传递到磁盘。 左侧所示为可于任何时刻显式强制刷新各类缓冲区的调用。图右侧所示为促使刷新自动化的调用:一是通过禁用 stdio 库的缓冲,二是在文件输出类的系统调用中启用同步,从而使每个 write()调用立刻刷新到磁盘。

13.5 就 I/O 模式向内核提出建议

posix_fadvise()系统调用允许进程就自身访问文件数据时可能采取的模式通知内核。

#include <fcntl.h>

int posix_fadvise(int fd, off_t offset,off_t len, int advice);		/* 成功返回,错误返回错误码 */

内核可以(但不必非要)根据 posix_fadvise()所提供的信息来优化对缓冲区高速缓存的使用,进而提高进程和整个系统的性能。调用 posix_fadvise()对程序语义并无影响。

参数 fd 所指为一文件描述符,调用期望通知内核进程对 fd 指代文件的访问模式。参数offset 和 len 确定了建议所适用的文件区域。offset 指定了区域起始的偏移量,len 指定了区域的大小(以字节数为单位)。len 为 0 表示从 offset 开始,直至文件结尾。

参数 advice 表示进程期望对文件采取的访问模式。具体为下列参数之一:

  • POSIX_FADV_NORMAL。进程对访问模式并无特别建议。如果没有建议,这就是默认行为。在 Linux 中,该操作将文件预读窗口大小置为默认值(128KB)。
  • POSIX_FADV_SEQUENTIAL。进程预计会从低偏移量到高偏移量顺序读取数据。在 Linux 中,该操作将文件预读窗口大小置为默认值的两倍。
  • POSIX_FADV_RANDOM。进程预计以随机顺序访问数据。在 Linux 中,该选项会禁用文件预读。
  • POSIX_FADV_WILLNEED。进程预计会在不久的将来访问指定的文件区域。内核将由 offset 和 len 指定区域的文件数据预先填充到缓冲区高速缓存中。后续对该文件的 read()调用将不会阻塞磁盘 I/O,只需从缓冲区高速缓存中抓取数据即可。对于从文件读取的数据在缓冲区高速缓存中能保留多长时间,
    内核并无保证。如果其他进程或内核的活动对内存存在强劲需求,那么最终会重用到这些页面。换言之,如果内存压力高,程序员就应该确保 posix_fadvise()调用和后续 read()调用间的总运行时长较短。(Linux 特有的系统调用 readahead()提供了与 POSIX_FADV_WILLNEED 操作等效的功能。)
  • POSIX_FADV_DONTNEED。进程预计在不久的将来将不会访问指定的文件区域。这一操作给内核的建议是释放相关的高速缓存页面(如果存在的话)。在 Linux 中,该操作将分两步执行。首先,如果底层设备目前没有挤满一系列排队的写操作请求,那么内核会对指定区域中已修改的页面进行刷新。之后,内核会尝试释放该区域的高速缓存页面。仅当该区域中已修改的页面在第一步中成功写入底层设备时,第二步才可能操作成功,也就是说,在该设备的写入操作请求没有发生拥塞的情况下。因为应用程序无法控制设备的拥塞(congestion),所以要确保释放高速缓存页面,变通的方法之一是在 POSIX_FADV_DONTNEED 操作之前对指定的参数 fd 调用 sync()或 fdatasync()。
  • POSIX_FADV_NOREUSE。进程预计会一次性地访问指定文件区域,不再复用。这等于提示内核对指定区域访问一次后即可释放页面。在 Linux 中,该操作目前不起作用。

13.6 绕过缓冲区高速缓存:直接 I/O

Linux 允许应用程序在执行磁盘 I/O 时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或磁盘设备。有时也称此为直接 I/O(direct I/O)或者裸 I/O(raw I/O)。

有时会将直接 I/O 误认为获取快速 I/O 性能的一种手段。然而,对于大多数应用而言,使用直接 I/O 可能会大大降低性能。这是因为为了提高 I/O 性能,内核针对缓冲区高速缓存做了不少优化,其中包括:按顺序预读取,在成簇(clusters)磁盘块上执行 I/O,允许访问同一文件的多个进程共享高速缓存的缓冲区。应用如使用了直接 I/O 将无法受益于这些优化举措。直接 I/O 只适用于有特定 I/O 需求的应用。例如数据库系统,其高速缓存和 I/O 优化机制均自成一体,无需内核消耗 CPU 时间和内存去完成相同任务。

可针对一个单独文件或块设备(比如,一块磁盘)执行直接 I/O。要做到这点,需要在调用 open()打开文件或设备时指定 O_DIRECT 标志

【注】若一进程以 O_DIRECT 标志打开某文件,而另一进程以普通方式(即使用了高速缓存缓冲区)打开同一文件,则由直接 I/O 所读写的数据与缓冲区高速缓存中内容之间不存在一致性。

13.6.1 直接 I/O 的对齐限制

因为直接 I/O(针对磁盘设备和文件)涉及对磁盘的直接访问,所以在执行 I/O 时,必须遵守一些限制。不遵守以下任一限制均将导致 EINVAL 错误。块大小(block size)指设备的物理块大小(通常为 512 字节)。

  • 用于传递数据的缓冲区,其内存边界必须对齐为块大小的整数倍。
  • 数据传输的开始点,亦即文件和设备的偏移量,必须是块大小的整数倍。
  • 待传递数据的长度必须是块大小的整数倍。

13.6.2 示例程序:使用 O_DIRECT 跳过缓冲区高速缓存

#define _GNU_SOURCE     /* Obtain O_DIRECT definition from <fcntl.h> */
#include <fcntl.h>
#include <malloc.h>
#include "tlpi_hdr.h"

int
main(int argc, char *argv[])
{
    int fd;
    ssize_t numRead;
    size_t length, alignment;
    off_t offset;
    char *buf;

    if (argc < 3 || strcmp(argv[1], "--help") == 0)
        usageErr("%s file length [offset [alignment]]\n", argv[0]);

    length = getLong(argv[2], GN_ANY_BASE, "length");							/* argv[2]为字节数 */
    offset = (argc > 3) ? getLong(argv[3], GN_ANY_BASE, "offset") : 0;				/* argv[3]为偏移量 */
    alignment = (argc > 4) ? getLong(argv[4], GN_ANY_BASE, "alignment") : 4096;		/* argv[4]为数据缓冲区对齐 */

    fd = open(argv[1], O_RDONLY | O_DIRECT);
    if (fd == -1)
        errExit("open");

    /* memalign() 函数用于分配一块内存,这块内存的地址是其第一个参数(一个对齐值)的倍数。通过将该参数指定为 2 * 'alignment'(其中 'alignment' 是你希望的对齐值),然后将返回的指针加上 'alignment',我们可以确保 buf(即分配的内存块的指针)是对齐于 'alignment' 的一个奇数倍的地址上。这样做是为了确保,例如,如果我们请求一个256字节对齐的缓冲区,我们不会意外地得到一个同时也对齐于512字节边界的缓冲区。*/

    buf = memalign(alignment * 2, length + alignment);
    if (buf == NULL)
        errExit("memalign");

    buf += alignment;

    if (lseek(fd, offset, SEEK_SET) == -1)
        errExit("lseek");

    numRead = read(fd, buf, length);
    if (numRead == -1)
        errExit("read");
    printf("Read %ld bytes\n", (long) numRead);

    exit(EXIT_SUCCESS);
}
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/filebuff$ ./direct_read direct_read.c 512
Read 512 bytes
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/filebuff$ ./direct_read direct_read.c 256
ERROR [?UNKNOWN? Invalid argument] read
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/filebuff$ ./direct_read direct_read.c 512 1
ERROR [?UNKNOWN? Invalid argument] read
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/filebuff$ ./direct_read direct_read.c 1024 512 512
Read 1024 bytes
vainx@DESKTOP-0DN0PNJ:~/wsl-code/tlpi-book/filebuff$ ./direct_read direct_read.c 1024 512 256
ERROR [?UNKNOWN? Invalid argument] read

13.7 混合使用库函数和系统调用进行文件 I/O

在同一文件上执行 I/O 操作时,还可以将系统调用和标准 C 语言库函数混合使用。fileno()和 fdopen()函数有助于完成这一工作。

#include <stdio.h>

int fileno(FILE *stream);				/* 成功返回文件描述符,错误返回-1 */
FILE *fdopen(int fd, const char *mode);		/* 成功返回新的文件指针,错误返回NULL */

给定一个(文件)流,fileno()函数将返回相应的文件描述符(即 stdio 库在该流上已经打开的文件描述符)。随即可以在诸如 read()、write()、dup()和 fcntl()之类的 I/O 系统调用中正常使用该文件描述符。

fdopen()函数与 fileno()函数的功能相反。给定一个文件描述符,该函数将创建了一个使用该描述符进行文件 I/O 的相应流。mode 参数与 fopen()函数中 mode 参数含义相同。例如,r为读,w 为写,a 为追加。若该参数与文件描述符 fd 的访问模式不一致,则对 fdopen()的调用将失败。

当使用 stdio 库函数,并结合系统 I/O 调用来实现对磁盘文件的 I/O 操作时,必须将缓冲问题牢记于心。I/O 系统调用会直接将数据传递到内核缓冲区高速缓存,而 stdio 库函数会等到用户空间的流缓冲区填满,再调用 write()将其传递到内核缓冲区高速缓存。所以通常情况下,printf()函数的输出往往在 write()函数的输出之后出现。将 I/O 系统调用和 stdio 函数混合使用时,使用 fflush()来规避这一问题,是明智之举。也可以使用 setvbuf()或 setbuf()使缓冲区失效,但这样做可能会影响应用的 I/O 性能,因为每个输出操作将引起一次 write()系统调用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值