この記事の狙い
Bash でサブシェル(child shell)が暗黙に生まれるパターンを把握し、意図せぬ変数消失・状態未反映を防ぎます。
代表的な落とし穴(パイプ・() グループ・コマンド置換・バックグラウンド)を安全な置き換えパターンで解消します。
前提と対象
- Bash 4+(
set -Eeuo pipefail推奨) - 非対話シェル(スクリプト)を想定。
lastpipeなど Bash 固有の挙動も扱います。
TL;DR(最小実装・コピペ可)
#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail
# 1) パイプ右側で更新した変数は親に残らない(サブシェル)
# NG: printf 'a\nb\nc' | while read -r _; do ((n++)); done; echo "$n" # n は空
# OK: プロセス置換で "親の while" が読む
n=0
while IFS= read -r _; do ((n++)); done < <(printf 'a\nb\nc\n')
echo "$n" # 3
# 2) ディレクトリ変更を親に残したくないときだけ ( ... )
( cd /tmp && touch x ) # 親の CWD は変わらない
# 親に残したいなら { ...; } で(末尾セミコロン必須)
{ cd /; :; }
サブシェルが生まれる主な場面
pipeline(cmd1 | cmd2の各コマンド)- 丸括弧グルーピング
(...) - コマンド置換
$(...) - バックグラウンド実行
cmd &(ジョブごとに子プロセス) - 一部の組み合わせ(
(cmd) | while ...など)
サブシェルでは親から環境・変数はコピーされますが、変更は親に戻りません。
典型的な落とし穴と回避策
1) パイプでカウント/集計が 0 になる
悪い例
count=0
printf '%s\n' a b c | while IFS= read -r _; do ((count++)); done
echo "$count" # => 0(while がサブシェル)
良い例(プロセス置換)
count=0
while IFS= read -r _; do ((count++)); done < <(printf '%s\n' a b c)
echo "$count" # => 3
代替(mapfile)
mapfile -t lines < <(printf '%s\n' a b c)
echo "${#lines[@]}" # 3
代替(lastpipe)
Bash 4.2+:非対話シェルかつジョブ制御 OFF(既定)で、パイプの最後のコマンドだけ親で実行できます。
shopt -s lastpipe
printf '%s\n' a b c | readarray -t lines
echo "${#lines[@]}" # 3
lastpipeは最後のコマンド限定。whileループを最後に置く必要があります。
2) () と {} の混同
(...)はサブシェル:内部のcd・変数更新は親に影響しない{ ...; }は同一シェル:状態が親に残る(末尾の;と前後の空白必須)
( cd dir && build ) # 親の CWD は不変(安全な隔離)
{ cd dir; build; } # 親に CWD 変更が残る(意図時のみ)
3) コマンド置換内の失敗見落とし
$(...) 内はサブシェル。内部失敗は外側の $? で検知。
out="$(awk '{s+=$1} END{print s}' input.txt)" || { echo "calc failed" >&2; exit 3; }
set -o pipefailを有効にしておくと、置換内のパイプ途中の失敗も拾いやすい。
4) バックグラウンドでの状態更新
cmd & は子プロセス。終了コードは wait で拾い、親の変数に書かせない。
ok=0
long_job & p=$!
if wait "$p"; then ok=1; fi
echo "$ok"
置き換えパターン早見表
| やりたいこと | NG 例 | OK 例 |
|---|---|---|
| ループで数える | `echo list | while read; do ((n++)); done` |
| 行配列に読み込み | `cmd | mapfile -t lines` |
一時的に cd | { cd dir; run; }(親が汚れる) | ( cd dir && run ) |
| 状態を残すグループ | ( var=1 ) | { var=1; } |
| 同時に複数へ流す | `log | while …` |
“コピペ可”テストブロック(最小)
#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail
# 1) パイプのサブシェル問題の回避(プロセス置換)
n=0
while IFS= read -r _; do ((n++)); done < <(printf 'x\ny\nz\n')
[[ $n -eq 3 ]] || { echo "count fail: $n"; exit 1; }
# 2) () は親に影響しない / {} は影響する
orig="$PWD"
( cd /; : )
[[ "$PWD" == "$orig" ]] || { echo "() leaked"; exit 1; }
{ cd /; :; }
[[ "$PWD" == "/" ]] || { echo "{} no effect"; exit 1; }
echo "PASS"
実務の勘所
- 親に残したい状態(変数・CWD・FD)を変更するブロックでは**
{}を選ぶ**。残したくない処理は()。 - パイプで状態を書きたいときはプロセス置換、もしくは
lastpipeを検討。 - 関数の出口で値を返す設計(stdout /
printf -v/nameref)に寄せ、外側の変数更新依存を減らす。 set -o pipefailを常時有効にして途中失敗の見落としを回避。
互換性と移植性
lastpipeは Bash のshopt機能(sh/dash では不可)。非対話で有効、ジョブ制御 ON だと無効。- プロセス置換
< <(...)/> >(…)は Bash 拡張(POSIX sh では不可)。 - POSIX 互換が必要なら、一時ファイルで受け渡す設計に。
セキュリティと安全設計
- サブシェル境界を跨いでロック/一時ファイルパスなどを共有する場合、明示的な受け渡し(引数・環境変数)に。
- コマンド置換内の未検証入力に注意。失敗時は外側で
$?を必ず検査。 - バックグラウンド処理は**
waitとタイムアウト**設計を入れ、ゾンビ化を防ぐ。
パフォーマンスの勘所(短く)
- 不要なサブシェル生成はプロセス起動コストに直結。
{}に置き換えられる箇所は置き換える。 - 大量テキストはストリーム処理+プロセス置換で一時ファイルを減らす。
- パイプの段数を詰め、Bash のパラメータ展開で置き換えられる所は置き換える。
