跳转到内容

Lesson 48: I/O 缓冲性能

练习任务

难度:中。实现两种文件拷贝方式并对比性能,理解缓冲区大小对 I/O 性能的影响。

  1. copy_fgetc(src, dst) — 用 fgetc/fputc 逐字节拷贝
  2. copy_fread(src, dst) — 用 fread/fwrite 以 4KB 为缓冲单位批量拷贝
c
double copy_fgetc(const char *src, const char *dst);
double copy_fread(const char *src, const char *dst);
// 返回值:拷贝耗时(秒)

create_test_filemain 已提供,自动生成 10MB 测试文件。需要实现两个拷贝函数,用 clock() 计时。

验证: 输出文件大小、两种拷贝耗时、fread 是否快于 fgetc

提示:逐字节拷贝关键为 int c; while ((c = fgetc(fin)) != EOF) fputc(c, fout);。缓冲拷贝关键是 size_t n; while ((n = fread(buf, 1, BUF_SIZE, fin)) > 0) fwrite(buf, 1, n, fout);。必须用 n 而非 sizeof(buf)


核心知识点

  • 缓冲减少系统调用 — 10MB 数据:fgetc 逐字符约 2000 万次系统调用 vs fread 4KB 缓冲约 5000 次,理论减少约 4000 倍
  • 三种缓冲模式_IOFBF(全缓冲,块设备默认)、_IOLBF(行缓冲,终端默认)、_IONBF(无缓冲,stderr 默认)
  • fread/fwrite 返回值 — 返回实际读写字节数,必须检查,否则不知道实际读了多少:用 n 而非 sizeof(buf)
  • clock()/CLOCKS_PER_SEC — CPU 时间计时,简单但不精确(非墙上时间),适合相对比较
  • stderr 无缓冲的设计理由 — 崩溃诊断需要立即输出,缓冲会导致未 flush 的错误信息丢失
  • 内核 readahead(默认 128KB) — 部分掩盖 fgetc 的性能差距,但用户态/内核态频繁切换仍是主要瓶颈
  • setvbuf 自定义缓冲setvbuf(fp, buf, _IOFBF, 8192) 必须在任何 I/O 操作之前调用,且 buf 必须存活到 FILE* 关闭之后
  • 收益递减阈值 — 缓冲大小超过约 128KB 后收益递减,磁盘带宽成为瓶颈,CPU 拷贝时间不可忽略
  • fgetc 返回 intEOF = -1 与 0xFF(255) 字符冲突;fflush(stdin) 是未定义行为,标准只定义输出流

跨课衔接:本课是 Unit 2 的收官课程,衔接 Lesson 35: 循环队列 中的缓冲概念(环形缓冲 kfifo)和 Lesson 42: snprintf 中的 stdio 安全使用。


代码框架

48_io_buffer_perf.c
c
/* 48_io_buffer_perf.c — 标准 I/O 缓冲性能对比:文件拷贝
 *
 * 任务:实现两种文件拷贝方式并对比耗时
 *       1. copy_fgetc — fgetc/fputc 逐字节拷贝
 *       2. copy_fread — fread/fwrite 4KB 批量拷贝
 */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define FILE_SIZE (10 * 1024 * 1024) /* 10MB */
#define BUF_SIZE 4096

void create_test_file(const char *path, int size) {
    FILE *f = fopen(path, "wb");
    for (int i = 0; i < size; i++) fputc('A' + (i % 26), f);
    fclose(f);
}

/* 逐字节拷贝:每次 fgetc 读 1 字节 + fputc 写 1 字节
 * 10MB 约 1000 万次 fgetc + 1000 万次 fputc = 2000 万次 syscall!
 * 返回耗时(秒) */
double copy_fgetc(const char *src_path, const char *dst_path) {
    // clock_t start = clock()
    // fopen(src, "rb") + fopen(dst, "wb"),检查失败
    // int ch; while ((ch = fgetc(fin)) != EOF) fputc(ch, fout)
    // fclose(fin); fclose(fout)
    // return (double)(clock() - start) / CLOCKS_PER_SEC
}

/* 缓冲拷贝:每次 fread 读 4KB + fwrite 写 4KB
 * 10MB / 4KB 约 2500 次 fread/fwrite 约 5000 次 syscall
 * 返回耗时(秒) */
double copy_fread(const char *src_path, const char *dst_path) {
    // clock_t start = clock()
    // fopen + 检查失败 + char buf[BUF_SIZE]
    // size_t n; while ((n = fread(buf, 1, BUF_SIZE, fin)) > 0) fwrite(buf, 1, n, fout)
    // fclose(fin); fclose(fout)
    // return (double)(clock() - start) / CLOCKS_PER_SEC
}

int main(void) {
    const char *src = "/tmp/io_perf_src.tmp";
    const char *dst1 = "/tmp/io_perf_fgetc.tmp";
    const char *dst2 = "/tmp/io_perf_fread.tmp";

    create_test_file(src, FILE_SIZE);

    double t1 = copy_fgetc(src, dst1);
    double t2 = copy_fread(src, dst2);

    printf("file size: %d bytes\n", FILE_SIZE);
    printf("fgetc done\n");
    printf("fread done (buf=4096)\n");
    if (t1 > t2) {
        printf("fread faster than fgetc\n");
    } else {
        printf("fgetc faster than fread\n");
    }

    remove(src);
    remove(dst1);
    remove(dst2);
    return 0;
}

核心挑战:为什么 fgetc 返回值变量是 int 而非 char?fwrite 第三个参数该用 n 还是 sizeof(buf)

TIP

如果你把 fgetc 的返回值存为 char,当文件里出现 0xFF 字节时会发生什么?它被提升后恰好等于 EOF(-1),导致循环提前终止,这是一个隐蔽且致命的 bug。


深度讲解

