13
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

シェル必須の基礎知識「サブシェル」とは何か?をちゃんと正しく解説する

Last updated at Posted at 2024-09-29

はじめに

シェルにはサブシェルという重要で独特な概念があります。どの言語でも独特の概念はあるものですが、シェルにとってはそれがサブシェルです。豆知識やうんちくとかいうレベルではなくサブシェルは基礎知識なので、シェルスクリプトをちゃんと書こうという人は知らなければヤバいと言っても過言ではありません。「難しい概念だから知らなくてもいいよ」などではなく、サブシェルを理解しなければまともに動くシェルスクリプトを書くことはできないと思ってください。独特なだけで難しいわけではないのですが、ちゃんと正しく説明できているものは市販の技術書を含めてもあまりありません。

サブシェルを一言で言うと、

現在実行中のシェル実行環境をコピーして作られた新たなシェル実行環境です。

重要なのは太字の「シェル実行環境のコピー」という点です。つまり、

シェル実行環境のコピーではない ./test.sh の実行はサブシェルではありません。

サブシェルとは現在のシェル実行環境をコピーした新たなシェル実行環境のことです。子プロセスのシェルのことではありません。サブシェルは親シェルのシェル変数やエイリアス設定などを引き継ぎますが .bashrc を読み込むことはありません。シェルスクリプトを実行したときに起動する新しいシェルはサブシェルではありません。

この件に関していくつかの本の説明が間違っていることに気づいたので、何がどう間違っているかも説明しています。詳細はこの記事の「間違っている本がいっぱいある」を参照してください。

🔰 この記事の内容やこの記事へのコメント(Twitter、はてなブックマーク、その他の外部サービスの公開コメントを含む)に対する私の返信の方針についてはこちらを参照してください。

サブシェルとはどういうもの?

サブシェルとは現在実行中のプロセスのシェル実行環境をコピーして作られた新たなシェル実行環境で、子プロセスのシェルのことではありません。サブシェルはシェルスクリプトの内部で明示的または暗黙的に作られます。シェルから起動した子プロセスのシェルやシェルスクリプトはサブシェルではありません。

シェル実行環境のコピーとは大雑把にはプロセスのメモリ内容などのコピーです。サブシェルはほとんどの場合、子プロセスとして実装されており、ほぼ同じ内容のプロセスがもう一つ作られます。ただし POSIX にはサブシェルを子プロセスとして実装しなければいけないという要件はありません。明示的なサブシェルの例は以下のようなものです。

test.sh
x=123

# 以下の ( ... ) の部分がサブシェル
(
  echo "$x" # => 123(呼び出し元のシェル実行環境のシェル変数の値が引き継がれている)
  x=456     # サブシェル = 新しいシェル実行環境(≒子プロセス)で変数の値を変更
)

echo "$x" # => 123(サブシェルでの変更は反映されない)

test.sh を実行したとき、test.sh の中のサブシェル部分(上記の ( ... ) の部分)が新しいシェル実行環境として生成されます。このとき元のシェル実行環境である test.sh のメモリなどがコピーされてサブシェルが作られます。生成したサブシェルが実行している間、呼び出し元のシェルはサブシェルが終了するまで待ちます。(バックグラウンドで実行しない限り)呼び出し元のシェルとサブシェルが並列に実行されるわけではありません。C プログラミングに詳しい人であれば知っていると思いますが「子プロセス版のサブシェル」はいわゆる fork されて作られます。サブシェルは次のような特徴を持っています。

  • サブシェルはシェル実行環境のコピーなので、シェル変数を継承します
  • サブシェルは別のシェル実行環境なので、元のシェル変数を変更することはできません

サブシェルは次のような場合に暗黙に生成されます。

# コマンド置換の中はサブシェルで実行される
i=$(echo 123)

# パイプでつないだ各コマンドはそれぞれのサブシェルで実行される
#   以下の例では while/for から done の範囲が1つのサブシェルになる
#   (while/for から done の"中が"サブシェルになるのではない)
for i in 1 2 3 4 5; do     # サブシェル
  echo "$i"
done | while read i; do    # サブシェル
  echo "$i"
done | while read i; do    # サブシェル
  echo "$i"
done

echo "$i" # => 123 (上記はすべてサブシェルの中で行われるため)

上記の例ではループで whilefor を使っていますが、caseif、シェル関数など、パイプにつないだものはなんでもサブシェルで実行されます。もっとも外部コマンドをつないだ場合はすぐに exec されてサブシェルは消えてしまいますが。

なんでサブシェルなんて物があるのか?という話ですが、コマンドを実行するシェルでは設計的にそう作るのが自然で楽だったという話でよいと思います。ローカルスコープ相当のものが作れて便利なのとマルチプロセスを使った並列動作が可能になるなどのメリットもありますが、その気になれば別の方法で実現することもできたわけで(実際に fish はサブシェルを作らない)、やっぱりシェルが持つべき機能を一番シンプルに実装する方法がサブシェルだったのでしょう。

サブシェルは環境の状態を変えたくない場合に便利

サブシェルでの実行はコピーされたシェル実行環境で行われるため、その環境を変更しても呼び出し元環境には影響がありません。例えば一時的にディレクトリを移動したい時に便利です。

$ pwd # 現在のディレクトリ
/home/koichi

$ (cd /tmp; pwd) # 一時的にディレクトリ移動する
/tmp

