シェルとファイルデスクリプタのお話


Shell Script Advent Calendar 2016が始まりました。1日目はです。好きなシェルはBashです。

はじめに

ファイルデスクリプタを知ることで、よりスマートにシェルを記述でき、シェル芸の幅も広がります。この記事はシェルとファイルデスクリプタの理解を深めることを目的としており、対象は次のような人です。

  • パイプ・リダイレクトをうまく使えない人
  • 2>&1 の置き場所にいつも迷う人、そもそも2>&1が何って人?
  • シェルの作業で中間ファイルを作成している人

ファイルデスクリプタとは

ファイルデスクリプタ (FD) とは、プロセスが入出力するファイルやデバイスを扱うためのインターフェイスです。これはUNIX系OSのデバイスとファイルは透過的に扱えることと関係し、任意のファイルもデバイスも同じAPIで操作できます。

FDは扱うデバイス・ファイルごとに番号が振られ、プログラムはその番号でファイル・デバイスを識別します。標準の入出力デバイスにFD0、FD1、FD2が用意されており、それぞれ標準入力 (stdin)、標準出力 (stdout)、標準エラー出力 (stderr) です。プロセスが現在使用しているFDは/dev/fd/以下からアクセスでき、中身は実体となるデバイスやファイルへのシンボリックリンクです。

試しに stat を使って確認してみましょう。stat-c%Nをオプションにつけると、リンク先を見ることができます。このとき表示されるのは、stat プロセスが使用するFDです。

