2 ポイント 投稿者 GN⁺ 2024-11-30 | 1件のコメント | WhatsAppで共有
  • tail -f /some/log/file | grep thing1 | grep thing2 のように、ゆっくり流れてくる出力を複数のコマンドでつなぐパイプラインは、実際には止まっているのではなく、中間のコマンドが 出力をバッファにため込む ため、空に見えることがある
  • grep と多くのプログラムは、stdout が端末かどうかを isatty で確認し、端末なら 行バッファリング、パイプやファイルならおよそ 8KB 単位のブロックバッファリング を使う
  • tailcattee は出力バッファリングをしない例だが、grep --line-bufferedsed -utcpdump -ljq -utr -u のように、バッファリング緩和オプションはコマンドごとに異なる
  • Ctrl-C でパイプラインを中断すると、tcpdump のようなプログラムのバッファにあった出力が失われることがあり、kill -TERM $PID で終了させるとバッファがフラッシュされて出力が見えることがある
  • 実務上の解決策は、すぐ終わるコマンドに置き換える、grep --line-buffered、単一の awk や複雑な grepstdbufunbuffer などだが、それぞれ 動作条件と副作用 を確認する必要がある

パイプが止まったように見える理由

  • ログファイルに行がゆっくり追加されるとき、次のパイプラインは一致する結果があっても出力が見えないことがある
    • tail -f /some/log/file | grep thing1 | grep thing2
  • 原因はパイプ自体ではなく、中間の grep thing1 が結果をすぐに書き出さず、バッファに保存 するため
  • プログラムが毎回すぐに書き出すとシステムコールが増えるので、性能のためにある程度データをためてからパイプやファイルに書く
  • この例では、grep thing1 はおよそ 8KB ほどの出力がたまるまで待つことがあり、遅いログではその条件が事実上いつまでも満たされないことがある

端末とパイプで変わる出力方式

  • tail -f file | grep thing はうまく動くが、その後ろに 2 つ目の grep を付けると出力が止まったように見えることがある
  • grep と多くのプログラムは、stdout が端末かどうかを isatty 関数で確認する
    • stdout が 端末 なら行バッファリングを使い、行単位で即座に出力する
    • stdout が パイプやファイル ならブロックバッファリングを使い、一定サイズ以上のデータがたまったときに出力する
  • そのため、grep が端末へ直接書く場合は行がすぐ見えるが、次のコマンドへ続くパイプに書く場合は見えないことがある
  • バッファサイズはプログラムごとに異なる
    • grep では libc がバッファリングを処理し、libc のサイズは BUFSIZ 変数で定義されている
    • glibc の定義位置は stdio.h にある
  • 端末に書くときに 8KB の出力バッファを使わないのは物理法則ではなく、プログラムが望めばそう実装できるが、かなり不自然な挙動に近い

コマンドごとに異なるバッファリング動作

  • 出力バッファリングが厄介なのは、どのコマンドがパイプ出力でバッファリングするかを利用者が覚えておく必要があるから
  • 出力バッファリングをしないコマンドの例は次のとおり
    • tail
    • cat
    • tee
  • パイプに書くときに出力バッファリングをする一般的なコマンドと、その緩和方法は次のとおり
    • grep: --line-buffered
    • sed: -u
    • awk: fflush() 関数
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: バッファリング無効化不可
  • sort のように入力をすべて受け取ってからでないと処理できないコマンドでは、バッファリングの有無は実質的に重要ではない
  • Mac OS と GNU 版の両方をテストしようとしたが、バリエーションが多く、いくつか誤りがあるかもしれない

プログラミング言語の標準出力もバッファリングする

  • 一部のプログラミング言語のデフォルトの print 出力も、パイプに書くときはバッファリングする
  • 言語ごとの無効化方法は次のとおり
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • こうしたデフォルト動作は、バッチ処理で標準出力関数を高速にするための設計と思われる
  • 出力方法によってバッファリングの有無が変わることがある
    • C++ で cout << "hello\n" は、パイプに書くときバッファリングする
    • cout << "hello" << endl は出力をフラッシュする