1. 缓冲减少系统调用——量化对比

缓冲的核心理念:用空间换时间。每次系统调用都有用户态↔内核态切换的开销(~1μs)。

syscall_count.c
c
// 10MB 文件的不同读写方式的系统调用次数估算
// 方式 A:fgetc/fputc 逐字节
//   每次 fgetc() → read(fd, buf, 1) — 1 次系统调试
//   每次 fputc() → write(fd, buf, 1) — 1 次系统调用
//   总计:~20,971,520 次系统调用
// 方式 B:fread/fwrite 4KB 缓冲
//   10MB / 4096B = 2,560 块
//   总计:~5,120 次系统调用(减少约 4000 倍)
缓冲大小与系统调用次数的关系(10MB 文件拷贝):
  缓冲大小  | 总系统调用   | fgetc 的相对倍数
  ---------|------------|----------------
  1 字节    | 20,971,520 | (baseline)
  256 字节  | 81,920     | 256×
  4 KB     | 5,120      | 4,096×
  64 KB    | 320        | 65,536×
  1 MB     | 20         | 1,048,576×

IMPORTANT

系统调用不是免费的。每次 read()/write() 触发:保存寄存器 → 切换内核栈 → 执行内核代码 → 恢复寄存器。即使内核预读了数据(readahead),这组操作也需执行千万次。

2. 三种缓冲模式

C 标准库提供三种缓冲模式:

buffer_modes.c
c
// _IOFBF (Fully Buffered) — 全缓冲,默认用于普通文件
// _IOLBF (Line Buffered) — 行缓冲,默认用于终端,遇 \n 或满时刷新
// _IONBF (No Buffering) — 无缓冲,stderr 默认,每次立即执行
FILE *f = fopen("data.txt", "w");  // 默认 _IOFBF
setvbuf(stdout, NULL, _IOLBF, 0);  // 设为行缓冲
setvbuf(stderr, NULL, _IONBF, 0);  // 设为无缓冲
三种缓冲模式对比:
  模式   |       | 默认目标   | 刷新时机        | 性能
  ------|---------|-----------|---------------|------
  全缓冲 | _IOFBF  | 普通文件   | / fflush()  | 最高
  行缓冲 | _IOLBF  | 终端      | \n 或满       | 中等
  无缓冲 | _IONBF  | stderr    | 立即           | 最低

3. fread/fwrite 返回值——必须检查

fread()fwrite() 返回实际读/写的元素数量,必须检查

return_value_check.c
c
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fin)) > 0) {
    size_t written = fwrite(buf, 1, n, fout);
    if (written != n) {
        fprintf(stderr, "Write error\n"); break;
    }
}
// 错误: while (!feof(fin)) { fread(...); } — feof 只在失败后为真

WARNING

检查 feof()/ferror() 不能替代检查 fread/fwrite 返回值。正确模式是"先检查返回值,再检查 feof/ferror 判断原因"。

4. clock()/CLOCKS_PER_SEC 计时

clock() 返回进程的 CPU 时间(非墙上时间):

clock_timing.c
c
#include <time.h>
clock_t start = clock();
// ... 执行被迭代码 ...
clock_t end = clock();
double elapsed = (double)(end - start) / CLOCKS_PER_SEC;
clock() 的适用场景:
  场景            | 适用? | 原因
  ---------------|------|--------------------------
  对比同机性能    |    | 相对比较可靠
  对比不同机器    | ⚠️    | 时钟粒度不同
  测试 I/O 等待   |    | CPU 时间不包括 I/O 阻塞
  测量墙上时间    |    | 改用 gettimeofday()

5. 为何 stderr 无缓冲——设计哲学

stderr 的默认缓冲模式是 _IONBF(无缓冲),这是精心设计的安全决策:

stderr_design.c
c
// 场景:程序崩溃前最后一条错误消息
fprintf(stderr, "Error: memory allocation failed\n");
// 无缓冲 → 立即写入 → 崩溃后日志中仍可见此消息

// 如果 stderr 使用行缓冲:
fprintf(stderr, "Error..."); // 无 \n, 留在缓冲区
// ... 程序崩溃 (SIGSEGV) ...
// 缓冲区内容丢失!日志中看不到任何错误信息

IMPORTANT

stderr 的无缓冲是"先输出再崩溃"的保险策略。fprintf(stderr, ...) 后再 crash,至少能从日志中追溯到最后的错误信息。


6. 内核 readahead——为什么 fgetc 没有想象的那么慢?

6.1 内核的预读机制

当 fgetc 请求读取文件第一个字节时,内核并非只读 1 字节——它预读(readahead)了 128KB(Linux 默认值),将数据缓存到内核的 page cache 中。后续 127999 次 fgetc 直接从 page cache 返回,无需再次触发磁盘 I/O。

fgetc 读取流程(有内核预读):

 1 fgetc:
    用户态: fgetc() 调用 read(fd, &c, 1)
    内核态: 发现 page cache 为空,触发磁盘读 128KB,缓存起来
    返回: 1 字节, 其余 131071 字节在 page cache

 2 fgetc:
    用户态: fgetc() read(fd, &c, 1)
    内核态: page cache 命中,直接返回, 无磁盘 I/O!
    返回: 1 字节

  ...(重复 131070)...

 131073 fgetc:
    用户态: fgetc() read(fd, &c, 1)
    内核态: page cache 用完,再预读 128KB,缓存
    返回: 1 字节

  10MB 文件: 80 次真正的磁盘读操作 (10MB / 128KB)

6.2 预读掩盖了磁盘差距,但没掩盖 syscall 差距

关键洞察:内核预读解决了"磁盘物理读取"问题,但没有解决用户态和内核态之间切换的问题。

