缓冲 I/O(stdio)

本页目录

stdio 用 FILE 对象提供格式化、缓冲、流状态和位置管理。缓冲能把很多小操作合并,但并不保证每次 fwrite 都不进入内核,也不保证无缓冲时每次 API 恰好对应一次系统调用。要判断数据到了哪里,应分清库接口、底层 I/O 和持久化层次。

FILE 与文件描述符

fd 是进程描述符表中的整数索引,FILE 还保存缓冲游标、EOF/error 状态、方向和锁等。fread 可能直接从已预取的用户缓冲返回,也可能填充缓冲或进行较大的底层读取;fwrite 同理可走缓冲或直接写入路径。

glibc 的 _IO_FILE 字段有助于阅读实现,但不是跨 libc 的可移植接口,不应在应用代码中修改。FILE 也不一定对应普通文件,例如内存流可有不同底层操作。

三种缓冲策略

模式典型用途刷新时机的理解
_IOFBF 全缓冲普通文件、大量小块输出缓冲满、显式刷新、关闭等;不是只能等满
_IOLBF 行缓冲交互终端输出遇换行等条件刷新,也可显式刷新
_IONBF 无缓冲需要尽快推送的短输出不积累常规输出缓冲,但没有落盘承诺

Linux 常见 libc 默认让终端 stdout 行缓冲、普通文件输出全缓冲、stderr 无缓冲。重定向到文件或管道后,stdout 行为可能变化,因此同一程序在终端及时输出、在流水线中延后输出并不矛盾。

setvbuf 应在打开流后、对它做其他操作前调用,并检查返回值。BUFSIZ 是实现提供的宏,不是所有 FILE 缓冲区都必定大小相同,也不应把某个 glibc 值写成可移植常量。setvbuf

FILE *fp = tmpfile();
if (fp == NULL) return 1;
char storage[4096];
if (setvbuf(fp, storage, _IOFBF, sizeof storage) != 0) {
    fclose(fp);
    return 1;
}
/* 使用 fp;必须在 storage 离开作用域前 fclose(fp)。 */
if (fclose(fp) == EOF) return 1;

这是函数体内片段,需 <stdio.h>。自定义缓冲必须一直存活到流关闭;把函数局部数组交给一个会被返回的 FILE,会留下失效引用。

fwrite、fflush 与 fsync 保证不同

写入成功有多个层次:用户缓冲、内核与持久介质 FILE 用户缓冲 fwrite 可先接受数据 内核文件缓存 fflush 推送至底层 存储持久化 fsync 等协议与设备保证 示意常见普通文件路径;终端、管道或自定义流并不都经过同一种 page cache。

fwrite 返回完整数量,可能只表示数据已被库接受;fflush 成功表示相应用户缓冲已通过底层写操作提交,不保证已经掉电持久。普通文件需要相应持久化保证时,通常先 fflush,再对 fileno(fp) 调用 fsync,并分别检查错误。fflush(3)

fsync 的对象是内核文件状态,不能替你刷出还停在 FILE 里的数据。新建或重命名文件的崩溃一致性还可能要求同步包含它的目录,这与单个文件内容同步不同。fsync(2)

内核缓存也不会把任意一千次 write 保证合并成恰好一次物理 I/O。写回、文件系统、块大小和设备队列都参与决定。避免写死“固定快 100 倍”或“系统调用必定 200 ns”的数字。

fsync 为什么刷不出 FILE 里的数据?

先追加三个字节,直接 fsync:内核还不知道这三个字节,因此同步保证不会覆盖它们。再 fflush,最后 fsync,三层账目才接上。底层文件行保留已提交内容,不把同步画成从内核“搬走并清空”数据,因为文件缓存仍可保留这些字节。

正在呈现知识画面
fsync 为什么刷不出 FILE 里的数据?

fflush 把用户缓冲提交给底层文件;fsync 同步内核已知文件状态。先 fsync 再 fflush,不会为后一次提交补上持久化保证。