Ctrl-C とファイルリダイレクトで生じる違い

  • 次のように tcpdump の出力を grep に接続し、-l を付け忘れると、出力がバッファに残ることがある
    • sudo tcpdump -ni any port 53 | grep example.com
  • 理想的には、Ctrl-C を押したときに tcpdump がバッファをフラッシュし、grep が検索して取りこぼしていた出力が見えることを期待したくなる
  • 実際には、プログラムが終了する際に tcpdump のバッファにあった出力は 失われる
  • strace で確認すると、greptcpdump より先に SIGINT を受け取るため、tcpdump がフラッシュしようとしても grep はすでに終了していることがある
  • 回避策として、tcpdump の PID を見つけて kill -TERM $PID を実行すると、tcpdump がバッファをフラッシュして出力が見えることがある
  • ファイルリダイレクトもバッファリングする
    • sudo tcpdump -ni any port 53 > output.txt
  • ただしファイルリダイレクトは、Ctrl-C がバッファ内容を完全に失わせる問題とは異なり、経験上はプログラム終了前にバッファ内容がファイルへ書き込まれることが多い
  • この動作を常に信頼できるかどうかははっきりしない

バッファリングを避ける 5 つの方法

  • すぐ終わるプログラムに置き換える

    • ゆっくりパイプへ書き込む状況そのものを避けて、すぐ終わるコマンドに置き換えられることがある
    • 例は次のとおり
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • 元の tail -f コマンドと同じ動作ではないが、複雑なバッファリング問題を避けられる
  • grep の行バッファオプションを使う

    • grep にはバッファリングを避けるためのフラグがある
    • 例は次のとおり
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • awk やより複雑な grep にまとめる

    • 複数の grep を使う状況は、単一の awk に置き換えられる
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • または、より複雑な正規表現の grep にできる
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • awk もバッファリングするので、この方法が機能するには awk がパイプラインの 最後のコマンド である必要がある
  • stdbuf を使う

    • stdbufLD_PRELOAD を使って libc のバッファリングを無効にする
    • 出力バッファリングを無効にする例は次のとおり
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • LD_PRELOAD ベースの解決策と同様に、信頼性には限界がある
      • 静的バイナリでは動作しない
      • プログラムが libc のバッファリングを使っていなければ動作しないことがある
      • Mac OS では常に動作するとは限らない
    • 関連説明として Harry Marr の How stdbuf works がある
  • unbuffer を使う

    • unbuffer program は、プログラム出力が TTY であるかのように強制し、通常の TTY と同様にバッファリングを減らし、色付き出力なども有効にする
    • 例は次のとおり
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • stdbuf と違って常に動作するが、望ましくない副作用が出ることがある
      • たとえば grep thing1 が一致結果に色を付けることがある
    • unbufferexpect パッケージに含まれている

主に問題が表面化する状況と環境変数のアイデア

  • この問題は、データをパイプにゆっくり流し込むプログラムで主に起こる
  • 例は次のとおり
    • tcpdump
    • tail -f
    • kubectl logs のようなログ監視
    • 遅い計算の出力
  • Python の PYTHONUNBUFFERED のように、バッファリングを無効にする標準の環境変数があるとよいかもしれない
  • Mark Dominus の 2018 年の ブログ記事続編 からこのアイデアを得た
  • 名前の例としては NO_COLOR のように NO_BUFFER が考えられる
  • 設計は難しい
    • NETBSD には、多くの制御を提供する STDBUF, STDBUF1 などの環境変数 がある
    • ほとんどの開発者は、比較的小さなエッジケースのために複数の環境変数を実装したいとは思わないかもしれない
  • 出力バッファを 1 秒ごとなどの周期で自動フラッシュするプログラムがあるかも気になるが、そのようなプログラムは思い当たらず、欠点もありそうだ

扱っていない範囲

  • 行バッファリングと完全な無バッファ出力の違いは除外している
  • stderr のバッファリングと stdout のバッファリングの違いは除外している
  • ここで扱っているのは、プログラム内部で発生する バッファリング のみ
  • OS の TTY ドライバも、ときどき多少のバッファリングを行う
  • パイプに書く場面以外で出力をフラッシュする必要がある他の理由は除外している

