57
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

シェル芸Advent Calendar 2023

Day 6

パイプに関係するさまざまなバッファ、ちゃんと意識していますか?

Last updated at Posted at 2023-12-06

はじめに

コマンドをパイプでつなげた時、各コマンドの間にはいくつかのバッファが存在します。そのバッファについてちゃんと意識しているでしょうか? バッファの存在によって各コマンドの実行には分かりづらい変化があります。そのバッファを知らないと罠にハマってしまう・・・かもしれません。

プロセス間のパイプ通信のバッファ

まずプロセス間のパイプ通信に存在しているバッファです。多くのコマンドは行単位でデータを処理しますが、一般的にパイプでつなげた各コマンドはそれぞれ処理速度が異なります。処理がすぐに終わるコマンドもあれば時間がかかるコマンドもあります。各コマンドは並列で動作可能ですが必ずしも並列で動作するわけではありません。

コマンド1~3はパイプ「|」でつながっている
コマンド1 | コマンド2 | コマンド3
   ↑         ↑         ↑
  それぞれのコマンドは並列で動作可能
 (必ずしも動作するという意味ではない)

一般論としてパイプライン全体の処理にかかる実時間はパイプでつながったコマンドの中で一番遅いコマンドに足を引っ張られます。いくら並列で動作可能と言ってもデータが到着しなければ処理するデータが存在しないので動くことはありません。例えば 8 コアの CPU を使っているとして8個のコマンドや更に多くのコマンドをパイプでつなげても、一番遅いコマンド以外は全力で走れないわけで、それだけでは CPU を使い切ることはできないということです。逆にコマンドを多くつなぎすぎるとパイプ間の通信によって実時間 (real) は一番遅いコマンドに足を引っ張られて変わらないのに 総 CPU 時間(ユーザー時間+システム時間)だけが増えて、CPU の無駄使い(速くならないのにプロセス間の通信コストで CPU コアを無駄に使っている)になることもあります。CPU の無駄使いは消費電力を増やしバッテリーの消費の原因になります。

さて、速さの違うコマンドが複数あったとして後のコマンドが十分な速さでデータを処理できないとします。前のコマンドは速いため次から次へとデータを送信します。前のコマンドがデータを送信して次のコマンドがデータを処理できない場合、送信されたデータはどこに保存されているのでしょうか? その答えがプロセス間のパイプ通信のバッファです。

コマンド1 ─[バッファ]→ コマンド2 ─[バッファ]→ コマンド3
            ↑                  ↑
     バッファ: データ通信をスムーズに行うためのメモリ領域

このバッファのサイズは OS によって異なります。次のようなプログラムでそのサイズを調べることができます。出力側はバッファにデータが溜まるまで1バイトずつ出力を行い、バッファがいっぱいになるとデータを出力できずに停止します。

# sleep 100 でパイプの次のコマンドはデータを処理せずに待っている
# パイプの前のコマンドは1バイトずつデータを次のコマンドに送信ししている
# 送信したサイズは標準エラー出力で画面に出力している
(i=0; while i=$((i + 1)); do printf .; printf "\r$i">&2; done) | sleep 100

バッファのサイズは次のとおりです。このサイズは OS のバージョンなどの要素によって変わる可能性はあるでしょう。

OS バッファサイズ
Ubuntu 22.04 (Linux) 65536 byte (64KB)
macOS 13 65536 byte (64KB)
FreeBSD 14 65536 byte (64KB)
DragonFly BSD 6.4 32768 byte (32KB)
NetBSD 10 16384 byte (16KB)
OpenBSD 7.4 16384 byte (16KB)
Solaris 11 16384 byte (16KB)

意外と小さいですよね? つまり前のコマンドがこのサイズを超えるぐらいのデータを送信し、次のコマンドがデータの処理に間に合わない場合、前のコマンドのデータ送信はブロック(バッファに空きができるまで待たされる)されてしまうということです。待ちが発生している間は当然 CPU は使われません。

アトミックにデータ書き込み可能な PIPE_BUF

一部な特殊な用途を除き、現在の OS はマルチタスクで動作します。そのため複数のプロセスが(ほぼ)同時に一つのファイルに書き込むといったことが可能です。複数のファイルが一つのファイルにデータを書き込んだ時、そのデータは正しく書き込まれるのでしょうか? 正しく書き込まれるというのは言い換えると他のプロセスからの書き込みに割り込まれずに書き込めるという意味で、データが分割されないことを「アトミック」と表現します。ここでは例としてファイルに書き込む話をしていますが、内部的にはパイプに関係した話です。

次の例は他のプロセスに割り込まれた状態だと、データがどう書き込めるかのイメージです。実際にはこのようなことにはならないので注意してください。

# 以下の二つのコマンドを同時に実行して書き込んだとする
$ echo "AAAAAAAAAA" >> /tmp/file.txt &
$ echo "BBBBBBBBBB" >> /tmp/file.txt &

# 同時に書き込んだせいで混ざってしまった状態(イメージです)
$ cat /tmp/file.txt
AAAAAABBBBBBBBBB
AAAA

実は OS には「同時に書き込んでも問題ないデータサイズ」というのが決まっています。それが PIPE_BUF です。

PIPE_BUF
POSIX.1-2001 では、 PIPE_BUF バイト以下の write(2) は atomic に行われること、つまりパイプへの出力データの書き込みは連続したシーケンスとして行われることを必須としている (MUST)。PIPE_BUF バイトより多くのデータを書き込み場合は atomic とはならない、 つまりパイプへの他のプロセスによるデータの書き込みが間に入る 可能性がある。 POSIX.1-2001 の仕様では、 PIPE_BUFは最小でも 512 バイトであることが要求されている (Linux では PIPE_BUF は 4096 バイトである)。 正確な動作は、ファイルディスクリプターが nonblocking (O_NONBLOCK) かどうか、パイプへの書き込みが複数から行われるかどうか、および 書き込みを行うバイト数 n により決定される。

PIPE_BUF の値は getconf コマンドで取得することができます。比較を兼ねて先程のプロセス間のバッファサイズの一覧に追加して書きます。

$ getconf PIPE_BUF /
4096
OS バッファサイズ PIPE_BUF
Ubuntu 22.04 (Linux) 65536 byte (64KB) 4096 byte (4KB)
macOS 13 65536 byte (64KB) 512 byte
FreeBSD 14 65536 byte (64KB) 512 byte
DragonFly BSD 6.4 32768 byte (32KB) 512 byte
NetBSD 10 16384 byte (16KB) 512 byte
OpenBSD 7.4 16384 byte (16KB) 512 byte
Solaris 11 16384 byte (16KB) 5120 byte (5KB)

なかなかに小さいですが、これよりも小さいサイズであればデータは分割されずにアトミックに書き込むことができます。POSIX では PIPE_BUF は少なくとも 512 バイトは保証するようにとなっており、どの環境でもアトミックにデータが書き込みできるといえるサイズは 512 バイトまでです。逆に言えばこれ以上のデータを一つのファイルに書き込もうとしたら、データが分割されて書き込まれることがあるということです。

さて検証なんですが、これが意外と難しくて PIPE_BUF を超えても分割されない場合があるんですよね。分割されないと保証しているサイズが PIPE_BUF なので矛盾はしていないのですが。次のようなコードで Ubuntu では PIPE_BUF を超えた4096バイトで分割されることを確認しましたが、macOSでは PIPE_BUF とは異なり512バイト以上を超えても分割されず、1024バイトを超えたところで分割されました。

Ubuntuの場合(以下を境目に分割された)
$ sh -c 'for i in $(seq 1000); do env printf "%04096d\n" & done>/tmp/buf; wait'
$ awk '{print length}' /tmp/buf | uniq
macOSの場合(以下を境目に分割された)
$ sh -c 'for i in $(seq 1000); do printf "%01024d\n" & done>/tmp/buf; wait'
$ awk '{print length}' /tmp/buf | uniq

標準入出力のバッファ

正確には標準入出力の話でありパイプだけに限らないのですがパイプに深い関わりがあるバッファです。前二つのバッファはカーネル内のバッファですが、標準入出力のバッファはプロセス内のバッファで C 言語ライブラリ(glibcなど)の標準ストリームによって提供されているバッファです。コマンドによってはこのバッファを使用しないため関係ない場合があります。例えば(GNU 版の)dd コマンドや cat コマンドは標準ストリームを使用しないので関係ありません。

三種類のバッファリングモード

標準入出力のバッファには次の三つがあります。

  • フルバッファリング(一定のサイズでバッファリングを行う)
  • ラインバッファリング(一定のサイズまたは行単位でバッファリングを行う)
  • バッファリングなし(バッファリングを行わない)

ちなみにバッファリングを行う理由はパフォーマンスを向上させるためです。パフォーマンスの観点からするとフルバッファリングが一番パフォーマンスが高いのですが、バッファリングを行うと都合が悪い場合もあります。

フルバッファリング

フルバッファリングはコマンドをパイプでつなげて別のコマンドに渡す場合やファイルに出力する場合に使われます。もっとも高いパフォーマンスを引き出すために使われます。