正确解释返回值与错误

fread/fwrite 返回完成的元素数量,只有 size=1 时数量才等于字节数。需要逐字节处理尾部时,使用 fread(buf, 1, capacity, fp) 更容易表达已得到多少字节。

短读可能是 EOF,也可能是错误,用 feof 和 ferror 区分。EOF 标记是在尝试读取时发现末尾后设置,所以 while (!feof(fp)) 不是正确的读循环条件。fread(3)

下面函数复制两个由调用方打开的流,并在成功结束时刷新输出;它不负责关闭流,也不提供持久化保证:

#include <stdio.h>

int copy_stream(FILE *in, FILE *out) {
    unsigned char buf[8192];
    size_t n;
    while ((n = fread(buf, 1, sizeof buf, in)) != 0) {
        if (fwrite(buf, 1, n, out) != n) return -1;
    }
    if (ferror(in)) return -1;
    return fflush(out) == 0 ? 0 : -1;
}

写错误可能在 fwrite 当时出现,也可能延后到 fflush 或 fclose。关闭时即使报告错误,调用方也不能继续把原 FILE 当作有效对象重试 fclose。对错误流是否 clearerr 后重试,要结合原因与位置语义设计,不应盲目无限循环。

写入被接受后,错误仍可能出现在刷新时

Linux 的 /dev/full 对写入返回 ENOSPC,可以用来演示库缓冲把错误推迟到 fflush,而无需填满真实磁盘。以下限定 glibc/Linux,全缓冲中放入三个字符后再显式刷新。full(4)

#include <errno.h>
#include <stdio.h>
int main(void) {
    FILE *fp = fopen("/dev/full", "w");
    if (fp == NULL) return 1;
    char buffer[1024];
    if (setvbuf(fp, buffer, _IOFBF, sizeof buffer) != 0) {
        fclose(fp); return 1;
    }
    int accepted = fputc('a', fp) != EOF;
    accepted = (fputc('b', fp) != EOF) && accepted;
    accepted = (fputc('c', fp) != EOF) && accepted;
    errno = 0;
    int flushed = fflush(fp);
    int saved = errno;
    int marked = ferror(fp) != 0;
    /* 关闭后无论返回什么,都不再访问 fp 或重复 fclose。 */
    int closed = fclose(fp);
    printf("accepted=%d flush_failed=%d ENOSPC=%d error_mark=%d close=%d\n",
           accepted, flushed == EOF, saved == ENOSPC, marked, closed);
    return !(accepted && flushed == EOF && saved == ENOSPC && marked);
}

保存为 delayed-error.c,编译运行后,前四个布尔结果应为 1。close 的结果单独展示,不要求它一定重复报告同一次刷新错误。不能只在结束时看 fclose 的返回值而忽略前面已经出现的失败,更不能看到三次 fputc 成功就宣布数据写入完成。

混合读取、写入和底层 fd 操作

更新流(如 r+、w+)从输出切到输入,需要 fflush 或定位操作;从输入切到输出通常需要定位操作,除非刚才的输入已遇 EOF。使用 fseek/fsetpos 等明确同步方向与位置。fopen(3)

stdio 的预读可使底层 fd 偏移先于应用消费位置。直接在 fileno(fp) 上再 read,可能绕过尚未消费的缓冲;混用 write 也可能改变输出顺序。优先在一个对象上统一 I/O 层,必须混用时遵守 POSIX active handle 协调规则。

fflush(stdin) 不是通用的键盘输入清空方法。POSIX 对可定位输入流定义了相应丢弃缓冲行为,但管道与终端不属于同一情况。

应用已消费的位置,可以落后于底层预读位置 应用读走 3 字节 逻辑位置 = 3 FILE 仍保留预读内容 字节 3 至 7 尚未消费 底层 fd 已读到 8 直接 read 会从此处开始 统一使用流接口,或按 active handle 规则显式协调 切换更新流方向时,也要遵守定位/刷新的接口约定 人为构造的 8 字节预读例子;实际缓冲容量和一次底层读取长度由实现决定。