$ stat -c%N /dev/fd/*
'/dev/fd/0' -> '/dev/pts/1'    # stdin
'/dev/fd/1' -> '/dev/pts/1'    # stdout
'/dev/fd/2' -> '/dev/pts/1'    # stderr

statが使用するFD0、FD1、FD2が、/dev/pts/1を指しています。これはttyコマンドの結果と一致します。つまりstdin, stdout, stderrは使用している端末であることを意味し、statの出力 (stdout) は端末に書き込まれます。

リダイレクト

先程の例はFDは端末を指していました。FDが指す先を変更する機能が多くのシェルにも備わっており、おそらく読者はすでに知っています。その一つがリダイレクトです。次の例はFD0を<、FD1を 1> で、FD2を 2> で指定します。

$ stat -c%N /dev/fd/* </dev/zero 1>/tmp/output 2>/dev/null

おっとこのままでは結果までリダイレクトされて、画面に出力されません。stdoutで出力したファイルの内容を確認してみましょう。

$ cat output 
'/dev/fd/0' -> '/dev/zero'      # stdin
'/dev/fd/1' -> '/tmp/output'    # stdout
'/dev/fd/2' -> '/dev/null'      # stderr

プログラムはFDの実体がデバイスかファイルかを気にせず、FD0、FD1、FD2へ読み書きしています。statはFD1への書き込みにputs(3)やputchar(3)を使いますが、FD1が何なのかは気にしません。FDという透過的なインターフェイスにより、プログラムは同じAPIを使ってFDにアクセスします。そして外部からFDの指す先を、画面やファイルに切り替えてるだけです。

FDの複製・移動

次は、FDの実体を別のFDにも割り当てる、FDの複製と移動についてです。次の例はstdout/stderrの出力を共に/dev/nullに送り出す定石です。

$ date 1>/dev/null 2>&1

シェル初心者は、1>/dev/null2>&1の順番で迷います。しかしそれぞれの意味することを紐解くことで、つまづきません。

まず前提として、リダイレクトは左から評価されます。まず1>/dev/nullは、FD1の指す先を/dev/nullにします。そして2>&1が意味するのは、FD1の指す先(この場合/dev/null)をFD2にも割り当てます。これはFD1をFD2に複製していることになります。

もうひとつ、FDの移動についても説明します。FDの移動は複製と違い、移動元のFDが消えます。次の例はstdoutとstderrの出力先を入れ替える例です。swapの実装に一時変数が必要なように、FDの入れ替えも一時的なFDを使います。

$ date 3>&1 1>&2 2>&3-

1つ目の 3>&1 で、FD1を新たなFD3に複製します。2つ目の 1>&2 でFD2をFD1に複製します。3つ目の 2>&3- でFD3をFD2に移動します。dateからは移動元のFD3は見えず、扱えるFDはFD0、FD1、FD2のみとなります。

FDの複製や移動はもっぱら、シェルの組込みコマンドexecと併用されることが多いです。execコマンドを使うと、現在のシェルのリダイレクト先を変更できます。移動元を指定せずFDの移動をすると、移動先のFDを閉じます。

$ exec 3>>/tmp/output   # /tmp/outputをFD3に割り当て
$ echo "Hello" >&3      # FD3に出力
$ exec 3>&-             # FD3を閉じる

意図しない出力を防ぐためにも、使い終わったFDは閉じましょう。

パイプ

さてここまででは単一のプロセスでの話でした。次にシェルで欠せないパイプです。パイプ | を使うと、FDはパイプと呼ばれる特殊なFIFOデバイスを指します。

以下の例はcalのstdoutがwcのstdinに渡されます。その仕組みは、calプロセスのFD1にパイプが割り当てられ、wcプロセスのFD0に同じパイプが割り当てられます。リダイレクト同様、プログラムはFDがファイルかパイプかを気にせず、calはFD1に書き込みを、wcはFD0の文字数をカウントするのみです。

$ cal | wc

パイプのFDとその実体を確認してみましょう。次の例は、statでFD1のリンク先を確認しています。catはご存知の通り、FD0をFD1に流すコマンドで、statの出力を端末に表示します。

$ stat -c%N /dev/fd/1 | cat
'/dev/fd/1' -> 'pipe:[152817]'

同様にパイプされたプロセスのFD0も表示してみます。

$ : | stat -c%N /dev/fd/0 
'/dev/fd/0' -> 'pipe:[152818]'

| で作られるパイプは匿名パイプと呼ばれます。任意の名前をつけられる名前付きパイプmkfifo で作成できます。

$ mkfifo /tmp/tmppipe
$ date >/tmp/tmppipe &
$ cat /tmp/tmppipe 
Tue Dec 01 12:34:56 JST 2016

プロセス置換

プロセス置換は匿名パイプよりも柔軟かつ強力な機能です。プロセス置換ではパイプをFDとして取得できます(匿名名前付きパイプと呼ばれたりします)。そのためパイプをパス名として参照でき、プロセスの出力を別のプロセスにコマンドライン引数として渡せます。

次の例は2つのコマンドの結果を比較するときに用いられるイディオムです。プロセス置換はリダイレクトと構文が似てますが、全くの別物です。diffはご存知の通り、引数に与えられたファイルを比較するコマンドです。

$ diff  <(ls /usr) <(ls /usr/local)

プロセス置換を用いることで、複数のプロセスの出力をひとつのコマンドに渡せます。diffにはそれぞれのプロセスの出力がパス名として渡されますが、diffはファイル同様与えられたファイルを読み込んで差分を計算するだけです。

どんなファイル名が渡されているかは、いつものように stat で確認してみましょう。

$ stat -c%N <(ls /usr) <(ls /usr/local)
'/dev/fd/63' -> 'pipe:[187612]'
'/dev/fd/62' -> 'pipe:[187614]'

statにはFDのパスが渡され、その実態はパイプです。匿名パイプと同様に、パイプからはプロセスの出力が取得できます。

また stat -c%A でファイルのアクセス権も見てみましょう。興味深いことに >(...) はwrite-onlyに、<(...) はread-onlyになっています。

$ stat -c%A >(:) <(:)
l-wx------
lr-x------

応用例

ここまでは説明ばかりで少々退屈だったかも知れません。最後にいくつかの応用例を紹介します。

プロセス置換とリダイレクトの組み合わせ

プロセス置換はパイプをパスとして扱えるので、プロセス置換へのリダイレクトも可能です。次の例は、あるコマンドのstdoutとstderrを整形した後に、ファイルに追加書き込みします。

$ date \
    1> >(fmt >>out.log) \
    2> >(fmt >>err.log)

1> によりFD1はプロセス置換へ出力され、受け取る側のプロセスはfmtコマンド(所定の幅にフォーマット)を経てout.logに追加書き込みします。FD2も同様に、2>でリダイレクトされて、fmtした後に err.log に追加書き込みされます。

teeコマンドとの組み合わせ

stdinからの入力をstdoutと(複数の)ファイルに分岐させるteeコマンドも、プロセス置換と相性が良いです。teeはFD0から読み込んだデータを、引数に与えられたパスに書き込みます。

次の例では、あるサーバを起動するコマンドstart-serverコマンドの出力をteeに渡します。teestart-serverの出力をパイプで受け取ります。そしてプロセス置換でそれぞれのコマンドに出力し、すべての出力はall.logに出力します。それぞれのプロセス置換の中は、GETやPOSTにマッチする行をgrepして、get.logpost.logに出力します。

$ start-server | tee >(grep 'GET' >get.log) >(grep 'POST' >post.log) >all.log

終わりに

FDの仕組みは単純ですが、すごく合理的な仕組みです。FDを意識することで、いつもよりprettyにリダイレクトできたり、中間ファイル作成の手間を省けたりできます。

2日目は @hamano_t さんです、よろしくお願いします!