RACCOON TECH BLOG

株式会社ラクーンホールディングスのエンジニア/デザイナーから技術情報をはじめ、世の中のためになることや社内のことなどを発信してます。

この問題を解けたらスゴい!?シェルスクリプトとファイルディスクリプタの話

こんにちは、羽山です。
今回はシェルスクリプトのちょっとしたクイズからファイルディスクリプタとリダイレクトの動作を解説します。

早速ですがこのシェルスクリプトの問題を解けますか?

問:
bashで以下のシェルスクリプトを実行した場合に表示される文字列はどれか?
※ echo の -n は改行を出力しないオプション

(
  echo -n "A" > /dev/null
  echo -n "B" 2>&1
  echo -n "C" >&2
  echo -n "D" 2> /dev/null
  echo -n "E" 3>&1 >&2 2>&3
) > /dev/null

選択肢:
  a. ABCDE      g. CBD
  b. ABC        h. BD
  c. ACDE       i. BCDE
  d. BAD        j. ACE
  e. BCE        k. AC
  f. CE         l. なにも表示されない

これは弊社の採用フローで以前実施していたスキルテストの最終問題から引用しています。(※現在はスキルテスト自体を廃止しています)
しかしそのスキルテストでは時間制限もあってか、この問題の正答率が極端に低かったのでこの機会に解説してみようと思います。

まず始めに正解は「f. CE」(※白文字なので選択状態で閲覧ください)です。

ではどうしてその結果になるのかを順を追ってみていきましょう。
検証に利用した環境は以下です。

bash --version
GNU bash, バージョン 5.0.17(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2019 Free Software Foundation, Inc.
ライセンス GPLv3+: GNU GPL バージョン 3 またはそれ以降 <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

サブシェルとはなにか?

この問題を考えるにあたって最初に必要なのは全体を囲んでいるサブシェルの理解です。
サブシェルは以下のように ( ) で囲まれた領域で、今回の問題はサブシェル内で echo コマンドが複数回実行されています。

(
  # サブシェル
  ...
) > /dev/null

サブシェルとは元の bash プロセスをコピー(fork)して作られた子プロセスです。
親子関係こそあるものの独立したプロセスであり、子プロセスは親プロセスからリソースをコピーして作られますが、起動して以降の変化は互いに干渉しません。

例えば次のように親と子のシェルでそれぞれ MESSAGE 変数に値をセットすると以下のような結果になります。

MESSAGE="親プロセスでセット"
(
  echo $MESSAGE                # 親プロセスでセット

  MESSAGE="子プロセスでセット"
  echo $MESSAGE                # 子プロセスでセット
)
echo $MESSAGE                  # 親プロセスでセット

サブシェルは親プロセスのコピーなので、サブシェルの起動時点でセットされていた MESSAGE 変数の値を参照できます。もちろん更新も可能ですがそれはあくまでサブシェル(子プロセス)内だけの変化であり、サブシェルの終了と共に破棄されます。
つまり親プロセスから見ると最初に値をセットして以降は何の変化もありません。

ファイルディスクリプタ

親プロセスからコピーされるのは変数だけではなく、親プロセスが持つファイルディスクリプタも同様です。

プロセスが開いているファイルディスクリプタを確認するには /proc/self/fd を参照するのが簡単です。

$ ls -al /proc/self/fd
合計 0
dr-x------ 2 jun jun  0  4月 19 14:26 .
dr-xr-xr-x 9 jun jun  0  4月 19 14:26 ..
lrwx------ 1 jun jun 64  4月 19 14:26 0 -> /dev/pts/6
lrwx------ 1 jun jun 64  4月 19 14:26 1 -> /dev/pts/6
lrwx------ 1 jun jun 64  4月 19 14:26 2 -> /dev/pts/6
lr-x------ 1 jun jun 64  4月 19 14:26 3 -> /proc/24620/fd

$ tty
/dev/pts/6

この実行結果は bash プロセスからコピー(fork/exec)して作られた ls プロセスの持つファイルディスクリプタですが、それらは親プロセスからコピーされるので bash プロセスが持っているファイルディスクリプタとほぼイコールと考えられます。
ただし前述の出力のうちファイルディスクリプタ3(3 -> /proc/24620/fd)は ls が開いた /proc/self/fd のリンク先なので親プロセスである bash からコピーされたものではありません。

bash プロセスが持つファイルディスクリプタは以下3つです。

0 が標準入力、1 が標準出力、2 が標準エラー出力であり、いずれも tty/dev/pts/6)につながっているので画面に表示されます。