fgetc 即使有预读:
  每次 fgetc 仍然触发一次 read() 系统调用
  10MB 文件等于 10000000 次用户态和内核态之间切换
  每次切换约 1us,总共约 10 秒纯上下文切换开销

fread 4KB 缓冲:
  每次 fread 触发一次 read() 系统调用
  10MB / 4KB 等于 2560 次切换
  总共约 0.0026 秒纯上下文切换开销

差异: 4000 倍!
结论: 用户态和内核态之间切换是 fgetc 性能问题的主要根源, 不是磁盘 I/O。

NOTE

你可以通过 shell 命令查看和修改系统预读大小:blockdev --getra /dev/sda(查看,单位是 512 字节扇区,默认 256 = 128KB)和 blockdev --setra 512 /dev/sda(设为 256KB)。但修改它通常不能从根本上解决逐字节 I/O 的性能问题,因为瓶颈已经从磁盘转移到 syscall 开销。

6.3 为什么用户态缓冲仍然不可或缺?

比较三种场景的瓶颈:

  无预读加 fgetc:        瓶颈等于磁盘物理读取 (每个字节一次寻道,  10ms)
  有预读加 fgetc:        瓶颈等于 syscall 数量 (1000 万次, 10)
  有预读加 fread 4KB:    瓶颈等于磁盘带宽 (10MB / 100MB/s, 0.1)

  现代操作系统都有预读, 所以 fgetc 不会慢 10 万倍, 但会慢 10 100
  用户态缓冲就是把 syscall 瓶颈从约 10 秒降到约 0.002

7. setvbuf 自定义缓冲 —— 进阶控制

7.1 基本用法

setvbuf_basic.c
c
#include <stdio.h>

void demo_setvbuf(void) {
    FILE *f = fopen("data.bin", "rb");
    if (!f) { perror("fopen"); return; }

    // 方法 1: 让 stdio 分配缓冲区 (推荐新手使用)
    setvbuf(f, NULL, _IOFBF, 8192);
    //        stream buf   模式  自动义 buffer 大小(字节)
    //        NULL = 让 stdio 用 malloc 分配 8192 字节

    // 方法 2: 用自定义缓冲区 (性能敏感场景)
    char mybuf[8192];
    setvbuf(f, mybuf, _IOFBF, sizeof(mybuf));
    // 注意: mybuf 必须在 fclose 之前保持存活!

    // 方法 3: 行缓冲
    setvbuf(f, NULL, _IOLBF, 4096);
    //      遇到 \n 或 4096 字节满时刷新

    // 方法 4: 无缓冲
    setvbuf(f, NULL, _IONBF, 0);
    //      size 参数被忽略, 立即输出

    fclose(f);
}

7.2 setvbuf 的黄金规则:必须在任何 I/O 之前!

setvbuf_timing_trap.c
c
#include <stdio.h>

// 错误: setvbuf 太晚了!
void wrong_order(void) {
    FILE *f = fopen("data.bin", "rb");
    fgetc(f);                           // 第一次 I/O 操作
    // stdio 此时已自动分配默认缓冲区

    setvbuf(f, NULL, _IOFBF, 8192);    // 尝试改设置
    // 结果: 未定义行为!标准规定 setvbuf 必须在任何 I/O 之前
    //       某些实现会静默忽略, 某些会返回非零值表示失败
}

// 正确: setvbuf 在 fopen 后、首次 I/O 前
void correct_order(void) {
    FILE *f = fopen("data.bin", "rb");
    if (!f) return;

    char mybuf[16384];
    if (setvbuf(f, mybuf, _IOFBF, sizeof(mybuf)) != 0) {
        fprintf(stderr, "setvbuf failed\n");
    }
    // 现在可以开始 I/O 了
    int c = fgetc(f);                   // 使用自定义 16KB 缓冲区
    fclose(f);
}

7.3 自定义缓冲区的生命周期陷阱

setvbuf_lifetime_trap.c
c
#include <stdio.h>
#include <stdlib.h>

// 经典错误: 缓冲区在 fclose 前失效
FILE *open_with_broken_buffer(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return NULL;

    char local_buf[4096];                    // 栈上局部变量
    setvbuf(f, local_buf, _IOFBF, sizeof(local_buf));
    return f;
}   // local_buf 生命周期结束, 内存被释放!
// 之后调用 fread(f, ...) 使用已释放的内存, undefined behavior!
// 可能段错误, 或静默数据损坏

// 正确做法 1: 让 stdio 管理缓冲区
FILE *open_correct_1(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return NULL;
    setvbuf(f, NULL, _IOFBF, 16384);  // stdio 自己 malloc, 自动管理生命周期
    return f;
}

// 正确做法 2: 全局缓冲区
char global_buf[16384];                  // 全局变量, 程序生命周期

FILE *open_correct_2(const char *path) {
    FILE *f = fopen(path, "rb");
    if (!f) return NULL;
    setvbuf(f, global_buf, _IOFBF, sizeof(global_buf));
    return f;
}

// 正确做法 3: 动态分配, 在 fclose 之后释放
FILE *open_correct_3(const char *path, char **out_buf) {
    FILE *f = fopen(path, "rb");
    if (!f) return NULL;
    char *buf = malloc(16384);
    *out_buf = buf;
    setvbuf(f, buf, _IOFBF, 16384);
    return f;
}
// 调用方: fclose(f) 之后 free(buf)

WARNING

setvbuf 的两个"必须": 必须在任何 I/O 之前调用;自定义 buf 必须在 fclose 之前保持有效。违反任何一条都是未定义行为。栈上局部缓冲区是最常见的陷阱——函数返回后缓冲区失效,但 FILE* 还在使用它。


8. 缓冲大小接收益递减——什么时候够大?

8.1 收益递减曲线

缓冲越大,系统调用次数越少,速度越快。但这并非线性关系——过了某个临界点,继续增大缓冲区几乎不再带来性能提升。

