プロセス境界について考える

前回は、シェルスクリプトの末尾に exit $? を明示する是非について整理した。本稿では、その議論の背後にある中核概念であるプロセス境界をまとめる。子スクリプトが exit しても呼び出し元が終了しない理由、そして終了させたい場合の設計上の選択肢を、シェルスクリプトと Python の双方から確認する。


プロセス境界とは何か

プロセス境界とは、親プロセスと子プロセスを隔てる壁のことである。各プロセスは OS により独立したアドレス空間・環境・ファイルディスクリプタ・PID を持ち、他プロセスのメモリや制御フローに直接干渉しない。したがって、子プロセスが exit しても親プロセスは終了しない。親に伝わるのは終了コードという整数値のみである。


プロセス境界が意味すること

  • メモリ空間は独立しており、変数や関数の状態を共有しない。
  • exit は自分(=現在のプロセス)だけを終了させる。
  • 親プロセスには終了コード($? や return code)だけが返る。
  • データ受け渡しはパイプ・ファイル・ソケットなどの明示的チャネルを通す。
  • 親子間の制御連携には、終了コード評価、シグナル、パイプ、待機のいずれかが必要である。

Unix / Linux におけるプロセス境界

Unix 系 OS では、プロセス生成が明確な二段階構造である fork() と exec() によって行われる。fork() は親のコピーとして子を作り、exec() はその子に別プログラムを上書き実行させる。これにより、親と子は完全に独立した実行単位となる。

get-device /mnt/disk1

上のような通常の外部コマンド呼び出しは、子プロセスを生成して実行し、終了コードのみを親に返す。親シェルは生きたままであり、必要であれば $? を評価して自らの振る舞い(継続か終了か)を決める。


同一プロセスとして実行したい場合

スクリプトを同一プロセスで実行すると、exit が親にも波及する。これは例外的に境界を作らない実行形態である。

. ./get-device
# または
source ./get-device

同様に、exec は現在のプロセスを置き換えるので、実行後の exit で親は終了する。

exec /etc/cron.exec/rsync_backup.sh

この二つは強力だが、親を巻き込んで終了するため運用上のリスクが高い。通常は避ける。


呼び出し元が影響を受けない実例

1) シェルから子スクリプトを実行

test -x /etc/cron.exec/rsync_backup.sh && /etc/cron.exec/rsync_backup.sh >> "$JOBLOG" 2>&1

これは別プロセス実行である。rsync_backup.sh 内で exit(ないし exit $?)しても、呼び出し元のシェルは終了しない。終了させたいなら呼び出し元で終了コードを判定して明示的に exit する。

/etc/cron.exec/rsync_backup.sh >> "$JOBLOG" 2>&1
rc=$?
[ "$rc" -ne 0 ] && exit "$rc"

2) コマンド置換内のスクリプト

dev=$(get-device "$T_HOME/$T_MOUNT/$T_DEVICE")

$(…) の中身もサブプロセス(子)で走る。get-device が exit 1 しても、親は生き続け、$? や展開結果の有無で分岐できる。

3) Python から外部コマンド呼び出し(現代的手法)

Python 3.5 以降では、subprocess.run() が標準的な方法である。子プロセスの exit は親の Python を終了させない。戻り値の return code を見て制御を判断する。

import subprocess

result = subprocess.run(["ls", "-l"], capture_output=True, text=True)
print(result.stdout)

if result.returncode != 0:
    print("Error occurred")
    exit(result.returncode)

subprocess.run() は CompletedProcess オブジェクトを返し、stdout・stderr・return code を一括で扱える。check=True を指定すれば、非ゼロ終了コードで例外(CalledProcessError)が送出される。

subprocess.run(["false"], check=True)
# → CalledProcessError が発生

また、コマンドの存在確認には shutil.which() を利用する。

import shutil
if shutil.which("rsync"):
    subprocess.run(["rsync", "--version"])

古いコードで見られる subprocess.call() や os.system() は非推奨であり、標準出力の扱いやエラー検出が不十分である。現代の Python コードでは subprocess.run() を基軸に設計すべきである。

時代 主なAPI 出力取得 例外制御 現行推奨
〜Python 2系初期 os.system
Python 2系 os.popen / commands
Python 2.4〜3.4 subprocess.call / Popen
Python 3.5〜 subprocess.run

他の OS におけるプロセス境界

プロセスという概念は Linux / Unix に限られたものではない。Windows でも CreateProcess() と ExitProcess() により独立性が提供され、子の終了は親の終了を意味しない。現代のほぼすべての OS がプロセスごとに独立した実行空間を持つという設計を採用している。


プロセス境界の本質

プロセス境界とは、言い換えれば「exit の波及を止める壁」であり、「$? だけが越えてくる窓口」である。子がどのように終了しても、その終了コードを受け取って判断するのは親の役割である。この設計こそが、シェルスクリプトや Python が堅牢に動作し続ける基盤となっている。


設計チェックリスト

  • 子の失敗で親を終了させたいか。→ 親が終了コードを評価し exit(または source/exec を明示採用)。
  • 子の失敗を集計して最後に判断したいか。→ 戻り値を蓄積し、最後に代表値で exit。
  • ログと可観測性は十分か。→ 直後に RC=$? を保存しログへ出力。
  • サブシェルやコマンド置換の失敗が握り潰されていないか。→ 展開結果と $? を必ず検査。

まとめ

  • プロセス境界は exit の波及を止める壁である。
  • 子が exit しても親は終了しない。親に伝わるのは終了コードのみである。
  • Python では subprocess.run() が現行標準であり、return code を評価して制御を決める。
  • この性質は Linux / Unix に限らず主要 OS に共通である。

コメントする

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)