この3種類の入出力は標準ストリームと呼ばれ、大抵のユースケースで必要になる入力1つと正常・異常系の出力2つが標準となったものです。
一般的なコマンドは起動した時点でこれら3種類の入出力が用意されていることを前提に動作します。デフォルトでは出力2種は両方とも画面につながっているので違いが分かりにくいですが、例えばリダイレクトを使って標準出力はファイルへ書き込み、エラーがあれば画面に表示するという使い分けができます。

ではサブシェルの標準出力(ファイルディスクリプタ1)を /dev/null にリダイレクトして、そのサブシェル内で ls -al /proc/self/fd を実行してみます。

(
  ls -al /proc/self/fd
) >/dev/null

これは当然ですが出力を得られません。

親プロセスの bash は先ほど確認したとおり tty につながっていますが、その子プロセスであるサブシェルは標準出力が /dev/null へリダイレクトされています。
孫プロセスの ls -al /proc/self/fd はサブシェルからコピー(fork)して作られるので標準出力は子プロセスと同様に /dev/null となり、結果として出力内容は破棄されます。(※)

(※)
実はサブシェル内で起動するコマンドが1つしかない場合はサブシェルは起動されずに直接コマンドが実行されます。
なぜなら実行するコマンドが1つだけなら実行終了と共にサブシェルも終了するので状態の保持が必要なくサブシェルを起動してもしなくても動作に違いがないからです。
一方で2つ以上のコマンドを実行する場合は前のコマンドの実行が以降のコマンドの動作に影響するのと、その状態を親シェルとは別に保持する必要があるためサブシェルの起動が必要です。
今回実行した例はサブシェル内が1つのコマンドだけなので実はサブシェルは起動しませんが説明の簡略化のためにサブシェルが起動したものとして解説しています。

結果が表示されないと分かりづらいので次は 2>/dev/null で標準エラー出力(ファイルディスクリプタ2)を /dev/null へリダイレクトして同じコマンドを再度実行してみます。

(
  ls -al /proc/self/fd
) 2>/dev/null
合計 0
dr-x------ 2 jun jun  0  4月 19 15:13 .
dr-xr-xr-x 9 jun jun  0  4月 19 15:13 ..
lrwx------ 1 jun jun 64  4月 19 15:13 0 -> /dev/pts/6
lrwx------ 1 jun jun 64  4月 19 15:13 1 -> /dev/pts/6
l-wx------ 1 jun jun 64  4月 19 15:13 2 -> /dev/null
lr-x------ 1 jun jun 64  4月 19 15:13 3 -> /proc/12051/fd

今度は画面に表示されました。ls -al /proc/self/fd のプロセスはサブシェルからファイルディスクリプタをコピーするため標準エラー出力(ファイルディスクリプタ2)が /dev/null となることも確認できました。

問題を分解して考える

(
  echo -n "A" > /dev/null
  echo -n "B" 2>&1
  echo -n "C" >&2
  echo -n "D" 2> /dev/null
  echo -n "E" 3>&1 >&2 2>&3
) > /dev/null

ここまで来ればあと一歩です。
親プロセスの bash0, 1, 2 全てが画面(tty)につながっていますが、子プロセスであるサブシェルはファイルディスクリプタ1が /dev/null にリダイレクトされているので3種の入出力は以下の状態になっています。

echo は引数で指定された文字列を標準出力(ファイルディスクリプタ1)に出力するので、サブシェルの中で5回実行された各コマンドにおいて標準出力が画面へとつながっているかどうかを考えていきます。

echo -n "A" > /dev/null

A を標準出力(ファイルディスクリプタ1)へ出力していますが、その接続先は /dev/null となっているためそのまま破棄されます。
また echo コマンド実行時にも標準出力(ファイルディスクリプタ1)を /dev/null へリダイレクトする指定がされていますが、echo を起動したサブシェルの標準出力が元々 /dev/null へ接続されているので指定してもしなくても影響はありません。