1件のコメント

 
GN⁺ 2024-11-30
Hacker Newsのコメント
  • バッファリングされたアプローチは、ほとんど常にバイト数のしきい値に達したとき、または少なくとも1バイトでもあれば一定時間後にフラッシュする「しきい値またはタイムアウト」方式であるべき
    似た問題を解くために、ハードウェアインターフェースではよく使われる方式
    この場合、ユーザー空間でバッファリングするライブラリは、データを初めてバッファに入れるときに適切なタイマーを設定する必要がある。タイムアウト値は引数で受け取るか、人間が感じるには短い1〜100ms程度にするか、{帯域幅 / しきい値}に比例させるか、システムコールのオーバーヘッドが全体時間の0.1%を超えないようにする、といった設定が考えられる
    この方式は書き込みだけでなく読み取りにも適用される。まとめ読みや結合読み取りをするなら同様の方式が必要だが、効率的に「待機中のデータ」を問い合わせたり通知を受けたりする方法がデータチャネル側に必要になるため、チャネル設計により左右される。ハードウェアでは割り込みの結合のような方式が一般的

    • 方向性としては正しいと思うが、libcが自動タイマーを設定すると期待される動作が変わり、厄介な問題が多く生じる
      入出力エラーが書き込み時点だけでなく、いつでも発生し得るし、プログラムが自前でタイマーを置いた場所やシグナルが届いた場所だけでなく、さまざまなシステムコールがタイマーによって中断され得る
      アプリケーションとlibcの両方がタイマーを設定すると混乱が生じる可能性もある。最近のカーネルタイマーAPIは昔の記憶より良く見えるので、関係は薄いかもしれないが、アプリケーションが重要な区間でシグナルを一時的にブロックすると、入出力タイマーにも影響する
      シグナル処理のタイミングと方法のため、入出力構造体へのアクセスにはより注意が必要
    • そうしたタイムアウトを透過的に処理するのは、POSIXとISO Cの制約下では難しそう。アプリケーション層の協力がある程度必要に見える
    • 一般的なLinuxのアラームはシグナルベースなので管理が非常に難しく、再スケジュールするにはカーネルに入る必要があるため、性能への影響があり得る
      io_uringとユーザー空間タイマーを使えばずっとスケールしやすいが、高速な小さな書き込みを多数サポートするには、なお小技が必要。たとえば毎秒約100万回を超えるとタイマー管理コストが次第に見え始め、毎秒1億回の書き込みまで行くにはかなり変わった手法が必要だった
    • 同意しにくい。ここでのバッファリングは本来やるべきことをしている
      問題の原因は、インタラクティブであるべきものと、インタラクションを前提にしていない契約が混ざっていることにある。たとえばtailの追跡出力をパイプに送る場合
      解くべき実際の問題はないと思う。ハードウェアでたとえるなら、雨水をためる水槽で、満杯になったときだけ移す構造。どの例を想定しているのか分からないが、私の知る限りハードウェアで時間ベースのフラッシュが一般的ということはない
      提案された修正は契約をずっと複雑にする
    • 予測可能なfootgunのほうがましだと思う。アイデアは良いが別フラグにすべきで、そうするとその存在を知っている必要がある
      問題はセマンティクスそのものよりも、セマンティクスを知らないことにある
  • NIX系システムを20年以上扱ってきて、こういうことが起きるのは知っているのに、出力がなぜ出ないのかしばらく悩んでからでないと毎回思い出せない

  • 「最近の記事はかなり長くなっていて、バッファリングについての3000語の記事を本当に読みたい人がいるのか」という部分については、個人的には読みたい

    • 記事による
      冗長な記事が検索最適化のための水増しである場合もあると思う
    • こういう部分ではAI要約がかなり役に立つと思う。記事を要約させてから確認すればよい
      TLDRとNTLDR、つまり「長くても読んだ」セクションを置くような形もあり得る
  • システム全体のCPUがアイドル状態になるたびに、すべてのバッファがフラッシュされるとよいのに
    バッファリングはおおむねCPUを節約するための手法。CPUが無限にあるなら、すべてのバッファは1バイトになるはず。バッファは効率のためにデータを集めて一括処理する方式
    だがCPUがアイドル状態になったら、「あとでやること」が残っていてはいけない。カーネルスケジューラはアイドル状態になった瞬間、すべてのプロセスにバッファをフラッシュせよというシグナルを送るべき

    • 面白いアイデア。ただし全プロセスにシグナルを送るのは非常に高くつきそう
      その作業を全部やったうえで、またバッファフラッシュのためにシステムコールをさせることになる。カーネルがユーザー空間バッファを認識できるようにして、アイドル時にそこから直接取り出す仕組みを追加できるかもしれない
      これはある程度io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff... のようなものなのかもしれない
    • すばらしいアイデアだが、一度に全部やらないほうがよさそう。それに低電力システムでは、推測的に眠っているプロセスを起こして効率を落とす可能性もある
  • この記事は無バッファ行バッファリングという異なる2つを混同している
    無バッファは不要に性能を悪化させ、複数のソースが同じパイプに書き込む場合には誤った出力を作る可能性がある。十分に長い行はいずれにせよ混ざるが、現実の大半の出力行は、書式・制御文字や補助平面の文字を含めても4096バイトより短い
    行バッファリングは端末のデフォルトであり、パイプでも通常望まれる動作。各コマンドをstdbuf -oL -eLの下で実行すればよい。行内更新を望むまれなプログラムは、すでに手動フラッシュを行う必要があるため、ここでも正しく動作する
    stdbufが実際にしていることは、次のように確認できる:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • 以前この問題について記事を書いたことがあります: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    バッファリングしないコマンドについては実装依存だったり、cat の場合は誤っている可能性もあります。https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c...-u を参照してください。POSIX がこれを管理する公式な方法を用意していないのは大きな苦痛です。
    言及されていないものとして 入力バッファリング もあり、次のような奇妙な結果を生みます:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    この場合の解決策は stdbuf -i0 head -1 を使うことです

    • パイプや socketpair のような場所から読み込むプロセスが、書き込むプロセスにそのような制約を強制できるとは思いません。ptrace() のような重いハックを使う場合は別ですが。
      パイプのバッファサイズを調整することは可能かもしれませんが、標準 C 入出力がそれに従うべきという慣例は知りません。
      いずれにせよ、この場合 stdbuf は役に立たないようです:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • バッファが存在するのには正当な理由があります。画面に出力を表示するのは、バッファに書き込むのに比べて相対的に非常に遅いからです。
    文字を1つずつ出力するのは非常に非効率です。
    古くからある問題で、UART を扱うときによく遭遇します。考えられる解決策はいくつもあります。改行のような特殊文字で出力の終わりを示す行ベース方式、8KB のような長さに達するまで待つ長さベース方式、X ミリ秒ごとに出力する時間ベース方式です。
    それぞれの方式には長所と短所があり、何が最適かはアプリケーションによって変わります。記事で一部のプログラムがバッファリングを使っていないとしている箇所は誤りだと思います。それらのプログラムは明示的な 長さベース方式 を使っていないだけです。

    • インターフェースより1~2層上で制約を把握している方式が可能な場合に最もうまく機能します。
      行ベースのアプローチはその一例ですが、どの文字を使うかについての合意が必要です。通常は改行です。
    • 実際の書き込みをバックエンドで処理するコストだけの問題ではありません。/dev/null に対してそれほど多くの システムコール を行うだけでも、性能が大きく落ちる可能性があります。
  • Unix を35年以上使ってきましたが、これがどう動作するのかを完全に理解したことはありませんでした。
    複数のシステムやコンポーネントにまたがるバッファリングの挙動を全体として説明してくれてよかったですし、確かに学ぶことがありました。

  • 「パイプで Ctrl-C を押すとバッファの内容が消える」という部分については、ほとんどのプログラムは SIGINT でバッファをフラッシュするのではないかと思います。
    ただしシェルでそう動作するには、パイプラインの最初のプログラムにだけ SIGINT を渡す必要があるはずですが、おそらく実際の挙動はそうではなさそうです。

    • 最後のプロセスが sigint を受け取り、残りは sigpipe を受け取ると記憶しています