このページの目次

バッファリング I/O (stdio)

カバー範囲: setvbuf の3つのポリシー → fread/fwrite のバッファリング機構 → ユーザー空間バッファ vs カーネルページキャッシュ → 行バッファリングと端末 → カスタムバッファ 対象: glibc stdio, musl

概要

fread/fwrite は直接 read/write システムコールを呼び出しません。ユーザー空間でバッファを管理しています。そのため、fwrite(buf, 1, 1, fp) を実行しても(fflush が呼ばれるか、バッファがいっぱいになるまで)システムコールは発生しません。この仕組みを理解することは、「なぜ printf の出力が表示されないのか」「なぜ fwritewrite よりも高速なのか」といった問題のトラブルシューティングの基礎となります。

setvbuf: 3つのバッファリングモード

#include <stdio.h>

// _IONBF: バッファなし → 各 fwrite で直接 syscall
setvbuf(fp, NULL, _IONBF, 0);

// _IOLBF: 行バッファ → '\n' またはバッファがいっぱいになると flush
setvbuf(fp, NULL, _IOLBF, BUFSIZ);  // BUFSIZ = 8192 (glibc)

// _IOFBF: 全バッファ → バッファがいっぱいになると flush
setvbuf(fp, NULL, _IOFBF, BUFSIZ);

// デフォルトポリシー:
//   stderr:      バッファなし (_IONBF)
//   tty (stdout): 行バッファ (_IOLBF)
//   通常ファイル:  全バッファ (_IOFBF), サイズ = BUFSIZ

FILE 内部構造 (glibc 簡略版)

// glibc: libio/fileops.c, libio/genops.c
struct _IO_FILE {
    int     _flags;         // _IO_UNBUFFERED, _IO_LINE_BUF, ...
    char    *_IO_read_ptr;  // 現在の読み込みポインタ
    char    *_IO_read_end;  // 読み込みバッファの末尾 (buffer + bytes_read)
    char    *_IO_write_ptr; // 現在の書き込みポインタ
    char    *_IO_write_end; // 書き込みバッファの末尾 (buffer + BUFSIZ)
    char    *_IO_buf_base;  // バッファの開始位置
    char    *_IO_buf_end;   // バッファの末尾

    int     _fileno;        // 対応するファイルディスクリプタ
    // ... ロック, ワイド文字, フォールバック, ...
};

// fwrite(buf, 1, n, fp):
//   1. n <= (fp->_IO_write_end - fp->_IO_write_ptr) の場合:
//      → バッファに memcpy → _IO_write_ptr += n → n を返す
//      (非常に高速! ユーザー空間のみで完結し、syscall は発生しない)
//   2. それ以外の場合: まず flush (write(fd, buffer, ...)) → その後 memcpy
//   3. 行バッファモードで buf に '\n' が含まれている場合: '\n' の位置まで flush

ユーザー空間バッファ vs カーネルページキャッシュ

fwrite で 1 バイト × 1000 回書き込み:
  全バッファモード:    1 回の write syscall (バッファ flush) → 約 1 回のカーネル呼び出し
  バッファなしモード:  1000 回の write syscall → 約 1000 回のカーネル呼び出し
  パフォーマンス差:   約 100 倍

カーネル write で 1 バイト × 1000 回ファイルに書き込み:
  ページキャッシュがマージされる (同じページ内であれば) → 1 回のディスク IO
  ただし、各 write() には syscall のオーバーヘッドがかかる (それぞれ約 200ns)

典型的な組み合わせ:
  fwrite: ユーザー空間でのバッチ処理 (syscall 回数の削減)
  page cache: カーネル空間でのバッチ処理 (ディスク IO 回数の削減)

行バッファリングの落とし穴

// 端末出力 (stdout はデフォルトで行バッファ):
printf("hello");        // バッファに入るが、出力されない ('\n' がないため)
fprintf(stderr, "!");   // stderr はバッファなし → "!" が即座に出力
printf(" world\n");     // バッファに入る + '\n' → flush → "hello world" が出力

// 出力結果: "!hello world" (意図した順序ではない!)

// 修正方法:
printf("hello");
fflush(stdout);         // 強制的に flush
fprintf(stderr, "!");
printf(" world\n");

// または: 最初から stdout のバッファリングを無効にする:
setvbuf(stdout, NULL, _IONBF, 0);

fork とバッファ

// 古典的な罠:
printf("before fork\n");  // バッファにデータがあるが '\n' もある? → '\n' があるため flush される
fork();
// OK: fork の前に flush されている

printf("before fork");    // バッファにデータがあるが '\n' がない
fork();
// 子プロセスは FILE 構造全体 (バッファ含む) をコピーする
// → 親プロセスと子プロセスの両方が、後の flush 時に "before fork" を出力する
// → 2 回出力される!

// ルール: fork の前に常に fflush(NULL) を呼び出すか、行バッファを使用して確実に flush する

カスタムバッファリングポリシー

// 独自のバッファを提供する (glibc デフォルトの BUFSIZ 割り当てではない):
char mybuf[4096];
setvbuf(fp, mybuf, _IOFBF, sizeof(mybuf));

// 注意: mybuf の生存期間は fp の使用期間以上でなければならない
// (glibc はバッファをコピーせず、ポインタのみを保持する)

参考

  • man: setvbuf(3), fflush(3), fwrite(3)
  • glibc ソースコード⁠: libio/fileops.c, libio/genops.c
  • LWN: "The GNU C Library's stdio implementation"

キーワード: setvbuf, _IONBF, _IOLBF, _IOFBF, fflush, BUFSIZ, FILE, fork buffer, 行バッファリング