echo -n "B" 2>&1

リダイレクトを含むコマンドを実行する場合、コマンド本体の実行前にリダイレクトが処理されます。
まず 2>&1 に着目すると、これは標準エラー出力(ファイルディスクリプタ2)の接続先を標準出力(ファイルディスクリプタ1)からコピーして上書きする指示です。

結果として以下の状態で echo コマンドが実行され、B は標準出力(ファイルディスクリプタ1)に出力されるものの /dev/null で破棄されます。

このコマンドは標準エラー出力(ファイルディスクリプタ2)を /dev/null へと接続するよう指示されていましたが、echo の出力先は標準出力(ファイルディスクリプタ1)なので標準エラー出力がどこに接続されていても(エラー発生時以外は)影響しません。

echo -n "C" >&2

>&2 は標準出力(ファイルディスクリプタ1)を標準エラー出力(ファイルディスクリプタ2)からコピーして上書きする指示です。
標準エラー出力は画面(tty)に接続されているので、それをコピーすると標準出力も画面(tty)に接続され、echo コマンドは以下の状態で実行されます。

標準出力が画面(tty)へ接続されているので C は標準出力経由で画面に表示されます。

echo -n "D" 2> /dev/null

2> /dev/null は標準エラー出力(ファイルディスクリプタ2)を /dev/null とする指示なので、echo コマンドは以下の状態で実行されます。

D は標準出力を経由して /dev/null で破棄されて出力されません。

echo -n "E" 3>&1 >&2 2>&3

3>&1 >&2 2>&3 のように複数のリダイレクト指示がある場合は手続き型言語のように左から順に処理します。

  1. 3>&1 で新規ファイルディスクリプタ3を開いて標準出力(ファイルディスクリプタ1)からコピーする
    • 1 -> /dev/null
    • 2 -> tty
    • 3 -> /dev/null (NEW!)
  2. >&2 で標準出力(ファイルディスクリプタ1)を標準エラー出力(ファイルディスクリプタ2)からコピーして上書きする
    • 1 -> tty (UPDATE!)
    • 2 -> tty
    • 3 -> /dev/null
  3. 2>&3 で標準エラー出力(ファイルディスクリプタ2)をファイルディスクリプタ3からコピーして上書きする
    • 1 -> tty
    • 2 -> /dev/null (UPDATE!)
    • 3 -> /dev/null

標準出力と標準エラー出力の接続先を入れ替えるための一時変数的な役割でファイルディスクリプタ3を利用しています。

結果として標準出力が画面(tty)につながった状態で echo コマンドが実行されるので E は画面に表示されます。

出力結果

標準出力(ファイルディスクリプタ1)が画面につながった状態で実行された CE だけが画面に表示されてそれ以外は /dev/null へ破棄されます。
リダイレクトやらサブシェルが複雑に組み合わさった問題でしたが、親プロセスと子プロセスとリダイレクトにおけるファイルディスクリプタの動作を分解して考えれば意外と簡単に読み解けたのではないでしょうか?

応用パターン

これで主題の解説は完了しましたが、せっかくサブシェルやファイルディスクリプタ、そしてリダイレクトの動作を学習したのでさらに応用パターンも見てみましょう。

標準出力・標準エラー出力が破棄されている状態から画面出力

次の例はサブシェルに対して >/dev/null2>/dev/null が指定されている絶望的な状況ですが、このサブシェル内で画面に表示する方法はあるのでしょうか?

出力が両方とも /dev/null で破棄されているため当然このままでは画面に表示することはできません。

(
  # この部分で画面に表示する方法は?
  echo 'サブシェル内から画面へ出力'
) >/dev/null  2>/dev/null

tty コマンドを利用する

一つ目の方法は以下のように tty コマンドから得られた結果を利用して標準出力を画面に再接続する方法です。

(
  # この部分で画面に表示する方法は?
  echo 'サブシェル内から画面へ出力' >$(tty)
) >/dev/null  2>/dev/null