$ pwd # 元に戻っている
/home/koichi

一時的に環境を変更できるのでローカル変数のようなものを作れますが、変数だけではなくシェル関数の定義などもサブシェルに閉じ込められます。ただしサブシェルの中でサブシェルの外の標準入力を読み取ったりした場合は、標準入力は読み取られたままになります。

こういうわかりやすい便利な使い方がある一方で、暗黙的に作成されるサブシェルは知らないと不可解とも思える現象を引き起こし、パフォーマンス低下やハマる原因となりえるので基礎知識として知っておく必要があるのです。

サブシェル (fork) と exec を利用した外部コマンド実行

外部コマンドの実行は fork と exec によって行われます。これをシェルスクリプトで書くこともできます。ここで少し遠回りをして、外部コマンド版の echo (/bin/echo) を確実に呼び出すテクニックを紹介します。

Linux システムでは一般的に /bin/echo は GNU 版による実装であり echo --version でバージョン番号が出力されます。これを利用すると外部コマンドが呼び出されているかどうかを知ることができます。

$ echo --version # GNU版でない場合
--version

$ /bin/echo --version
echo (GNU coreutils) 8.32
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://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.

Written by Brian Fox and Chet Ramey.

おまけの話ですが、シェルは外部コマンドをシェル関数で再定義できます。再定義されたシェル関数を無視する場合は command をつけて呼び出します。このテクニックを利用すると、外部コマンドの実装を置き換えたり前後に処理を挟み込んだりできます。

$ echo() { printf "function echo\n"; }

$ echo --version
function echo

$ command echo --version
--version

注意: (sh/kshエミュレーションモードでない)zshでは外部コマンド版が呼び出される

さて絶対パスを使わずに外部コマンド版の echo を確実に呼び出したいとき、どのように書けばよいでしょうか? その答えはこれです。

$ (exec echo --version)
echo (GNU coreutils) 8.32
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://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.

Written by Brian Fox and Chet Ramey.

サブシェル (...) が行うのは fork です。そして exec コマンドが行うのは exec です。exec はシェルビルトイン版の echo を呼び出すことはなく、確実に外部コマンド版を呼び出します。しかしここでサブシェルを使わなければ、呼び出したシェル自身が echo コマンドへ変化し、バージョン番号を出力した後に終了してしまいます。このコードはサブシェルを使うことで子プロセスを生成し、サブシェルを echo コマンドに変化させることで、外部コマンドを実行したのと同じ動作を行います。このように外部コマンドの実行が fork と exec の組み合わせで行われていることをシェルで間接的に確認することができます。

ちなみにこの方法を使って確実に外部コマンドを呼び出すテクニックは、POSIX でも紹介されています。

Historical implementations of the env utility use the execvp() or execlp() functions defined in the System Interfaces volume of POSIX.1-2024 to invoke the specified utility; this provides better performance and keeps users from having to escape characters with special meaning to the shell. Therefore, shell functions, special built-ins, and built-ins that are only provided by the shell are not found by this type of env implementation. However, env can be implemented as a shell built-in, in which case it may be able to execute shell functions and built-ins. An application wishing to ensure execution of a non-built-in utility can use exec in a subshell for this purpose.

意訳 env コマンドを使えば外部コマンドを呼び出すことができるけど、env コマンドがシェルビルトインコマンドとして実装されている場合は、シェル関数やシェルビルトインコマンドを呼び出すかもしれないよ。サブシェルの中で exec を使えば確実に外部コマンドを呼び出せるよ。

パイプラインの最後はサブシェルにならない場合がある

実はパイプラインの最後のコマンドはサブシェルにならない場合があります。

i=123

for i in 1 2 3 4 5; do   # ← ここの for ... done はサブシェルになる 
  echo "$i"
done | while read i; do  # ← ここの while ... done はサブシェルになる
  echo "$i"
done | while read i; do  # ← ここの while ... done はサブシェルにならない場合がある
  echo "$i"
done

# パイプラインの最後がサブシェルにならないシェルでは、read コマンドがすべての行を
# 読み取り、最後のループで空文字を読み取るため123ではなく空文字が出力される
echo "$i" # => 空文字

パイプラインの最後のコマンドがサブシェルにならないのは、ksh93、zsh、shopt -s lastpipe を実行した bash です。ちなみに POSIX ではパイプでつないだ各コマンドをサブシェルにしなくてもよいと明記されているため、パイプラインの最後以外をサブシェルで実行しないシェルを作っても POSIX 準拠です。もっともそんな実装をしようと思うシェルが現れるとは思いませんが(バグや非互換性の原因になるだけでメリットがあるとは思えないので)。

サブシェルは新たにシェルを立ち上げているのではない

次のシェルスクリプトはいくつかのネストしたサブシェルが生成されるシェルスクリプトです。echo コマンドはシェルの最適化機能によって不要と判断されたサブシェルが削除されないように入れています。

#!/bin/sh

(
  (
    (
      ps --forest
      echo 3
    )
    echo 2
  )
  echo 1
)

このシェルスクリプトを実行すると次のように出力されます。

$ dash subshell.sh
Sun Sep 29 08:58:59 PM JST 2024
    PID TTY          TIME CMD