在同一个更新流中检查位置、方向和 EOF

保存为 update-stream.c,tmpfile 建立二进制更新流。先写 abcdef,再读前三字节;使用定位操作协调从读到写的切换,将当前位置的 d 替换为 X,最后读回 abcXef。

#include <stdio.h>
#include <string.h>
int main(void) {
    FILE *fp = tmpfile();
    if (fp == NULL) return 1;
    int failed = 1;
    char data[7] = {0};
    if (fwrite("abcdef", 1, 6, fp) != 6) goto done;
    if (fseek(fp, 0, SEEK_SET) != 0) goto done;
    if (fread(data, 1, 3, fp) != 3 || memcmp(data, "abc", 3) != 0) goto done;
    if (fseek(fp, 0, SEEK_CUR) != 0) goto done;
    if (fputc('X', fp) == EOF) goto done;
    if (fseek(fp, 0, SEEK_SET) != 0) goto done;
    if (fread(data, 1, 6, fp) != 6 || memcmp(data, "abcXef", 6) != 0) goto done;
    if (feof(fp) || ferror(fp)) goto done;
    if (fgetc(fp) != EOF || !feof(fp) || ferror(fp)) goto done;
    clearerr(fp);
    if (feof(fp) || ferror(fp)) goto done;
    /* 清除标记并没有移动到文件开头,再读仍会遇到 EOF。 */
    if (fgetc(fp) != EOF || !feof(fp)) goto done;
    if (fseek(fp, 0, SEEK_SET) != 0 || feof(fp)) goto done;
    if (fgetc(fp) != 'a') goto done;
    failed = 0;
    puts("update and EOF transitions passed");
done:
    if (fclose(fp) == EOF) failed = 1;
    return failed;
}

编译运行:cc -Wall -Wextra update-stream.c -o update-stream,然后 ./update-stream。读完恰好 6 字节时,应用请求已满足,还没有额外读取来发现末尾;下一次 fgetc 才设置 EOF。clearerr 只清状态,不移动文件位置,而成功 fseek 既定位又清除 EOF。fseek(3)、流状态

这里的 goto 统一关闭 FILE,避免每个检查分支都漏掉资源。它不能让已经失败的 I/O 自动恢复;如果要报告具体原因,应在跳转前保存错误信息。

fork 会复制尚未刷出的用户缓冲

fork 后父子进程各有 FILE 状态及用户缓冲的副本,底层打开文件描述则可能共享。如果两边都通过正常 exit 刷新,同一段未刷出的内容可能写出两次。

即使 printf("before fork\n") 含换行,stdout 重定向为全缓冲时也未必已刷新。单线程程序可在合适位置显式 fflush 并检查结果;不能以“有换行”推断跨所有输出环境安全。

多线程 fork 后,子进程在 exec 前还受异步信号安全限制,不能任意调用 stdio 来整理锁状态。exec 失败路径通常用适当的低层报告和 _exit,避免重复刷新继承缓冲。_exit 不刷新 stdio 与正常 exit 不同。

顺序、原子性与性能

分别写 stdout 和 stderr,即使都刷新,也不能在所有外部收集器中保证统一的全局显示顺序。需要严格顺序时应采用同一输出通道或带序号的记录协议。

stdio 的单次调用通常有流锁,但多个调用组成的一条日志不自动成为事务。多个进程写管道时,还需考虑真正底层 write 的长度与 PIPE_BUF,不能把“一次 fprintf”直接当作“一次原子管道消息”。

使用 strace 观察底层 write 次数能验证批量效果;用实际吞吐、延迟和错误行为评估缓冲大小。非阻塞网络事件循环通常直接管理 fd 与应用缓冲,详见 epoll 与事件驱动。

关键词:stdio, FILE, setvbuf, fread, fwrite, fflush, fsync, buffering, EOF, fork, active handle