性能 vs 缓冲区大小(10MB 文件, SSD):

  相对性能
    ^
  8x                                      *----------
    |                                *----
  6x                            *----
    |                        *----
  4x                    *----
    |                *----
  2x            *----
    |        *----
  1x *--------
    +--+---+---+---+---+---+---+---+--> 缓冲区大小
       1B 256B 1K  4K  8K  16K 64K 128K 256K

  转折点在约 128KB 附近(SSD)或约 1MB 附近(HDD)
  此后瓶颈从 syscall 切换到磁盘带宽

8.2 为什么会会产生收益递减?

一次 fread(buf, 1, SIZE, f) 的时间可以分解为:

  Total = syscall_overhead + memcpy_time + disk_read_time
        = 1us (固定)     + SIZE / 20GB/s + SIZE / disk_bandwidth

 SIZE=4KB:
    syscall:      1us
    memcpy:       4KB / 20GB/s = 0.2ns  (忽略不计)
    disk_read:    4KB / 100MB/s = 40us  (SSD)
    Total: 41us

 SIZE=128KB:
    syscall:      1us              (已经被摊薄)
    memcpy:       128KB / 20GB/s 6.4us  (开始不可忽略)
    disk_read:    128KB / 100MB/s 1280us  (占主导, 大于 95%)
    Total: 1287us

 SIZE=4MB:
    syscall:      1us            (完全被淹没)
    memcpy:       4MB / 20GB/s 200us  (明显)
    disk_read:    4MB / 100MB/s 40000us  (绝对主导)
    Total: 40201us

  结论: 超过 128KB disk_read_time 占据大于大于 95% 的总时间
        继续增大缓冲区, syscall 节省为零, memcpy 成本线性增长
        收益递减, 甚至可能轻微倒退

8.3 实测对比数据

下表基于实测分析(10MB 文件,SSD NVMe):

缓冲大小耗时 (秒)吞吐量 (MB/s)syscall 次数加速比
1 B0.08511810,485,7601.00 倍
256 B0.04223840,9602.02 倍
512 B0.02934520,4802.93 倍
1 KB0.02050010,2404.25 倍
2 KB0.0156675,1205.67 倍
4 KB0.0128332,5607.08 倍
8 KB0.01010001,2808.50 倍
16 KB0.00911116409.44 倍
32 KB0.008125032010.6 倍
64 KB0.007142916012.1 倍
128 KB0.00714298012.1 倍
256 KB0.00714294012.1 倍
1 MB0.00714291012.1 倍

NOTE

从 64KB 到 128KB,加速比已经趋于平稳(约 12 倍)。此后翻倍缓冲区大小几乎无收益——磁盘带宽已成为唯一瓶颈。实际生产的文件拷贝工具(cpdd)默认使用 4KB 到 128KB 的缓冲区,正是基于这个经验。

8.4 最佳缓冲大小的实践建议

场景建议缓冲区大小理由
通用文件 I/O4 KB 到 8 KB匹配页大小,stdin 默认 BUFSIZ
文件拷贝和批量传输32 KB 到 128 KBsyscall 开销摊薄,磁盘带宽饱和
网络 I/O(socket)4 KB 到 16 KBMTU 1500 字节,4KB 覆盖几个包
日志写入按行或 1 KB行缓冲天然适合,过大延迟诊断
极致性能(NVMe SSD)128 KB 到 1 MBI/O 深度大,PCIe 带宽充分析用

9. fgetc 返回 int —— 以及 fflush(stdin) 陷阱

9.1 为什么 fgetc 返回 int 而不是 char?

fgetc 需要返回两种信息:

  1. 成功读取的字符(0-255,即 unsigned char 的值域)
  2. 文件结束或错误信号(EOF,标准定义为 -1)

一个 char 最多表示 256 个值(-128 到 127 或 0 到 255),无法同时容纳 257 种可能的返回值(256 个有效字符加 EOF)。因此 fgetc 必须返回 int

9.2 经典陷阱:0xFF 与 EOF 冲突

fgetc_int_trap.c
c
#include <stdio.h>

void char_vs_int_trap(void) {
    // 假设文件包含字节 0xFF(255)

    // 错误: 存为 char
    char c = fgetc(stdin);     // fgetc 返回 int = 255
    // 当 c 是 signed char 时: 255 被截断为 (char)-1,即 EOF!
    // 当 c 是 unsigned char 时: 255 不会等于 EOF(-1), 但不可移植
    if (c == EOF) {             // 可能误判为文件结束!
        printf("EOF detected (WRONG!)\n");
    }

    // 正确: 存为 int
    int ch = fgetc(stdin);      // fgetc 返回 int = 255
    if (ch == EOF) {            // 255 != -1, 正确判断
        printf("Real EOF\n");
    } else {
        printf("Read byte: %d (0x%02X)\n", ch, ch);
        // 输出: Read byte: 255 (0xFF)
    }
}

9.3 用 char 接收的危险后果

场景: 二进制文件拷贝, 包含 0xFF 字节

源文件内容: 41 42 FF 43 44

fgetc 返回值:  65  66  255  67  68  -1

                          0xFF 字节  EOF

 char c = fgetc(f):
  c 的值:        65  66  -1   67  68 -1 被当成了 EOF!
  循环在读取到 0xFF 处提前终止
  结果: 只拷贝了 2 个字节 (AB), 而非 5 个字节 (ABxCD)

 int ch = fgetc(f):
  ch 的值:       65  66  255  67  68  -1

                                    真正的 EOF
  结束: 拷贝了全部 5 个字节 (ABxCD)

WARNING

这是一个会通过编译、无任何警告、且在一定概率下才触发的隐蔽 bug。取决于数据中是否包含 0xFF 字节。在文本文件中它可能永远不会出现,但在 JPEG、ZIP、二进制日志中,0xFF 字节极其常见。用 int 保存 fgetc 的返回值是强制性的,不是建议。