2089273 pts/28   00:00:01 bash                     ← 使用している対話シェル
2359955 pts/28   00:00:00  \_ dash                 ← シェルスクリプトを実行したシェル
2359956 pts/28   00:00:00      \_ dash             ← サブシェル1
2359957 pts/28   00:00:00          \_ dash         ← サブシェル2
2359958 pts/28   00:00:00              \_ dash     ← サブシェル3
2359959 pts/28   00:00:00                  \_ ps
3
2
1

これを見て「あぁなるほど、サブシェルとは新たなシェル (dash) を立ち上げているということなのね?」と早合点してはいけません。ここで dash の名前が登場するのはプロセスをコピーしているからであって、実際に dash を起動しているわけではありません。dash を起動してしまったら、それはサブシェル(シェル実行環境をコピー)ではなくなってしまいます。サブシェルは立ち上げるものではなく作るものです。

最適化によるサブシェルの削除

少々マニアックな話ですが、最適化によるサブシェルの削除についてもう少し説明します。さきほどの例から echo の実行を削除してから実行してみます。

#!/bin/sh

(
  (
    (
      ps --forest
    )
  )
)

そうするとサブシェルが一つもないことがわかります。これはサブシェルを作る必要がないとシェルが判断して削除しているのです。

$ dash sub.sh
    PID TTY          TIME CMD
2225752 pts/30   00:00:00 bash        ← 使用している対話シェル
2459348 pts/30   00:00:00  \_ dash    ← シェルスクリプトを実行したシェル
2459349 pts/30   00:00:00      \_ ps

他のシェルもだいたい同じ同じような出力をしますが、kshでは次のように出力されます。

$ ksh sub.sh
    PID TTY          TIME CMD
2225752 pts/30   00:00:00 bash    ← 使用している対話シェル
2459482 pts/30   00:00:00  \_ ps

これは ksh が実行されなかったわけではなく、ksh は実行したが ps コマンドを実行した後にコマンドがなにもないので、ksh のプロセスはもう必要ないと判断して、(exec して)ps コマンドに変化させているためです。これによりわずかにパフォーマンスが向上します。

少し異なるもう一つの例です。

#!/bin/sh

(
  echo 1
  (
    (
      ps --forest
    )
  )
)

この出力は dash と bash で異なります。

$ dash sub.sh # yash、zsh でも同様
1
    PID TTY          TIME CMD
2225752 pts/30   00:00:00 bash            ← 使用している対話シェル
2459784 pts/30   00:00:00  \_ dash        ← シェルスクリプトを実行したシェル
2459785 pts/30   00:00:00      \_ ps

$ bash sub.sh # mksh でも同様
1
    PID TTY          TIME CMD
2225752 pts/30   00:00:00 bash            ← 使用している対話シェル
2459818 pts/30   00:00:00  \_ bash        ← シェルスクリプトを実行したシェル
2459819 pts/30   00:00:00      \_ bash    ← サブシェル
2459820 pts/30   00:00:00          \_ ps

この結果はシェルによって最適化ぐあいが違っており、dash の方がより賢く最適化を行っていることを示しています。シェルスクリプトの実行速度はサブシェルだけで決まるわけではありませんが、このような実装の違いがパフォーマンスの違いとなっています。

サブシェルが作られるかどうかは状況によるので、シェルスクリプトに書いた通りとは限らないことに注意が必要です。

子プロセスを作らないksh93の仮想サブシェル

ksh93 には仮想サブシェルと呼ばれるものがあります。仮想サブシェルは子プロセスで実装されていませんが、サブシェルと全く同じ性質を持つように作られています。なぜこんな物があるかと言うと理由はパフォーマンスです。子プロセスを作るサブシェルは遅いため、パフォーマンスを向上させるために ksh93 は仮想サブシェルというものを作りました。子プロセスで作れば実装は簡単なのに、それをエミュレートするように頑張って作ったためバグがいっぱいできました。そのバグのほとんどは ksh93u+m でようやく修正されました。

仮想サブシェルを使うための特別な文法はありません。使い方はサブシェルとまったく同じで、仮想サブシェルでは実現不可能な命令を見つけた時点で自動的に、透過的に真のサブシェルへと移行します。

kshの仮想サブシェルの動作
# こういう単純なものは仮想サブシェルで実行される(子プロセスを作らない)
( echo 123 )

# 最初は仮想サブシェルで起動するが、exec true は真のサブシェルでないと実現できないので
# 途中で仮想サブシェルから真のサブシェルに変化する
( echo 123; exec true )

# バックグラウンドプロセスは、生成したプロセスの PID ($!) を
# 取得できる必要があるので子プロセスが作られる
( echo 123 ) &
wait $!

ksh93 はサブシェルとは異なる仮想サブシェル (virtual subshell) という用語を使ってますが、サブシェルの実装方法の一つにすぎないという考え方もできるわけで、POSIX で子プロセスとして実装しなければいけないという要件がないのは、仮想サブシェルであってもサブシェルと解釈できるようにするためと言えるでしょう。元々 POSIX は実装方法を規定したものではなく、インターフェースを規定したものなので、サブシェルの実装方法を規定しないは当たり前とも言えます。

パイプ + while に関するサブシェル問題の回避方法

この記事の目的からしたらおまけみたいなものですが、サブシェルでよく問題になる話について紹介しておきます。次のコードを、dash や bash で実行すると、期待した 55 ではなく 0 が出力されてしまいます。その理由はすでに説明した通り、while ... done 部分がサブシェル(子プロセス)になるからです。

1から10までの数値を合計する
total=0
seq 10 | while IFS= read -r n; do
  total=$((total + n))