ラインバッファリング

ラインバッファリングは端末(画面)に出力するときに使われます。なぜフルバッファリングではダメかと言うとフルバッファリングはデータが溜まるまで出力を行わないからです。バッファのサイズはプログラムで調整することが可能ですが、おそらくデフォルトの 8KB (GNU コマンドの場合)になっているはずです。つまりフルバッファリングが使われているとあるコマンドが画面に一行出力してもバッファに貯まるまで出力が行われなくなってしまうということです。一般的に端末に出力している場合は一行ごとにデータを確認したいため、行単位で出力が行われるラインバッファリングでなければなりません。

バッファリングなし

バッファリングなしは標準エラー出力などで使われます。標準エラー出力では行とか関係なく、何か置きたときにすぐに出力されてほしいからです。その一方で標準入出力のパフォーマンスは悪くなってしまいます。

バッファリングによる動作の違い

通常はこれらのバッファリング処理を気にする必要はないかもしれませんが、パイプでコマンドを複数つなげたときに問題が発生する場合があります。ping コマンドを使って一歩ずつ解説しましょう。ping コマンドはあるサーバーへの接続状況を確認するコマンドで1秒おきに結果を出力します。

1秒ごとに出力される
$ ping example.com
PING example.com (93.184.216.34) 56(84) bytes of data.
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=52 time=119 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=52 time=119 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=52 time=119 ms
   ︙

ここで出力の一行目が邪魔に思ったとします。sed コマンドを使って削除してみましょう。これはなんの問題もなく削除できます。

1秒ごとに出力される
$ ping example.com | sed '1d'
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=52 time=120 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=52 time=120 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=52 time=119 ms
   ︙

さてここでもう一つ「: 」よりも後ろのデータだけ欲しいと考えたとします。前のデータは不要なので sed コマンドを使って削除してみましょう。そうするとなんとデータが出力されません。

データが出力されない?
$ ping example.com | sed '1d' | sed 's/.*: //g'
            ?

実際には1分程度でデータがまとめて出力されます。

1分程度でデータがまとめて出力される
$ ping example.com | sed '1d' | sed 's/.*: //g'
icmp_seq=1 ttl=52 time=119 ms
icmp_seq=2 ttl=52 time=120 ms
icmp_seq=3 ttl=52 time=119 ms
   ︙
icmp_seq=51 ttl=52 time=119 ms
icmp_seq=52 ttl=52 time=119 ms
icmp_seq=53 ttl=52 time=119 ms
icmp_seq=54 ttl=52 time=120 ms

これがラインバッファリングとフルバッファリングの違いです。

まず ping コマンドはおそらく常にラインバッファリングかバッファリングなしのようです。sed コマンドが一つの場合、その出力は端末なのでラインバッファリングが行われます。しかし sed コマンドの後に sed コマンドをつなげた場合、前の sed コマンドは出力先が端末ではなく別のコマンドになるためフルバッファリングが行われ、一定のサイズのデータが溜まるまで次の sed コマンドにデータを渡しません。そのため1分程度経ってデータが溜まった頃にようやく出力が行われるという理屈です。ちなみにバッファのサイズは glibc ではデフォルトで 8KB、他環境では 1KB などでサイズは変更が可能です。

同様の現象は例えば一定間隔で出力されているログファイルを、複数の grep コマンドでフィルタリングして見たいという場合にも発生します。

フルバッファリングさせない方法

通常はパフォーマンスが良いためフルバッファリングが好ましいのですが、先に示したように場合によってはフルバッファリングでは困ることがあります。

コマンドを一つにまとめる

先の ping コマンドの例では、パイプでコマンドを複数つなげるのではなく一つにまとめることで対処可能です。

sed コマンドは端末に出力するのでラインバッファリングが行われる
$ ping example.com | sed '1d; s/.*: //g'

対話シェルを使っているとよく複数のコマンドをつなげて書きがちですが、そうすると途中のコマンドがフルバッファリングになってしまいます。複数の sed は一つにまとめることが可能ですし、awksedgrep の機能を内包しているため awk 一つにまとめて書くことができます。

awk で実装した場合
$ ping example.com | gawk 'NR > 1 { sub(/.*: /, ""); print }'

注意: mawk は入力をバッファリングする珍しいコマンドで -W interactive が必要
$ ping example.com | mawk -W interactive 'NR > 1 { sub(/.*: /, ""); print }'

バッファリングモードを変更する

一つにまとめることができない場合、各コマンドが持っているかもしれないバッファリングモードを変更するオプションを使用します。これらは POSIX では標準化されていない機能ですが、GNU コマンド以外でも多くの実装がバッファリングモードの変更機能を持っているのでドキュメントをくわしく読んでみてください。