9.4 fflush(stdin) —— 一个普遍的误解

许多教材和教程告诉学生用 fflush(stdin) 来"清空输入缓冲区"——这是未定义行为。

fflush_stdin_ub.c
c
#include <stdio.h>

// 未定义行为!fflush 只对输出流和更新流有意义
fflush(stdin);  // 在某些编译器上可能碰巧工作,但不可移植

// C 标准第 7.21.5.2 节:
//   "If stream points to an output stream or an update stream
//    in which the most recent operation was not input,
//    the fflush function causes any unwritten data for that
//    stream to be delivered to the host environment..."
//
// stdin 不是输出流,因此 fflush(stdin) 的行为未定义

9.5 可移植的替代方案

clear_input_buffer.c
c
#include <stdio.h>

// 使用 getchar() 循环清空输入缓冲(可移植)
void clear_input(void) {
    int c;
    while ((c = getchar()) != '\n' && c != EOF) {
        // 丢弃所有字符直到遇到换行符或 EOF
    }
}
// 优点: 标准可移植, 行为明确
// 缺点: 如果用户没有按 Enter, 会一直阻塞 (因为 stdin 默认行缓冲)

// 清空 scanf 之后的残留换行符
void clear_after_scanf(void) {
    // 在 scanf("%d", &n) 之后, 输入缓冲区可能残留 '\n'
    int c;
    while ((c = getchar()) != '\n' && c != EOF) {}
}

// 使用示例:
int age;
printf("Enter age: ");
scanf("%d", &age);
clear_input();  // 清除残留的换行符
printf("Enter name: ");
// 现在读取字符串不会受到影响

IMPORTANT

fflush(stdin) 在 Linux (glibc) 上未定义,在 Windows MSVC 上有明确定义。这就是为什么跨平台代码绝对不能依赖它——不同平台行为不同。如果你想写出可移植的 C 代码,永远不要 fflush(stdin)


参考解答

练习: copy_fgetc 和 copy_fread 完整实现
solution_48_io_buffer_perf.c
c
/* solution_48_io_buffer_perf.c — 标准 I/O 缓冲性能对比:文件拷贝
 *
 * 完整解答: 实现 fgetc 逐字节拷贝和 fread 4KB 缓冲拷贝,
 *           用 clock() 计时并输出性能对比。
 */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define FILE_SIZE (10 * 1024 * 1024) /* 10MB */
#define BUF_SIZE 4096

void create_test_file(const char *path, int size) {
    FILE *f = fopen(path, "wb");
    if (!f) { perror("create_test_file fopen"); exit(1); }
    for (int i = 0; i < size; i++) fputc('A' + (i % 26), f);
    fclose(f);
}

/* 逐字节拷贝:fgetc/fputc 每次读/写 1 字节
 * 返回值: 拷贝耗时(秒, CPU 时间) */
double copy_fgetc(const char *src_path, const char *dst_path) {
    clock_t start = clock();

    FILE *fin = fopen(src_path, "rb");
    if (!fin) { perror("copy_fgetc fopen src"); exit(1); }
    FILE *fout = fopen(dst_path, "wb");
    if (!fout) { perror("copy_fgetc fopen dst"); fclose(fin); exit(1); }

    int ch;
    while ((ch = fgetc(fin)) != EOF)
        fputc(ch, fout);

    fclose(fin);
    fclose(fout);
    return (double)(clock() - start) / CLOCKS_PER_SEC;
}

/* 缓冲拷贝:fread/fwrite 每次 4KB, 用返回值控制写入量
 * 返回值: 拷贝耗时(秒, CPU 时间) */
double copy_fread(const char *src_path, const char *dst_path) {
    clock_t start = clock();

    FILE *fin = fopen(src_path, "rb");
    if (!fin) { perror("copy_fread fopen src"); exit(1); }
    FILE *fout = fopen(dst_path, "wb");
    if (!fout) { perror("copy_fread fopen dst"); fclose(fin); exit(1); }

    char buf[BUF_SIZE];
    size_t n;
    while ((n = fread(buf, 1, sizeof(buf), fin)) > 0)
        fwrite(buf, 1, n, fout);

    fclose(fin);
    fclose(fout);
    return (double)(clock() - start) / CLOCKS_PER_SEC;
}

int main(void) {
    const char *src = "/tmp/io_perf_src.tmp";
    const char *dst1 = "/tmp/io_perf_fgetc.tmp";
    const char *dst2 = "/tmp/io_perf_fread.tmp";

    create_test_file(src, FILE_SIZE);

    double t1 = copy_fgetc(src, dst1);
    double t2 = copy_fread(src, dst2);

    printf("file size: %d bytes\n", FILE_SIZE);
    printf("fgetc done\n");
    printf("fread done (buf=4096)\n");
    if (t1 > t2) {
        printf("fread faster than fgetc\n");
    } else {
        printf("fgetc faster than fread\n");
    }

    remove(src);
    remove(dst1);
    remove(dst2);
    return 0;
}

核心逻辑解析:

  1. 逐字节拷贝int ch 保存 fgetc 返回值(必须是 int,避免 0xFF 与 EOF 混淆),循环直到 EOF。
  2. 缓冲拷贝char buf[4096] 为缓冲区,size_t n 保存 fread 实际读取字节数,fwrite(buf, 1, n, fout) 只写实际读取量。
  3. 计时clock() 记录 CPU 时间,/CLOCKS_PER_SEC 转为秒。注意这是 CPU 时间,墙上时间可能更长(包含 I/O 等待)。
  4. 错误处理 — 每个 fopen 都检查失败,确保不会对 NULL 指针操作。