done
echo "$total" # => 0

ただし ksh と zsh と lastpipe を有効にした bash では期待した答えが返ってきます。これはパイプラインの最後がサブシェルではなく呼び出し元のシェル環境で実行されるからです。

bashはlastpipeを有効にすると期待した結果になる
shopt -s lastpipe # ksh と zsh では不要
total=0
seq 10 | while IFS= read -r n; do
  total=$((total + n))
done
echo "$total" # => 55

それ以外のシェルではこのように書くと回避できます。なぜ回避できるのかと言うとサブシェルになる部分が { ... } の範囲に広がるからです。

サブシェルになる部分を広げれば回避できる
seq 10 | {
  total=0
  while IFS= read -r n; do
    total=$((total + n))
  done
  echo "$total" # => 55
}

# 別解(関数にしてやれば、変数のスコープはこうあるべきだとわかるでしょ?)
sum() {
  total=0
  while IFS= read -r n; do
    total=$((total + n))
  done
  echo "$total" # => 55
}

seq 10 | sum

つまり while を直接パイプにつなげるからいけないんです。この回避策は意外と知らない人がいるようです。その他の解決方法(プロセス置換や mkfifo を使った方法など)については以下の記事を参照してください。

whileをexitで終了するときの注意点

次のような whileexit で終了してはいけません。

この場合にwhileをexitで終了しようとしてはいけません
$ seq 10 | while read -r n; do echo "$n"; exit 0; done
1

理由はすでに説明していますが、ksh や zsh、shopt -s lastpipe を実行した bash では while ... done がサブシェルにならないので呼び出し元のシェルを終了してしまうからです。もし whileexit で終了したいのであれば明示的なサブシェルを作ります。

どうしてもexitで終了したい場合は(...)をつける
$ seq 10 | ( while read -r n; do echo "$n"; exit 0; done )
1

この exitwhile ループを抜けるという意味ではなくサブシェルを終了するという意味です。exitwhile ループを抜けるのではなくサブシェルを終了するので done の後のコードは実行されません。もし while ループを抜けたいのであれば break 一択です。ちなみにexit がサブシェルを終了することは POSIX でも標準化されています。

exit でサブシェルが終了するので echo end は実行されない
$ seq 10 | ( while read -r n; do echo "$n"; exit 0; done; echo end )
1

dash や デフォルト設定の bash などで、forwhile がサブシェルになるのはパイプラインの一部だからです。POSIX ではサブシェルにすることを要求していませんが多くのシェルではサブシェルになります。しかしサブシェルにならない場合もあります。サブシェルにならない場合に呼び出し元シェルを終了してしまうため、安易に exit で終了するようなコードを書いてはいけません。

ちなみにサブシェルを return で終了しようとしてはいけません。これは bash でエラーになり、NetBSD sh ではエラーにならずに、終了しません。return で終了できるのはシェル関数か . (source) で読み込んだスクリプトのみです。この仕様は POSIX でも標準化されています。エラーにならないシェルでは exit と同じように扱われるようです。