tty コマンドは仮想端末(=画面)のデバイスファイルを戻してくれるので、それに対して出力した内容は画面に表示されます。
実は標準入力などが接続されていた「画面」の正体は tty コマンドが出力するデバイスファイルと同じものです。

標準入力を利用する

このサブシェルは標準出力と標準エラー出力が破棄されていますが、標準入力(ファイルディスクリプタ0)は画面に接続されたままです。そこで標準入力を標準出力に対してコピーすることもできます。標準入力と標準出力では方向が違うのでコピーできないと思うかもしれませんが tty は読み書き可能で実体は同じものです。

(
  # この部分で画面に表示する方法は?
  echo 'サブシェル内から画面へ出力' >&0
) >/dev/null  2>/dev/null

標準出力以外の出力を利用

ここまでは echo を利用して標準出力(ファイルディスクリプタ1)に出力した上で、標準出力の接続先を変更して動作を見ていました。
次は出力の段階でさまざまなファイルディスクリプタに対して出力して動作を確認してみます。

ただし任意のファイルディスクリプタへ出力可能な標準コマンドに心当たりがなかったので python のワンライナーを利用します。

# ファイルディスクリプタ2(標準エラー出力)に出力する
$ python -c 'import os; os.write(2, b"A\n")'
A

通常 python でIOを利用するには open() を利用しますが、os.write() という低レイヤの関数にオープン済みのファイルディスクリプタを指定すれば直接書き込むことができます。
上記の例はファイルディスクリプタ2(標準エラー出力)を第1引数に指定して A という文字列を出力しています。

どの言語でも標準出力に書き込む print() 的な関数が用意されていますが、利用前にオープンなどが不要でクローズする必要もないのはプログラム起動・終了時に bash などのシェルがファイルディスクリプタを渡してくれて終了後も責任を持ってくれるからです。

この例は標準エラー出力に書き込んではいるのですが、画面に接続されていると標準出力と見分けがつきません。そこで標準出力・標準エラー出力をそれぞれ /dev/null で破棄した2種類のコマンドを実行すると狙い通り標準エラー出力に出力されていることを確認できます。

# 標準出力を破棄する → 出力される
$ python -c 'import os; os.write(2, b"A\n")' >/dev/null
A

# 標準エラー出力を破棄する → 出力されない
$ python -c 'import os; os.write(2, b"A\n")' 2>/dev/null

標準入力もファイルディスクリプタ0で開かれているので os.read() で読み込むことができます。
以下のように第1引数に 0 を指定して、第2引数には読み込むバイト数を余裕を持って指定します。

# 標準入力(ファイルディスクリプタ0)を読み込んでファイルディスクリプタ2(標準エラー出力)へ出力する
$ echo 'B' |python -c 'import os; os.write(2, os.read(0, 100))'
B

開いていないファイルディスクリプタへ書き込んだらどうなるでしょうか?
以下のように存在しないファイルディスクリプタ3へ書き込むと当然ですがエラーが発生します。

# 開いていないファイルディスクリプタ3へ出力すると出力時にエラーが発生する
$ python -c 'import os; os.write(3, b"C\n")'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
OSError: [Errno 9] Bad file descriptor

リダイレクトを使ってファイルディスクリプタ3を bash に開かせた状態なら同じ処理も成功します。
以下の 3>&1 は標準出力をファイルディスクリプタ3にコピーする指示で、つまり画面(tty)に接続されます。python プログラムはファイルディスクリプタ3への書き込みが正常に実行できるようになって出力も正しく得られます。

# ファイルディスクリプタ3をリダイレクトで開いてからpythonを起動すれば正常に出力できる
$ python -c 'import os; os.write(3, b"D\n")' 3>&1
D

ファイルディスクリプタは読み書きを抽象化したものなので、実体がファイルである必要はありません。
次のようにファイルディスクリプタの接続先をプロセス置換にすることで別のコマンドにパイプで接続することもできます。

# ファイルディスクリプタ3の接続先をプロセス置換にもできる
$ python -c 'import os; os.write(3, b"E\nF\nG\n")' 3> >(nl)
     1  E
     2  F
     3  G

ここで紹介したファイルディスクリプタへ直接書き込む方法を使えば、さきほどの標準出力と標準エラー出力が共に破棄されていたシチュエーションでの別解を考えることができます。