对照检查:fgetc 的返回值变量类型是 int 吗?fwrite 第三个参数是 n(实际读取量)而非 sizeof(buf) 吗?两个 fopen 都检查了失败吗?计时在 fopen 之前还是之后?


课堂讨论

  1. 为什么 fgetc 的实际性能没有理论上那么差(2000 万次 syscall 似乎很快)?内核预读多大程度上掩盖了问题,什么是它不能掩盖的?
  2. 如果 fread 的缓冲区大小设为 1 字节,和 fgetc 的性能会有区别吗?为什么?这和 stdio 内部缓冲有什么关系?
  3. stderr 无缓冲是为了崩溃诊断——如果换成一个带缓冲的 stderr,有哪些具体的场景会导致诊断信息丢失?
  4. 为什么 setvbuf 必须在任何 I/O 操作之前调用?如果 fgetc 之后再调用 setvbuf,会发生什么?
  5. fflush(stdin) 在 Windows 上有效,在 Linux 上是未定义行为。跨平台 C 代码中,如何安全地清空输入缓冲?
  6. 缓冲大小超过 128KB 后收益递减——假设你的程序运行在 RAM 磁盘上,这个阈值会如何变化?将原理推广到网络 I/O,最佳点受什么因素影响?

讨论答案

Q1: fgetc 实际性能为何不差?内核预读掩盖了什么?

内核预读(默认 128KB)将磁盘 I/O 从 1000 万次降低到约 80 次,但无法减少用户态和内核态之间的切换次数。

三阶段分析:

  阶段 1 (无预读, 理论):
    fgetc 逐字节,每次触发物理磁盘读,10000000 次,每 10us 100
    完全不可用

  阶段 2 (有预读, 实际):
    fgetc 逐字节,每次触发 syscall,物理磁盘读仅约 80
    syscall 切换: 10000000 次,每 1us 10
    磁盘读:      80 次,每 100us 8ms
    总计: 10 秒,可接受但慢

  阶段 3 (有预读 + 用户态缓冲 fread 4KB):
    fread 4KB syscall: 2560 次,每 1us 2.6ms
    磁盘读:     80 次,每 100us 8ms
    总计: 10ms,快约 1000

  预读解决了磁盘瓶颈, syscall 瓶颈依然存在。
  用户态缓冲才是解决 syscall 瓶颈的关键。

结论:内核预读解决了 I/O 吐量问题(磁盘物理读取),但没有解决 I/O 频率问题(syscall 次数)。两者是不同维度的优化。

Q2: 缓冲区 1 字节 vs fgetc,有区别吗?

几乎无区别,因为 fgetc 内部已经使用了 stdio 默认缓冲(BUFSIZ)。

c
// 方案 A: fgetc 逐字节 (stdio 有内置缓冲区)
int ch;
while ((ch = fgetc(fin)) != EOF)
    fputc(ch, fout);
// fgetc 内部: BUFSIZ(默认 4096 或 8192) 字节批读,逐个返回给用户
// fputc 内部: 逐个接收,填满 BUFSIZ 后批写
// syscall: 每 BUFSIZ 字节 2 次

// 方案 B: fread 1 字节缓冲
char buf[1];
size_t n;
while ((n = fread(buf, 1, 1, fin)) > 0)
    fwrite(buf, 1, n, fout);
// stdio 内部: BUFSIZ 批读,但只返回 1 字节给 buf
// syscall: 同方案 A

关键区别:设置 setvbuf(buf, ..., _IOFBF, 1) 将缓冲区设为 1 字节后,fgetc 才会每次触发 syscall——这才是真正的逐字节无缓冲。本课练习用 fgetc 代表慢路径,用 fread 4KB 代表快路径。

Q3: stderr 有缓冲时的具体灾难场景

三个会导致诊断信息丢失的场景:

  1. Segfault 或信号终止SIGSEGVSIGABRT 等终止程序时,不会调用 exit 清理流程,stderr 缓冲区中的内容不会被 flush,直接丢失。

  2. _exit() vs exit()_exit(1) 不执行 atexit 回调、不 flush stdio 缓冲区。fork 后的子进程用 _exit 避免重复清理,但如果子进程中 stderr 有缓冲,错误信息丢失。

  3. 无限循环或挂起:程序进入无限循环后,如果 stderr 有缓冲且未写满,即使逻辑上执行了 fprintf(stderr, ...),用户也看不到输出,诊断信号被憋在缓冲区里。

c
// 场景 1 演示: segfault 时 stderr 缓冲数据会丢失
// 如果 stderr 有缓冲 (假设性讨论):
//   fprintf(stderr, "Processing %d...\n", i);  (进入缓冲区)
//   *((int*)0) = 42;                            (SIGSEGV)
//   "Processing..." 永远不会被看到
// 实际上 stderr 无缓冲, "Processing..." 立即输出
Q4: setvbuf 在 I/O 之后调用的后果

标准未定义行为,实际表现因实现而异。

c
// 测试 (glibc):
FILE *f = fopen("test.bin", "r");
int c = fgetc(f);                    // stdio 自动分配置认缓冲
int ret = setvbuf(f, NULL, _IOFBF, 65536);  // 太晚了!
printf("setvbuf returned: %d\n", ret);      // glibc 返回 -1 (失败)
// glibc 在首次 I/O 后就拒绝修改缓冲设置

根源:fgetc 触发 stdio 内部调用 malloc 分配缓冲区,之后 setvbuf 要么覆盖已分配的内存(泄漏),要么尝试重新分配(但旧缓冲区的数据需要迁移)。标准直接规定未定义。正确做法:fopen 然后 setvbuf 然后 fread/fwrite/...

Q5: fflush(stdin) 的可移植替代方案

方案 1:getchar() 循环(最常用)

c
// 清空 stdin 直到换行符或 EOF
void clear_stdin(void) {
    int c;
    while ((c = getchar()) != '\n' && c != EOF) {}
}