サブシェルを return で終了することはできない
$ seq 10 | ( while read -r n; do echo "$n"; return 0; done )
1
-bash: return: `return' は関数または source されたスクリプト内のみで利用できます
  ︙

もしここで、あれ? サブシェルの中で return してるけど問題なく動いているよ? と思った人はおそらくこのようなコードを書いています(bash でも NetBSD sh でもエラーにならない)。

サブシェルの中で return しても動く?
$ foo() { seq 10 | ( while read -r n; do echo "$n"; return 0; done ); }; foo

bash のエラーメッセージをよく読むと「return は関数内のみで利用できます」と書かれています。とんちみたいな感じですが、この例は、関数の中に return があると判断されているのです。おそらく return が関数の中にあるかは実行時には判断が難しい(構文解析でしかわからない)という理由でこのような動作になっているのではないかと推測しています。bash が本当にやりたかったこと(関数でないところでは return は使えない)の仕様とは思えないので、動いたとしてもサブシェルを return で終了するべきではないでしょう。exit を使えばよいので return を使う必要はないはずです。

標準入出力があるとサブシェルになるのではない

forwhile がサブシェルになるのはパイプラインの一部だからであって、標準入出力があるからではありません。以下のコードは標準入力がありますが、サブシェルは生成されません。

標準入力があってもサブシェルにはなりません
i=0
while read -r line; do
  echo "$line"
  i=$((i + 1))
done < /etc/hosts
echo "$i" # => /etc/hostsファイルの行数

プロセス置換を使った場合もパイプではないのでサブシェルにはなりません。(補足ですが、cat コマンドを使ってファイル読み込む方法はパフォーマンスが悪いので、ファイルから読み込む場合はリダイレクトを使うほうが優れています)

プロセス置換を使っている場合もサブシェルにはなりません
# こちらはパイプを使っているのでサブシェルになっている
i=0
cat /etc/hosts | while read -r line; do
  echo "$line"
  i=$((i + 1))
done
echo "$i" # => 0

# パイプではなくプロセス置換を使っているのでサブシェルにはならない
i=0
while read -r line; do
  echo "$line"
  i=$((i + 1))
done < <(cat /etc/hosts)
echo "$i" # => /etc/hostsファイルの行数

一つ注意が必要なのが(もう誰も使っていないと思われる)いにしえの Bourne シェルの場合です。Bourne シェルでは標準入出力があるとき、正確には whilefor のような複合コマンドをリダイレクトを組み合わせるとサブシェルになります。複合コマンドには whilefor の他に ifcaseuntil、そしてグループコマンド( { ... }( ... ) )も含まれます。

Bourne シェルでは複合コマンドとリダイレクトを組み合わせるとサブシェルになる
i=0
while read line; do
  echo "$line"
  i=`expr $i + 1`
done < /etc/hosts
echo "$i" # => 0

total=0
for i in 1 2 3; do
  total=`expr $total + $i`
done < /dev/null
echo "$total" # => 0

そのため Bourne シェルでは while などの複合コマンドとリダイレクトを組み合わせようと思ったら、次のような回避策が必要でした。

複合コマンドとリダイレクトを組み合わせなければサブシェルにはならない
# シェル関数を使えば複合コマンド { ... } とリダイレクトを組み合わせたことにならない
i=0
foo() {
  while read line; do
    echo "$line"
    i=`expr $i + 1`
  done
}
foo < /etc/hosts
echo "$i" # => /etc/hostsファイルの行数

# Bourne シェルでもサブシェルになる部分を広げる回避方法も使える
{
  i=0
  while read line; do
    echo "$line"
    i=`expr $i + 1`
  done
  echo "$i" # => /etc/hostsファイルの行数
} < /etc/hosts

# もちろんexecを使う方法でも良い
exec 0</etc/hosts
i=0
while read line; do
  echo "$line"
  i=`expr $i + 1`
done
echo "$i" # => /etc/hostsファイルの行数

このように Bourne シェルの異なる挙動を今のシェルもそうだと思って勘違いしている例もあるようです。

シグナルハンドラはサブシェルに継承しない

次のコードは、サブシェル内で実行しているループの中で、CTRL+C で止めた場合どうなるかを確認するためのシェルスクリプトです。

#!/bin/sh

trap 'echo trapped in $loc; exit' INT
loc=parent
for i in 1 2 3; do
  (
    loc=subshell
    for i in 1 2 3; do
      echo "subshell: $i"
      sleep 1            # ← このタイミングで CTRL+C で止めてみる
    done
  )
  echo "parent: $i"
  sleep 1
done

サブシェル内のループを実行しているときに CTRL+C を押すと次のように出力されます。

$ ./subshell.sh
subshell: 1
subshell: 2
^Ctrapped in parent

ここで trapped in parent と出力されるのはどうしてでしょうか? サブシェルが親シェルの実行環境のコピーであれば trapped in subshell も両方が出力されるべきではないでしょうか?

これはどちらかと言えばサブシェルというよりもプロセスに関する話です。CTRL+C を押した時 INT シグナルがプロセスに対して送信されますが、この時(同じプロセスグループに属する)すべてのプロセスに対してそれぞれ INT シグナルが送信されます。送信順は決まっておらず、親プロセスからとか子プロセスからとかではありません。シグナルハンドラの設定には以下の3つがあります。

  • デフォルトのシグナルハンドラ (SIG_DFL) ・・・ シェルでは trap - で設定する
  • 無視 (SIG_IGN) ・・・ シェルでは trap "" で設定する
  • ユーザー定義のシグナルハンドラ ・・・ シェルでは trap ... で任意の処理に設定する

プロセスが生成された時、親プロセスで設定されている「ユーザー定義のシグナルハンドラ」は子プロセスには継承しません。子プロセスに継承するものは SIG_DFL と SIG_IGN のみです。ユーザー定義のシグナルハンドラは SIG_DFL にリセットされます。デフォルトのシグナルハンドラはシグナルの種類によって無視かプロセス終了かその他の動作を行います。

最初の例では、親プロセスで trap コマンドでユーザー定義のシグナルハンドラが設定されています。しかしサブシェルではユーザー定義のシグナルハンドラはデフォルトに戻されます。デフォルトに戻されたシグナルハンドラはサブシェル内で再設定することができますが、ここでは何も設定してないのでデフォルトの処理としてプロセスが終了します。

この動作は、CTRL+C で終了させるときのことを考えると理解できます。通常 CTRL+C のシグナルハンドラには終了処理を記述します。各プロセスの終了処理は、それぞれのプロセスで必要な処理を行う必要があるので親プロセスでシグナルハンドラが設定されていたとしても、子プロセスには継承されません。しかし無視に設定された状態では、子プロセスも CTRL+C で停止してはならないので、無視は子プロセスに継承するのです。

ちなみに無視に設定された状態で起動した「子プロセスのシェルスクリプト」は子プロセスの中でシグナルハンドラを変更しようとしても無視のまま変更できませんが、サブシェルの場合は無視を変更することができます。この動作は POSIX でも標準化されています。

Signals that were ignored on entry to a non-interactive shell cannot be trapped or reset, although no error need be reported when attempting to do so. An interactive shell may reset or catch signals ignored on entry. Traps shall remain in place for a given shell until explicitly changed with another trap command.

ただし「子プロセスの zsh シェルスクリプト」は、無視に設定された状態で起動してもシグナルハンドラを変更できてしまいます(shエミュレーションモードでもkshエミュレーションモードでもそうだったので POSIX に準拠させる方法は無い?)。

バックグラウンドプロセスとサブシェルについて

TODO やる気が出たら追記する

間違っている本がいっぱいある💦

シェルのサブシェルについては、知らないとハマるけど知っていればシェルってそういう仕組みなんだというだけの話です。そこはそれでいいんですよ。別の問題はサブシェルの説明が間違っている本がいっぱいあるということです。それらの本の一部、影響度が大きく間違いを指摘しておかなければならないと私が思った重要な本を紹介します。

1983 年: The UNIX Programming Environment

いやぁ、困ったことに大御所なんですよね。著者は Unix を作ったベル研究所のメンバーで、あのカーニハンとパイクです。

「UNIXプログラミング環境」の「3.3 新しいコマンドの作成法」にはこうあります。

シェルが実際に nu を実行するときには、

$ sh nu

と入力して、新しいシェル・プロセスを作り出すのと全く同じ方法をとる。この子供のシェルをサブシェルと呼び、これはユーザのカレントシェルによって起動したシェル・プロセスである。

大御所に対してこれを言うのは気が引けますが、この説明は間違っています。それはサブシェルではなく単なるシェルから実行したシェルスクリプトです。「シェル実行環境をコピーして作られた」わけではないからです。まあ厳密に言えば、fork によるプロセスのコピーでサブシェルが作られ、それがすぐに exec で置き換えられているので、一瞬だけサブシェルは存在しているのですが、nu シェルスクリプトの実行環境自体はサブシェルではありません。

なぜこんな間違いをしてしまっているのか? 可能性の一つとしては当時はまだサブシェルという用語の意味が定まっていなかった可能性があります。Bourne シェルのドキュメントを見る限りサブシェルの意味がはっきり定義されてないんですよね。Bourne シェルでは明確に定義されていなかったか、定義されていたけど正しく伝わっていなかったかのどちらかなんでしょう。

ちなみにサブシェルという用語自体は Bourne シェルが誕生した 1979 年のときからあります。でもこの程度しか書いてありません。

( list )
Execute list in a subshell.

If the file has execute permission but is not an a.out file, it is assumed to be a file containing shell commands. A subshell (i.e., a separate process) is spawned to read it. A parenthesized command is also executed in a subshell.

さらにおもしろいことに、Bourne シェルの原典の一つとも言える以下の本にはサブシェルという用語が(少なくとも索引に)使われてないんですよ。

なんて書いてあるかというと、separete shell process なんですよね(The Unix System の P64より)。

( command-list )
The second form executes command-list by a separate shell process.

subshell = separete shell process だとするならば、シェルスクリプトとして実行した場合もサブシェルだと言えなくもないかなというところです。

もう一つの可能性としては、シバン(#!/bin/sh#! のこと)が誕生する前の Bourne シェルの内部実装を前提としている可能性です。UNIXプログラミング環境にはシバンがでてきません。出版が 1983 年ですが、シバンは 1980 年ぐらいには誕生していたものの 1983 年頃まではデフォルトでは有効ではなく System V 系 Unix では 1988 年まで追加されていなかったようです(参考)。本が出版されていたときはまだシバンは一般的ではなく、その場合のシェルスクリプトの実行は OS ではなくシェルによって行われ、シェルがサブシェルを作っているという内部実装を強く意識していた可能性があります。ちなみに現在の OS ではスクリプトにはシバンを用いることが一般的で、シェルではなく OS によってスクリプトが実行されています。

さて、1983 年頃に Bourne シェルの後継として KornShell が開発されました。1986 年バージョンを経て、1988 年バージョンが広く配布されました。その KornShell (ksh88) では、事実上の公式解説書でサブシェルをどう説明しているかと言うと、copy of parent shell environment です。

(The KornShell Command and Programming Language の P238 より)

SUBSHELLS
A subshell is a separete environment that is a copy of parent shell environment. Changes made to the subshell environment do not affect the parent environment.
ksh creates subshells to carry out:

  • (...) commands.
  • Command substitution.
  • Co-processes.
  • Background process.
  • Each element of a pipeline except the last. Version: On the 06/03/86 and earlier version of ksh, the last element of a pipeline was run in a subshell environment.

ちなみに New KornShell Command And Programming Language の方、つまり ksh93 の本には仮想サブシェルについて言及したと思われる以下の一文(サブシェル環境は分離したプロセスである必要はない)が追加されています。

A subshell environment need not be a separete process.

このように、少なくとも KornShell ではサブシェルの定義が明確になっています。

1993 年: Learning the Korn Shell

さて、今度の間違っている本はオライリーです。近年発売されている入門書ではなく、昔の硬派な技術書の方です。取り消し線は私が未所有のものです(個人用メモ・・・)。

英語版

日本語版

KornShell と Bash の二種類ありますが、著者が同じ Bill Rosenblatt で、書いてある内容はシェルを KornShell から Bash に置き換えただけで章構成がほぼ同じです。最も新しい「入門 bash 第3版」には図入りでこのような間違いが書いてあります。

3.1 .bash_profile、.absh_logout、.bashrc

.bash_profileは、ログインシェルだけが読み込んで実行するファイルである。コマンドラインにbashと入力して新しいシェル(サブシェル)を起動した場合、bashは.bashrcファイルからコマンドを読み込もうとする。この仕組みを利用すれば、ログイン時に必要なコマンドとサブシェルの起動時に必要なコマンドを分けておくことができる。ログインシェルでもサブシェルでもまったく同じコマンドを実行する必要がある場合は、.bash_profileファイルにsource .bashrcを入れておけばよい。.bashrcファイルが存在しない場合、サブシェルを起動してもコマンドは1つも実行されない。

用語が間違ってるので、何を言ってるのかさっぱりですね。

4.1 シェルスクリプトと関数

ところで、シェルスクリプトの2つの実行方法には、もっと重要な違いがある。sourceを使用する方法では、スクリプトのコマンドがログインセッションの一部であるかのように実行されるが、「スクリプト名を入力する」方法では、一連の処理が発生する。まず、サブシェルと呼ばれるシェルの新しいコピーがサブプロセスとして実行される。サブシェルはスクリプトからコマンドを取り出し、それらを実行して終了した後、制御を親シェルに戻す。

図4-1は、シェルがシェルスクリプトを実行する仕組みを示している。aliceという簡単なシェルスクリプトがあり、hatterとgryphonの2つのコマンドが含まれているとしよう。

image.png

サブシェルにはさまざまな用途がある。最も重要なのは、3章で説明したエクスポートされた環境変数(TERM、EDITOR、PWDなど)がサブシェルで認識されるのに対し、その他のシェル変数(.bash_profileファイルにexport文なしで定義されるものなど)が認識されないことである。

ほんと、自然に間違っているんですよね。

8.6 サブシェル

3章で説明したように、シェルスクリプトを実行するたびに、メイン(親)シェルのサブプロセスとして新しいシェルが実行される。ここでは、サブシェルを詳しく見ていくことにする。

8.6.1 サブシェルの継承

同様に、親シェルから継承しないものも重要だ

  • シェル変数


8.6.2 入れ子のサブシェル
サブシェルは別のスクリプトにする必要はない。つまり、親シェルと同じスクリプト(または関数)から起動してもかまわない。

シェルコードの一部を(中かっこではなく)かっこで囲むと、そのコードはサブシェルで実行される。これを入れ子の(またはネストした)サブシェルと言う。

「入れ子のサブシェル (nested subshell)」ってなんだそれ? そんな用語聞いたことないぞと思って調べたのですが、原著には「We’ll call this a nested subshell.」と書いてあって、おそらく著者が考えた用語のようです。結論を言ってしまえばこの著者は、子プロセスのシェルのことをサブシェルと呼び、サブシェルのことを「入れ子のサブシェル」と呼んでいるのです。bashの方ですがこちら から章のタイトルと内容をチラ見できます。

改訂版ではSubShellからSubProcessに修正

実はさきほどの参考文献の中で一つだけ、内容が修正されているものがあります。それが、日本語版が出版されていない「Learning the Korn Shell 2nd Edition」です。何が変わったのかわかるでしょうか? 「Learning the Korn Shell」(日本語版では「入門Kornシェル」)では「SubShell」となっているのですが、それが「Subprocess」に変わっているのです。

Learning the Korn Shell (1993)

image.png

Learning the Korn Shell 2nd Edition (2002)

image.png

他にも章のタイトルも変わっています。「入門Kornシェル」と、その改訂版の原著「Learning the Korn Shell 2nd Edition」を比較した時、8章のタイトルが次のように変わっています。

Shell Subprocess and Subshells
改訂前は「Subshells」(日本語版では「8.6 サブシェル」)の内容が書いてある

Shell Subprocess Inheritance
改訂前は「Subshell Inheritance」(日本語版では「8.6.1 サブシェルの継承」)の内容が書いてある

SubShells
改訂前は「Nested Subshells」(日本語では「8.6.2 入れ子型のサブシェル」)の内容が書いてある

改訂前のタイトルの日本語版は「入門bash 第3版」と同じで、内容も似たようなものです。ようするに間違った用語が改訂版で訂正されているのです。「シェル サブプロセス」も正直聞いたことがない用語なんですがシェルのサブプロセスということで前よりはマシかなと。

なぜこの本だけ修正されているのか疑問になりましたが、著者に Arnold Robbins が加わっていることがわかります。Arnold Robbins は正しいことを知っていたので間違いを修正したのでしょう。Arnold Robbins は「詳解 シェルスクリプト」の著者で他にも多くの Unix 関連の本を執筆しています。

同書のP180の7.8.2とその枠外には次のようにサブシェルの正しい説明が書かれています。

7.8.2 サブシェルとコードブロック

(略) サブシェルとは、一連のコマンドが(と)で囲まれている部分のことを指します。囲まれているコマンドは別プロセスで実行されます†。

† POSIXでは「サブシェル環境 (subshell environment)」という言葉が使われています。つまり、サブシェルの中のコマンドは必ずしも別プロセス上で実行されなければならないというわけではありません。サブシェルの外部の環境(変数の値、カレントディレクトリの位置など)を変更することが禁止されていると言うだけです。ksh93では可能な限り同一プロセス上でサブシェルが起動しますが、他のシェルではほとんどすべて別プロセスが起動されます。

POSIX の話やマイナーな仮想サブシェルの話も知っているわけで、そのような人がサブシェルを理解してないとは考えられません。おそらく執筆に加わったときに、あまりにも明らかな間違いを見過ごせずに修正したのでしょう。

2015 年: 新しいLinuxの教科書

最後に紹介する間違ったことが書かれている本は、ついこの間第2版が出版され、著者が日本人のこの本です。最初に言っておくとこの本の内容は全体的に良くおすすめできる本です(だからこそ影響度が大きいので指摘しているのです)。ただざっくりと眺めていただけなのでサブシェルについての間違いを見落としていました。

実は最初に間違いを発見したのがこの本で、参考文献に「入門bash 第3版」と書いてあったことから確認して、そもそもの発端を見つけたという流れです。おそらく同書は「入門bash 第3版」を参考にした結果、間違いが含まれてしまったのでしょう。その他の参考文献と照らし合わせたら間違いに気づけたんじゃないかと思わなくもないですが、「入門bash 第3版」は他の参考文献よりもわかりやすくしっかりと(間違いが)書いてありますからねぇ。これを正とみなすのもわからなくはないかなと。天下のオライリーですし。

以下は第2版の P268 からの引用です。

image.png

まあ、一瞬だけサブシェルが作られていると言えなくもないかもしれませんが、すぐに子プロセスのシェルへと変化します。混乱するだけなのでここではサブシェルとして扱わないほうが良いでしょう。これをサブシェルとして説明しているためその後の説明が間違ったものになっています。通常のサブシェルは元のシェル実行環境のコピーであり別物ではありませんし、エイリアスなどの設定も引き継がれます。巻末の索引の「サブシェル」からたどり着けるのはこのページだけですが、これはサブシェルの説明として適切ではありません。サブシェルについて他に説明しているページがあるかもしれませんが、この本は Kindle 版でも検索できないのでよくわかりません。

ちなみに両著者はそれぞれ以下の本も出版しています。

その内容を読むと、少々怪しさを感じる部分はあるものの、サブシェルの説明はそんなに間違ったものではありません。おそらくサブシェルを正しく理解していながらも、でも「入門bash 第3版」にはこう書いてあるし、と悩みながら書いたんじゃないかと思います。

その他の本の間違った説明

その他の本にも間違った説明をしている本はいくつもあります。大抵の本はサブシェルを完全に理解せずに説明している感じで正確とは言えない表現があるだけですが、ある本はサブシェルを単にサブモジュールの意味で使っており . (source) で読み込むスクリプトまでサブシェルと読んでいました。サブシェルから呼び出し元の変数を書き換えることができると書いており、まったくもってサブシェルではありません。

ネットにも「サブシェル」の間違った説明がいっぱいある

ウェブページの記事でも「サブシェルは .bashrc を読み込む」などという間違った内容を見かけます。おそらくここで紹介した本のどれかを参照しているのでしょう。

  • サブシェルは .bashrc を読み込みません
  • シェル変数やシェル変数やエイリアスなどはサブシェルに継承されます

あー、エイリアスがサブシェルに継承されていることの証明って、ちょっと注意が必要です。なぜなら ( ... ) の部分を解釈する時点でエイリアス展開が行われるからです。なので eval を使えばおそらく証明になるでしょう。

エイリアスがサブシェルに継承されていることを証明する方法
#!/bin/sh

# bash の場合シェルスクリプトでエイリアスを有効にする必要がある
[ "$BASH" ] && shopt -s expand_aliases

alias e=echo

(
  e "ok"        # そのまま書くと ( ... ) の解釈時点でエイリアス展開が行われる
  alias         # サブシェルに「alias e='echo'」が継承されている証拠が出力される
  eval 'e "ok"' # eval実行時にエイリアス展開が行われるので証明になる
)

有名どころの間違いは「Advanced Bash-Scripting Guide」です。ここでの「Chapter 21. Subshells」の説明は以下のようになっています。

Running a shell script launches a new process, a subshell.
(シェルスクリプトを実行すると新しいプロセス、サブシェルが起動します)

Definition: A subshell is a child process launched by a shell (or shell script).
定義: サブシェルはシェル(またはシェルスクリプト)から起動される子プロセス)

サブシェルが子プロセスなのは bash の実装に限定した話だからということで目を瞑るとして、コードの方はしっかりと ( ... ) を使ったサブシェルの説明になっており、定義が正確ではなく誤解を招く例です。コードを読まずに定義だけを読んで理解した気になると、間違った理解へとつながります。

さいごに

ということで、元々 Bourne シェルでサブシェルの厳密な定義が書かれていなかったことを発端として「The UNIX Programming Environment」が間違って書かれ、それを元に「Learning the Korn Shell」が書かれ、おそらく日本では翻訳版の「入門Kornシェル」の姉妹版である「入門bash」を元に「新しいLinuxの教科書」が書かれ、その内容がウェブ上に広まったという流れではないかと思います。ここからの教訓は、偉い人が書いていたりオライリーだからって盲目的に信用してはいけないということです。

ただでさえサブシェルはシェルスクリプト特有と言っても良い概念で理解が必要なものなのに、間違った説明があると混乱してしまいます。正しく説明されていないものをいくら読んでも理解できるわけはありません。サブシェルは別に難しいものではありません。ただそれを正しく説明できる人が少ないだけです。正しく説明できる人が少ないのは難しいからではありません。単に知らないから説明できないのです。難しくないシェルスクリプトの書き方を知らない人がなんと多いことか。そして自分が知らないことを棚に上げてシェル言語の文法に文句を言ってるのをよく見かけます。自分の不勉強をシェルスクリプトのせいにするなという話です。

「新しいLinuxの教科書 第2版」については、お問い合わせから連絡しました。改訂版が出る前に私が気づけたらよかったんですけどね。残念。(2024-10-04追記 後日、サポートページの正誤情報に訂正を掲載すると連絡を頂きました)

13
14
3

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
13
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?