CUBE SUGAR CONTAINER

技術系のこと書きます。

head コマンドの不思議な挙動と標準入出力のバッファリング

今回は Unix における標準入出力のバッファリングモードについて扱う。 普段あまり意識していなかったけど head コマンドの挙動をきっかけに気になって調べていったらなかなか面白かった。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G1036
$ python --version
Python 3.6.3

head コマンド

まず、head コマンドというのは標準出力の中から先頭にある行だけを取り出すのに使われるユーティリティのこと。 例えば、次のようにして 5 行ある出力の中から先頭 3 行だけを取り出す、みたいな用途で使う。

$ for i in 1 2 3 4 5; do echo $i; done | head -n 3
1
2
3

左辺にあるコマンドの標準出力が、パイプを通して head コマンドの標準入力につながれる。 head コマンドは標準入力から指定された行数だけを読み出して、それを標準出力に書き出す。

長大な出力を head したときの不思議な挙動

調べ始めたきっかけは、長大な出力を出す自作スクリプトから head コマンドで先頭だけを取り出そうとしたときの挙動だった。

例えば、Python で次のようなサンプルコードを用意する。 このコードは、整数の連番を 1 秒ごとに延々と標準出力に書き出すというものになっている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import itertools
import time


def main():
    # 整数の連番を返すイテレータ
    c = itertools.count()
    for i in c:
        # イテレータから得られた数値を標準出力に書き出す
        print(i)
        # 書いたら 1 秒待つ
        time.sleep(1)


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。 整数の連番が 1 秒ごとに、ずっと出力され続けるはずだ。

$ python count.py 
0
1
2
3
4
5
6
7
8
9
...(省略)

では、次に上記の出力から head コマンドを使って先頭行を取り出してみよう。

上記の出力に対して head コマンドを使ったとき、どういった結果を期待するだろうか? おそらく、すぐに 0 が出力されて端末の制御が戻ってくることを期待するはず。 しかし、残念ながら実際はそうならない。 なんと、コマンドがずっと実行されっぱなしで返ってこなくなる。

$ python count.py | head -n 1
...(戻ってこない)

どうして、こんな挙動になるのだろうか?

結論から先に言ってしまうと、これには標準入出力のバッファリングが関わっている。 具体的には、直接端末に書き出すときとパイプを使って別のコマンド (今回であれば head) に標準出力をつなぐときでは、標準出力のバッファリングモードが変わる。

直接端末に書き出すときには「行単位バッファリング」だったのが、パイプを使うと「完全バッファリング」になる。 行単位バッファリングでは改行コードのタイミングでバッファがフラッシュされる。 しかし、完全バッファリングではバッファが一杯になるまでフラッシュされない。 つまり、今回のケースでは count.py の出力バッファが一杯になるまで head コマンドにデータが流れてこないのだ。 そのため、いつまでも結果が出力されず端末に制御が戻ってこなかった。

標準入出力のバッファリングとは

ここからは、より詳しく標準入出力のバッファリングについて見ていくことにしよう。 まず、そもそも標準入出力のバッファリングとは何だろうか? それには、まず前提として「アンバッファド入出力」について理解する必要がある。

アンバッファド入出力というのは read(2) や write(2) といった、入出力を司るシステムコールのことを指す。 システムコールというのは OS のカーネルがユーザ空間に提供している API のこと。 何らかの入出力をしたいとき、最終的にはこのアンバッファド入出力のシステムコールを呼び出すことになる。 その結果として、端末に文字を書き出したりファイルの内容を読み出したりできるというわけ。 そして、アンバッファド入出力はその名の通りバッファリングされていない。 つまり、呼び出されたら直後に入出力が実際に実行されることになる。

しかし、標準入出力を扱う上でユーザ空間のプログラムがアンバッファド入出力のシステムコールを直接使うことはあまりない。 なぜかというと、入出力の内容をバッファリングした方が効率が良いから。 アンバッファド入出力の呼び出し回数は、なるべく少ない方が計算機資源の節約になる。 そのため、一旦読み書きする内容をメモリにバッファしておいて、適切なタイミングでアンバッファド入出力を呼び出すことになる。 この作業をする存在のことを「標準入出力ライブラリ」と呼ぶ。

標準入出力ライブラリのバッファリングモード

標準入出力ライブラリはそれぞれのプログラミング言語ごとに用意されている。 例えば C 言語の printf() 関数や scanf() 関数がそれに当たるし、Python であれば print() 関数や input() 関数になる。 ただし、実装が異なっていてもバッファリングに関しては基本的に以下の 3 つのモードが用意されているはず。

  • 完全バッファリング
    • バッファが一杯になるまでシステムコールの呼び出しがされない
  • 行単位バッファリング
    • 改行コードが出現するタイミングでシステムコールが呼び出される
  • アンバッファド
    • バッファリングせずにシステムコールが呼び出される

大抵の場合は、デフォルトで上記が状況に応じて自動的に選ばれる。 例えば Python では、その選択方法について open() 関数の buffering オプションのところで説明されている。

2. 組み込み関数 — Python 3.6.3 ドキュメント

どうやら、端末につながっているものについては行単位バッファリングに、それ以外は完全バッファリングになるようだ。 そして、端末につながっているかどうかはファイルオブジェクトの isatty() メソッドで確認できる。

バッファリングモードが変わっていることを確認する

ここまでで、コマンドの入出力が端末につながっているか否かで、そのバッファリングモードが変わることが分かった。 そして、端末につながっているかどうかの判断方法はファイルオブジェクトの isatty() メソッドで確認できるらしい。

なので、次はコマンドをそのまま実行するときとパイプをつないだときで isatty() メソッドの結果が変わることを確認してみよう。 標準入出力のファイルオブジェクトは sys モジュールから取得できるので、その内容を確認すれば良い。 ただしパイプをつなぐので書き出す先は標準エラー出力にする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import print_function

import sys


def main():
    # 標準出力が端末につながっているかを調べる
    print(sys.stdout.isatty(), file=sys.stderr)


if __name__ == '__main__':
    main()

上記を保存して、まずはそのまま実行してみよう。

$ python stdouttty.py 
True

標準出力の isatty()True となることが分かった。 つまり、この状態では行単位バッファリングが使われている。

では、続いてパイプをつないでみよう。

$ python stdouttty.py | head
False

今度は False になった。 つまり、完全バッファリングになっている。 この状態ではバッファが一杯になるまで後続のコマンドにはデータが流れない。

システムコールから確認してみる

もう一つ、システムコールからも挙動の違いを確認してみよう。 直接端末に書き出すときと、パイプにつないだときはアンバッファド入出力のシステムコールの呼び出され方が変わるはず。

それにはまず、最初の連番を出すサンプルコードに手を加えて最初にプロセス番号を出力するようにしておこう。 先ほどと同じように、書き出す先は標準エラー出力にする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import print_function

import itertools
import os
import sys
import time


def main():
    # 自信のプロセス識別番号を標準エラー出力に書き出す
    print(os.getpid(), file=sys.stderr)

    c = itertools.count()
    for i in c:
        print(i)
        time.sleep(1)


if __name__ == '__main__':
    main()

まずは、そのまま実行しよう。 今回はプロセス番号 16750 で起動したことが分かる。

$ python pid.py 
16750
0
1
2
3
4
5
...

macOS であれば dtruss コマンドでプロセスが発行するシステムコールを追跡できる。 すると、定期的に write() システムコールが呼び出されていることが分かる。