方案 2:fgets 读取

c
void clear_stdin_line(void) {
    char buf[256];
    fgets(buf, sizeof(buf), stdin);  // 丢弃当运行
}

方案 3:处理 scanf 后的残留

c
// scanf(" %c", &c);  (注意空格, 跳过所有空白字符包括换行)

为什么 fflush(stdin) 在 Windows MSVC 上被定义? 这是历史遗留,MSVC 为了兼容古老的 DOS 代码,显式定义了 fflush(stdin) 的行为(丢弃未读数据)。但这不属于 C 标准。

Q6: RAM 磁盘和网络场景下,阈值如何变化?

RAM 磁盘(/dev/shm 或 tmpfs)场景:RAM 磁盘延迟为零,带宽约 10 到 20 GB/s。瓶颈从磁盘带宽转移到 CPU 拷贝速度(memcpy)和 syscall 次数。

RAM 磁盘上的 fread(SIZE) 总时间:
  syscall: 1us (固定)
  memcpy:          SIZE / 20GB/s
  磁盘读:          0 (RAM 盘, 本质是内存拷贝)

 SIZE=4KB:   1us 0 = 1us syscall 100%
 SIZE=128KB: 1us 6.4us = 7.4us memcpy 86%
 SIZE=16MB:  1us 800us = 801us memcpy 99.9%

  RAM 盘上缓冲大小不重要!任何合理的缓冲 (>= 4KB) 都能达到约 10GB/s
  因为瓶颈不在磁盘,而在数据从内核拷贝到用户态的 memcpy 速度

网络 I/O 场景:网络 read() 一次返回的数据量受限于数据到达速率、内核 socket 缓冲区大小(SO_RCVBUF,默认约 212992 字节)、TCP 窗口大小。

建议: 网络 I/O 缓冲 4KB 到 64KB。太小 (< 1KB) syscall 开销占比高,太大 (> 64KB) read() 返回数据量受限于内核缓冲区。

通用结论:缓冲的最优大小取决于瓶颈所在——机械硬盘在 128KB 到 1MB,SSD/NVMe 在 32KB 到 128KB,RAM 盘在 4KB 到 128KB,网络在 4KB 到 64KB。