まず1つめの方法として標準入力(ファイルディスクリプタ0)には tty が残っていたので、次のようにファイルディスクリプタ0へ書き込めば画面へ出力されます。

(
  # この部分で画面に表示する方法は?
  python -c 'import os; os.write(0, "サブシェル内から画面へ出力\n".encode("utf-8"))'
) >/dev/null 2>/dev/null

標準入力に書き込みをするってなにか悪いことしている気分になりますね。もちろんこの方法は標準入力が読み書き可能な tty だから成立するのであって、書き込み不可のデバイスならばエラーが発生します。

2つめの方法として以下のようにファイルディスクリプタ3で tty に繋いで書き込むこともできます。

(
  # この部分で画面に表示する方法は?
  python -c 'import os; os.write(3, "サブシェル内から画面へ出力\n".encode("utf-8"))' 3>$(tty)
) >/dev/null 2>/dev/null

このようにファイルディスクリプタを低レイヤから直接操作するとシェルとコマンドとファイルディスクリプタの関係がよく理解できたのではないでしょうか?

最後に理解をさらに深めるために bash がコマンドの起動時にリダイレクトをパースしてファイルディスクリプタを用意してくれる様子を以下で確認しましょう。

(
python <<'EOS'
import os

os.write(1, b"A\n")
os.write(2, b"B\n")
os.write(3, b"C\n")
os.write(4, b"D\n")
os.write(5, b"E\n")
EOS
) >logging.1  2>logging.2  3>logging.3  4>logging.4  5>logging.5

ファイルディスクリプタ1~5を logging.n へリダイレクトしたサブシェルを開き、その中で python プログラムが各ファイルディスクリプタを直接指定して値を出力します。

実行するとカレントディレクトリに5つのファイルが生成されて、それぞれ logging.1Alogging.2B のように値が書き込まれます。

標準ストリームを閉じたらどうなるか?

一般にプログラムは標準出力や標準エラー出力が常に利用できる前提で動作しますが、万が一それらが閉じているとどうなるでしょうか?
bash のリダイレクトでは >&- という形式で任意のファイルディスクリプタを閉じることができます。

まずは echo を標準出力が閉じた状態で実行してみます。動作にはほとんど違いはありませんが責任分界点を明確にするために bash の組み込みコマンドではないバージョンを利用します。

/usr/bin/echo A >&-
/usr/bin/echo: 書き込みエラー: 不正なファイル記述子です

画面にエラーが表示されました。
標準エラー出力は閉じていなかったので標準出力への書き込みが失敗したことを標準エラー出力から報告してきたようです。

それならば標準エラー出力も閉じてみます。

/usr/bin/echo A >&- 2>&-

次は何も表示されませんでした。
標準出力への書き込みが失敗したことを標準エラー出力で報告したはずですが、残念ながらそれも閉じられているので結果としてなにも表示されません。

ちなみにこの動作は echo コマンドに実装されたものであり、標準ストリームが閉じられていた場合の動作がシェルやその他のレイヤで担保されているわけではありません。

試しに標準出力を閉じた状態で以下の pythonprint() を実行してみると何も出力はされませんでした。echo とは違い pythonprint() はそのまま握り潰します。

python -c 'print("A")' >&-

python のようなプログラム言語では print() の失敗に対するエラーハンドリングを通常行わないので無視する設計となっているのだと思われます。
標準ストリームはプログラム側から暗黙で利用しますが、100% 動作が担保されているわけではないということが分かります。

まとめ

シェルのリダイレクトやサブシェルの動作、そしてファイルディスクリプタの理解はついつい適当になってしまいがちです。
しかしこれの動作原理は意外と単純なので1度しっかり理解すると意外とずっと生きる知識として使えます。

この記事でシェルに対する理解が少し進んだら幸いです。

関連記事

さて、ラクーングループは一緒に働く仲間を絶賛大募集中です!
高レイヤから低レイヤまで幅広く経験できる環境です。もしご興味を持っていただけましたら、こちらからエントリーお待ちしています!

一緒にラクーンのサービスを作りませんか? 採用情報を詳しく見る

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc