ラインバッファリング出力よりも優れた方法がある
We can do better than line-buffered output (nullprogram.com)
要約
この記事では、ターミナルへの出力におけるラインバッファリングがパフォーマンスのボトルネックとなる問題を指摘しています。ラインバッファリングの代わりに、Nagle's algorithmのように短いアイドル時間後にバッファをフラッシュするデッドラインベースのアプローチを提案し、インタラクティブなアプリケーションでも高速なデータ配信を実現する方法を示しています。
全文翻訳
ラインバッファリング出力よりも優れた方法がある
2026年11月5日
nullprogram.com/blog/2026/11/05/
バッファリングされた出力は通常、フルバッファリングとラインバッファリングの2種類があります。出力がターミナルに向かう場合、実装はそのバッファを自動的にラインバッファリングに設定し、行が完了したときにバッファをフラッシュして、ユーザーに即座に届けます。それ以外の場合はフルバッファリングを行い、バッファがいっぱいになるか、将来の任意の時点で明示的なフラッシュイベントが発生するまで出力を保持します。ラインバッファリングは、非インタラクティブなターミナルアプリケーションでは一般的なボトルネックであり、それを避けるために出力をファイルやパイプにリダイレクトする価値があります。しかし、そうする必要はありません。私たちは両方の利点を得ることができます。つまり、フルバッファリングの速度でラインをターミナルに迅速に配信することです。
例えば、Info-ZIPのunzipは、アーカイブからファイルを展開する際にファイル名を出力します。この出力は、多数の小さなファイルを持つアーカイブの展開時間の大半を占めます。時々、オプションなしでunzipを実行すると、ターミナルがファイル名で溢れかえり、展開がボトルネックになっていることがわかります。そのため、待つよりも再開する方が速いため、-q(quiet)オプションを付けてキャンセルして再起動します。この経験から、ユーザーインターフェースを変更せずにtugzのunzipでこの問題を回避しました。リストは表示されますが、そのためのコストはかかりません。
$ unzip example.zip
Archive: example.zip
inflating: example.bmp
inflating: example.svg
inflating: example.txt
3行を出力するこのCプログラムを考えてみましょう。
#include <stdio.h>
int main()
{
puts("a\nb");
puts("c");
}
例えばstraceを使って書き込みを調べると、次のようになります。
$ strace -o log -e write ./a.out
a
b
c
次のような出力が見られるかもしれません(ここではglibcの場合):
write(1, "a\n", 2) = 2
write(1, "b\n", 2) = 2
write(1, "c\n", 2) = 2
はい、glibcはラインバッファリングを文字通り解釈し、文字列の書き込みさえ分割します。一部の実装はもう少し洗練されています。
write(1, "a\nb\n", 2) = 4
write(1, "c\n", 2) = 2
CPythonはこのプログラムに対して、まさにこれらの書き込みを行います。
import sys
sys.stdout.write("a\nb\n")
sys.stdout.write("c\n")
そのルールは、バッファに新しい出力が追加された後に改行が含まれている場合にフラッシュするというもので、部分的な行を出力する可能性があります。以前の例を基にすると、次のようになります。
enum {
BUF_ERR = 1 << 0,
BUF_LINE = 1 << 1,
};
typedef struct {
uint8_t *buf;
ptrdiff_t cap;
ptrdiff_t len;
int fd;
int flags;
} Buf;
bool os_write(int fd, void *, ptrdiff_t);
void flush(Buf *b)
{
if (!(b->flags & BUF_ERR) && b->len) {
if (!os_write(b->fd, b->buf, b->len)) {
b->flags |= BUF_ERR;
}
b->len = 0;
}
}
このようなラインバッファリングは、テストしてからフラッシュすることで実現できるかもしれません。
bool has_newline(Buf *b)
{
int r = 0;
for (ptrdiff_t i = 0; i < b->len; i++) {
r |= b->buf[i] == '\n';
}
return r;
}
void append(Buf *b, uint8_t *src, ptrdiff_t len)
{
uint8_t *end = src + len;
while (!(b->flags & BUF_ERR) && src<end) {
ptrdiff_t left = end - src;
ptrdiff_t avail = b->cap - b->len;
ptrdiff_t amount = avail<left ? avail : left;
memcpy(b->buf+b->len, src, amount);
b->len += amount;
src += amount;
if (amount < left) {
flush(b); // make room
}
}
// test-then-flush if line buffered
if ((b->flags & BUF_LINE) && has_newline(b)) {
flush(b);
}
}
もし大量の行を出力する必要があり、それらをバッファに次々と追加する場合、それは1回の書き込みで済んだはずの多くの書き込みが発生します。
全てのケーキを食べる
目標を考えてみましょう。出力がターミナルである場合、行を無期限に保持したくありません。これは、改行ごとに書き込むこととは異なります。大量の小さな書き込みを行う基盤となる、バッファを意識しない呼び出し元がいる問題に対する解決策はすでに存在します。それはNagle's algorithmです。
つまり、バッファが短い時間アイドル状態であればフラッシュするということです。実際には改行に特別な関心はありません。ただし、インターフェースの調整が必要です。バッファにデッドラインを追加します。
typedef struct {
int64_t deadline;
// ...
} Buf;
int64_t os_monotonic_clock();
void deadline_flush(Buf *b)
{
if ((b->flags & BUF_LINE) && b->len) {
int64_t now = os_monotonic_clock();
if (b->deadline && now>b->deadline) {
flush(b);
b->deadline = 0;
} else {
b->deadline = now + DEADLINE;
}
}
}
ここでDEADLINEは、100ミリ秒程度など、人間の知覚に適した値です。スレッドでこれを並行して実行し、バッファにデータが書き込まれたときにfutexで起動させることができます。しかし、短い作業の合間にそれを実行する方が良いでしょう。それがtugzの動作方法です。例えば(私のstringsライブラリを使って):
for (/* filenames in filenames */) {
Str pre = S(" inflating: ");
Str suf = S("\n");
append(&stdout, pre.data, pre.len);
append(&stdout, filename.data, filename.len);
append(&stdout, suf.data, suf.len);
while (/* not done inflating */) {
// 1. inflate block
// 2. write block
deadline_flush(&stdout);
}
}
デッドライン内に複数のファイルを処理する場合、それらの名前はまとめて書き出されます。スレッドは不要です。従来の改行ごとのフラッシュと比較した場合の動作の違いは、プログラムが高速に実行されるという事実を除けば、人間には知覚できません。もう一つ、くだらないC stdioを気にしない理由です。