$ sudo dtruss -p 16750
SYSCALL(args)        = return
write(0x1, "53\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
write(0x1, "54\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
write(0x1, "55\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
write(0x1, "56\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
...(省略)

行単位でアンバッファド入出力のシステムコールが呼ばれていることが分かった。

続いてはパイプをつないで実行しよう。 今度はプロセス番号 17027 で起動した。

$ python pid.py | head
17027
...

さっきと同じように dtruss コマンドでプロセスのシステムコールを追跡する。 すると、今度は write() システムコールが発行されていないことが分かる。

$ sudo dtruss -p 17027
SYSCALL(args)        = return
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
...(省略)

つまり、アンバッファド入出力のシステムコールは呼ばれていない。 内容はずっとバッファリングされてしまっているようだ。

バッファリングモードを指定する

ここまでで、パイプを使ったときそのままでは標準出力が完全バッファリングモードになってしまうことが分かった。 これではバッファが一杯になるまで後続にデータが流れず head コマンドの実行に影響を与えてしまう。

対応方法はいくつか考えられる。 まず、最も簡単なのは Python のインタプリタを -u オプションをつけて起動すること。 そうすると標準出力と標準エラー出力がアンバッファドモードになる。 これは python コマンドの help から確認できる。

$ python --help | grep -A 2 unbuffered
-u     : unbuffered binary stdout and stderr, stdin always buffered;
         also PYTHONUNBUFFERED=x
         see man page for details on internal buffering relating to '-u'

実際に -u オプションを付けて実行してみよう。 すると、すぐに実行結果が得られることが分かる。

$ python -u count.py | head -n 1
0
Traceback (most recent call last):
  File "count.py", line 19, in <module>
    main()
  File "count.py", line 13, in main
    print(i)
BrokenPipeError: [Errno 32] Broken pipe

ただし、Python の BrokenPipeError 例外も同時に上がってしまっているようだ。 この例外は、head コマンドによって標準出力が途中で閉じられたことに起因している。

対応方法としては、プログラム側で対処しない方法であれば標準エラー出力の内容を捨ててやれば良い。 ただし、これはお行儀の悪いやり方。

$ python -u count.py 2>/dev/null | head -n 1
0

ハンドリングされない例外が残っているのはバグなので、本来はプログラム側で対処すべき問題。 次のように IOError を拾って errno の数値で Broken pipe を検出すると良い。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import itertools
import time
import sys


def main():
    # 整数の連番を返すイテレータ
    c = itertools.count()
    for i in c:
        # イテレータから得られた数値を標準出力に書き出す
        print(i)
        # 書いたら 1 秒待つ
        time.sleep(1)


if __name__ == '__main__':
    try:
        main()
    except IOError as e:
        # SIGPIPE
        if e.errno == 32:
            # Broken pipe
            sys.exit(1)
        else:
            raise

ちなみに BrokenPipeError を直接使ってしまうと Python 2 にはない例外なのでバージョン互換性を失ってしまう。

今度は例外にならずに実行できる。

$ python -u count.py | head -n 1
0

もう一つの対応方法としては、手動で標準出力のバッファリングモードを指定し直すことが考えられる。 具体的には、標準出力を改めて行単位バッファリングモードで開き直せば良い。

次のサンプルコードでは、標準出力を行単位バッファリングモードで開き直している。 その他の点については、最初に示した整数を連番で書き出すものと変わっていない。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import print_function

import itertools
import time
import sys


def main():
    stdout_fd = sys.stdout.fileno()
    linebuf_stdout = open(stdout_fd, mode='w', buffering=1)

    c = itertools.count()
    for i in c:
        print(i, file=linebuf_stdout)
        time.sleep(1)


if __name__ == '__main__':
    try:
        main()
    except IOError as e:
        # SIGPIPE は IOError になるので対処する
        if e.errno == 32:
            # Broken pipe
            sys.exit(1)
        else:
            raise

これも問題なく実行できる。

$ python linebuf.py | head -n 1
0

最後のやり方は、標準出力に一行書くごとにバッファをフラッシュしてやるというもの。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import itertools
import time
import sys


def main():
    # 整数の連番を返すイテレータ
    c = itertools.count()
    for i in c:
        # イテレータから得られた数値を標準出力に書き出す
        print(i)
        # 標準出力のバッファをフラッシュする
        sys.stdout.flush()
        # 書いたら 1 秒待つ
        time.sleep(1)


if __name__ == '__main__':
    try:
        main()
    except IOError as e:
        # SIGPIPE
        if e.errno == 32:
            # Broken pipe
            sys.exit(1)
        else:
            raise

これも上手くいく。 ただし、このやり方だと例外を拾っても次のようなエラー出力が出てしまう。

$ python count.py | head -n 1
0
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='UTF-8'>
BrokenPipeError: [Errno 32] Broken pipe

結局標準エラー出力を捨てることになるので、ちょっとイマイチな感じ。

$ python count.py 2>/dev/null | head -n 1
0

まとめ

今回は Unix における標準入出力のバッファリングについて調べた。 まず、入出力を司る read(2) や write(2) といったシステムコールは「アンバッファド入出力」といってバッファリングがされない。 それらを標準入出力で使うとき、効率的に呼び出すために内容をメモリにバッファリングする標準入出力ライブラリがある。 標準入出力ライブラリのバッファリングには「完全バッファリング」「行単位バッファリング」「アンバッファド」の 3 つがある。 標準入出力ライブラリの実装としては、パイプを使うと完全バッファリングモードになるものが多い。 そのため、head コマンドと組み合わせて使うと結果がなかなか返ってこないといったことが起こりうる。 これを防ぐには手動で標準出力を行単位バッファリングモードに指定するか、あるいは行単位でバッファをフラッシュすると良い。

ちなみに、ここらへんの話は「詳解UNIXプログラミング」に詳しく書かれている。 ちょっと高いけど、この本は Unix でプログラミングをするなら必ず読んだ方が良い、というくらいの名著だと思う。

詳解UNIXプログラミング 第3版

詳解UNIXプログラミング 第3版