sed (GNU, FreeBSD, NetBSD, OpenBSD) は -u でバッファリングなしに変更できる
$ ping example.com | sed -u '1d' | sed 's/.*: //g'
sed (FreeBSD, NetBSD) は -l でラインバッファリングに変更できる
$ ping example.com | sed -l '1d' | sed 's/.*: //g'
grep (GNU, FreeBSD, NetBSD, OpenBSD)は --line-buffered でラインバッファリングに変更できる
$ ping example.com | grep --line-buffered -v '^PING' | sed 's/.*: //g'

またコマンドがプログラミング言語の場合、強制的にバッファの内容を出力(フラッシュ)する関数を実行することで同等の結果が得られます。例えば awk コマンドの場合、fflush() 関数を呼び出します。ちなみに fflush() 関数は POSIX Issue 8 で標準化されている関数です。

stdbuf コマンドを使う

別の方法として stdbuf コマンドを使う方法があります。これは C 言語ライブラリ(glibcなど)の機能を利用してデフォルトのバッファリングモードを変更するコマンドです。GNU 版だけでなく FreeBSD でも実装されていますが、(往々にして OS に依存する)C 言語ライブラリの機能なので少々汎用性に劣ります。例えば macOS でも GNU 版のstdbuf コマンドは Homebrew からインストールできるのですが、同じく Homebrew でインストールする GNU 版のコマンドにしか効果がなく、macOS 標準のコマンドに対しては効果がありません。

stdbuf コマンドを利用して出力をラインバッファリングに変更する
$ ping example.com | stdbuf -oL sed '1d' | sed 's/.*: //g'

stdbuf コマンドは次のようなオプションを持っています。(日本語 man stdbufからの引用)

-i, --input=MODE 標準入力のバッファ動作を変更する
-o, --output=MODE 標準出力のバッファ動作を変更する
-e, --error=MODE 標準エラー出力のバッファ動作を変更する

MODE の意味と注意点です。

MODE が 'L' の場合、対応するストリームは行単位でバッファリングされます。 このオプションは標準入力に対しては無効です。

MODE が '0' の場合、対応するストリームはバッファリングされなくなります。

それ以外の場合は MODE に数値を指定します。数値には次の倍数を指定することができます: KB 1000, K 1024,MB 10001000, M 10241024, その他 G, T, P, E, Z, Y など。 対応したストリームに MODE バイトのサイズが割り当てられたバッファが設定されます。

注意: COMMAND が標準ストリームのバッファリングを調整する場合 (例えば 'tee')、 'stdbuf' が変更した設定は上書きされます。また、いくつかのフィルタ ('dd' や 'cat' 等) は入出力にストリームを使用しないため、'stdbuf' 設定の影響を受けません。

unbuffer コマンドを使う

もう一つの方法として expect パッケージに含まれている unbuffer コマンドを使う方法があります。フルバッファリングが行われる理由は出力先が端末ではないからです。unbuffer コマンドを使うと出力先を端末(擬似端末)だと思わせることができるためこの方法でもうまくいきます。パイプで使う場合は -p オプションが必要です。

unbuffer コマンドを利用して出力先を端末に見せかける
$ ping example.com | unbuffer -p sed '1d' | sed 's/.*: //g'

ちなみに expect パッケージに含まれるメインのコマンドである expect コマンドは対話的なプログラムを自動操作するためのスクリプト言語の一種です。その仕組の一つとして擬似端末を使っており、unbuffer コマンドはそれを利用したおまけコマンドのようなものです。この方法は出力先を端末だと誤魔化しているので汎用性は高く、出力先の違いでバッファリングモードを変更しているコマンドであればどれでも適用可能です。ただしバッファリング以外にも影響を及ぼし、例えば ls コマンドのようにデフォルトではカラム表示になったり色がついたりすることがあります。

まとめ

この記事ではパイプに関する三つのバッファについて解説しました。

  • プロセス間のバッファ(16KBや32KBや64KBなど)
  • アトミックに書き込めるサイズ PIPE_BUF(512 byteまで保証。512Bや4KBや5KBなど)
  • 標準入出力のバッファ(バッファサイズは変更可能、デフォルトは8KBや1KBなど)
    • フルバッファリング、ラインバッファリング、バッファリングなし

通常はこれらのバッファについて意識しないかもしれませんが、パイプで複数のコマンドをつなげたり並列でプロセスを実行したりするときに影響があります。もし不可思議な事が起きた場合はバッファの存在が影響しているかもしれません。

57
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?