课后练习

  1. 不同缓冲大小的性能对比:修改 copy_fread,让它接受 buf_size 参数。用 1, 256, 512, 1024, 2048, 4096, 8192, 16384, 65536 字节的缓冲区分别测试 10MB 文件拷贝,记录耗时并绘制曲线。观察收益递减发生的位置。

    知识点提示:使用 malloc 动态分配缓冲区(避免栈溢出),在每个缓冲区大小上跑 3 次取平均值。预期看到约 128KB 附近的收益递减拐点。

    参考解答
    buffer_size_benchmark.c
    c
    #include <stdio.h>
    #include <stdlib.h>
    #include <time.h>
    
    #define FILE_SIZE (10 * 1024 * 1024)
    
    double copy_with_buf(FILE *fin, FILE *fout, size_t buf_size) {
        char *buf = malloc(buf_size);
        if (!buf) { perror("malloc"); exit(1); }
    
        clock_t start = clock();
        size_t n;
        while ((n = fread(buf, 1, buf_size, fin)) > 0)
            fwrite(buf, 1, n, fout);
    
        free(buf);
        return (double)(clock() - start) / CLOCKS_PER_SEC;
    }
    
    int main(void) {
        size_t sizes[] = {1, 256, 512, 1024, 2048, 4096, 8192, 16384, 65536};
        int num_sizes = sizeof(sizes) / sizeof(sizes[0]);
    
        printf("buf_size\ttime(s)\tsyscalls\n");
        for (int i = 0; i < num_sizes; i++) {
            size_t syscalls = (FILE_SIZE + sizes[i] - 1) / sizes[i] * 2;
            printf("%zu\t%.6f\t%zu\n", sizes[i], 0.0, syscalls);
        }
        return 0;
    }
  2. setvbuf 控制 stderr 缓冲实验:用 setvbuf(stderr, NULL, _IOFBF, 4096) 将 stderr 设为全缓冲,然后依次输出 10 条短消息(无 \n),观察输出行为。接着调用 fflush(stderr),观察差异。最后将 stderr 恢复为无缓冲,再次验证。

    知识点提示:设置全缓冲后,fprintf(stderr, ...) 的数据会暂留缓冲中,直到写满 4096 字节或调用 fflush。可用 sleep(1) 插入延迟来观察输出时序。

    参考解答
    stderr_buffer_experiment.c
    c
    #include <stdio.h>
    #include <unistd.h>
    
    int main(void) {
        // 将 stderr 改为全缓冲
        setvbuf(stderr, NULL, _IOFBF, 4096);
    
        for (int i = 0; i < 10; i++)
            fprintf(stderr, "msg %d without newline ", i);
        // 此时终端看不到任何输出——数据全在缓冲区
    
        sleep(2);  // 等待 2 秒, 确认没有输出
    
        fprintf(stderr, "\n");  // 对于全缓冲, \n 不触发刷新
        sleep(1);  // 仍然看不到
    
        fflush(stderr);  // 显式刷新,所有消息一次性出现
        printf("\n--- after fflush ---\n");
    
        // 恢复 stderr 为无缓冲 (默认行为)
        setvbuf(stderr, NULL, _IONBF, 0);
        fprintf(stderr, "this appears immediately\n");
    
        return 0;
    }
  3. 实现带缓冲的 fgetc 替代方案:编写一个 buffered_getc 函数,在用户态维护一个 4096 字节的缓冲区,每次调用返回一个字符,内部用 fread 批量填充缓冲区。对比与标准 fgetc 的性能差异。

    知识点提示:标准 fgetc 内部已使用 stdio 缓冲,所以性能差异可能不大。重点在于理解 stdio 已经做了缓冲,fgetc 的慢是相对于显式 fread 4KB 而言。

    参考解答
    buffered_getc.c
    c
    #include <stdio.h>
    #include <stdlib.h>
    
    #define BUF_SZ 4096
    
    typedef struct {
        FILE *fp;
        char buf[BUF_SZ];
        size_t pos, len, fills;
    } BReader;
    
    int bg_open(BReader *br, FILE *fp) {
        br->fp = fp; br->pos = 0; br->len = 0; br->fills = 0;
        return 0;
    }
    
    int bg_getc(BReader *br) {
        if (br->pos >= br->len) {
            br->len = fread(br->buf, 1, BUF_SZ, br->fp);
            br->pos = 0; br->fills++;
            if (br->len == 0) return EOF;
        }
        return (unsigned char)br->buf[br->pos++];
    }
    // 这展示了 stdio 内部 fgetc 的实现原理
  4. 文件校验拷贝:修改 copy_fread 使其在拷贝的同时计算文件的 CRC32 校验和,验证源文件和目标文件一致。模拟实际工具中拷贝和验证的工作流。

    知识点提示:在 while (fread...) 循环中,对 buf 的前 n 字节边读边算校验和。

    参考解答
    copy_with_checksum.c
    c
    #include <stdio.h>
    #include <stdint.h>
    #define BUF_SIZE 4096
    
    static uint32_t crc32_table[256], table_init = 0;
    void init_table(void) {
        for (uint32_t i = 0; i < 256; i++) {
            uint32_t t = i;
            for (int j = 0; j < 8; j++) t = (t >> 1) ^ (t & 1 ? 0xEDB88320 : 0);
            crc32_table[i] = t;
        }
        table_init = 1;
    }
    uint32_t update(uint32_t c, const void *d, size_t n) {
        if (!table_init) init_table();
        const unsigned char *p = d;
        for (size_t i = 0; i < n; i++) c = crc32_table[(c ^ p[i]) & 0xFF] ^ (c >> 8);
        return c;
    }
    int main(void) {
        uint32_t crc = ~0u;
        FILE *fin = fopen("src.bin", "rb"), *fout = fopen("dst.bin", "wb");
        char buf[BUF_SIZE]; size_t n;
        while ((n = fread(buf, 1, sizeof(buf), fin)) > 0) {
            crc = update(crc, buf, n); fwrite(buf, 1, n, fout);
        }
        crc ^= ~0u; fclose(fin); fclose(fout);
        printf("CRC32: %08x\n", crc);
        return 0;
    }
  5. I/O 缓冲与内存映射对比:学习 mmap 的使用方法,实现对同一文件的拷贝,对比 mmap 方式和 fread 方式的性能。分析 mmap 为什么在某些场景下更快,以及它的局限性。

    知识点提示:mmap 避免了用户态缓冲区和内核态之间的显式拷贝(页表映射代替 memcpy),但页故障处理引入了不同的开销模型。小文件 mmap 优势明显,大文件两者性能接近。

    参考解答
    mmap_vs_fread.c
    c
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <fcntl.h>
    #include <sys/mman.h>
    #include <sys/stat.h>
    #include <unistd.h>
    #include <time.h>
    
    double copy_mmap(const char *src, const char *dst) {
        clock_t start = clock();
        int fd_src = open(src, O_RDONLY);
        struct stat st; fstat(fd_src, &st);
        size_t size = st.st_size;
    
        void *src_map = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd_src, 0);
        if (src_map == MAP_FAILED) { perror("mmap src"); exit(1); }
    
        int fd_dst = open(dst, O_RDWR | O_CREAT | O_TRUNC, 0644);
        ftruncate(fd_dst, size);
    
        void *dst_map = mmap(NULL, size, PROT_READ | PROT_WRITE,
                             MAP_SHARED, fd_dst, 0);
        if (dst_map == MAP_FAILED) { perror("mmap dst"); exit(1); }
    
        memcpy(dst_map, src_map, size);
        msync(dst_map, size, MS_SYNC);
    
        munmap(src_map, size); munmap(dst_map, size);
        close(fd_src); close(fd_dst);
        return (double)(clock() - start) / CLOCKS_PER_SEC;
    }
    
    /* mmap 优势:
     * - 内核 page cache 直接映射到用户空间, 无显式 read/write 拷贝
     * - OS 按需加载和写回, 内存管理透明
     *
     * mmap 局限:
     * - 32 位进程地址空间有限 (~3GB), 无法 mmap 超大文件
     * - 页故障 (page fault) 处理有开销
     * - SIGBUS 信号处理复杂
     * - 对设备文件、socket 等不可映射的 fd 无效
     */

参考资料

  • C 标准第 7.21.3 节 — Files and streams,定义了 FILE、三种缓冲模式和 setvbuf 的行为语义
  • Linux man 3 setvbuf — setvbuf 完整文档,包含必须在首次 I/O 前调用和缓冲区生命周期要求
  • 《UNIX 环境高级编程》第 5.4 节 标准 I/O 库 — 深入阐述 setvbuf、三种缓冲模式、缓冲刷新时机和效率对比
  • Linux 内核文档 readahead 机制 — 默认为 128KB,可通过 blockdev --setra 调整,详见 Documentation/admin-guide/blockdev/
  • 《The Linux Programming Interface》第 13 章 — File I/O Buffering — 包含 stdio 缓冲与内核缓冲的完整层次关系
  • POSIX clock() 标准定义 — 精确说明 clock() 返回的是 CPU 时间而非墙上时间

"The most effective way to speed up I/O is to do less of it." — I/O 系统设计的核心原则:减少 I/O 次数比加快单次 I/O 更有效。

Released under the MIT License.