シェルのプロセス・ジョブ・シグナル:終了コード130/137/143の正体
ps・kill・trap・バックグラウンドジョブ・wait・nohup を bash 3.2 と zsh 5.9 で実行。kill -INT が効かない理由、残る子プロセス、xargs -P の実測。
シリーズ:コマンドライン講座 5/6(上級)。 スクリプトから長い処理を動かしている開発者が、並列に走らせ、きれいに止め、137 のような終了コードが何を言っているのか読めるようになるための回です。
夜間バッチを手で止めたのに子プロセスだけが残っていた、Ctrl-C のつもりの kill -INT が効かなかった、CI やコンテナのログに exit code 137 としか出ていない。8コアあるのに処理が直列で15秒かかる。この記事はそういう問題を抱えている人のためのものです。プロセスの親子関係を見て、自分で起動したプロセスにだけ主要なシグナルを送り、返ってくる終了コードを読み、xargs -P の効果を実測します。
環境: macOS 26、Apple Silicon Mac(M1 Pro、8コア)、zsh 5.9 と GNU bash 3.2.57。BSD 版の ps・kill・pgrep・xargs。GNU/Linux の書き方は「ここでは未実行」と明記します。作業フォルダは ~/demo/jobs。最初から最後まで守ったルールが1つあります。シグナルは記録した PID に送り、名前では送らない。 pkill sleep は同じマシンの他人の sleep まで巻き込みます。
プロセス・PID・ps
動いているプログラムはすべてプロセスで、番号(PID)を持ちます。最初の1つを除き、どのプロセスにも親があり、その番号が PPID です。シェル自身もプロセスで、シェルが実行するコマンドはその子プロセスです。必要な番号はシェル変数で取れます。
$$:現在のシェルの PID(bash 3.2 では( サブシェル )の中でも親の番号のまま。BASHPIDは bash 4.0 からで、ここではunsetでした)$!:最後にバックグラウンドで起動したジョブの PID$?:直前のコマンドの終了コード
バックグラウンドで sleep を3つ起動して見てみます。
# sig.sh(前半)
sleep 300 & p1=$!
sleep 300 & p2=$!
sleep 300 & p3=$!
ps -o pid,ppid,stat,command -p "$p1,$p2,$p3"
## bash
PID PPID STAT COMMAND
3312 3310 S sleep 300
3313 3310 S sleep 300
3314 3310 S sleep 300
## zsh
PID PPID STAT COMMAND
3584 3581 SN sleep 300
3585 3581 SN sleep 300
3586 3581 SN sleep 300
S はスリープ中。zsh の N は意外な発見でした。zsh の BG_NICE オプションが既定で有効で、スクリプトの中でもバックグラウンドジョブの優先度を下げます。bash は下げません。zsh で並列処理が遅いと感じたら、これが理由の1つです。
ps -o ... -p PID は BSD の書き方で macOS で動きます。Linux では ps -o pid,ppid,stat,cmd -p ... や ps -ef --forest がよく使われます(ここでは未実行)。pgrep -P PID はその PID の子を列挙するので、名前に頼らず「自分のスクリプトが起動したプロセス」を探せます。
シグナルと 128+N の規則
シグナルは、カーネルがプロセスに届ける小さな通知です。「止まってください」「今すぐ止まれ」「端末がなくなった」など。たいていのシグナルはプロセスが捕まえてハンドラを動かせますが、KILL と STOP は捕まえられません。シグナル N で死んだプロセスについて、シェルは終了コード 128+N を報告します。sig.sh の続きです。
kill -TERM "$p1"; wait "$p1"; echo "TERM -> wait status $?"
kill -KILL "$p2"; wait "$p2"; echo "KILL -> wait status $?"
kill -INT "$p3"; sleep 0.5; kill -0 "$p3" && echo "INT ignored: $p3 still alive"
kill -TERM "$p3"; wait "$p3"; echo "then TERM -> $?"
kill -0 "$p1"; echo "kill -0 on finished pid -> $?"
## bash
sig.sh: line 5: 3312 Terminated: 15 sleep 300
TERM -> wait status 143
sig.sh: line 6: 3313 Killed: 9 sleep 300
KILL -> wait status 137
INT ignored: 3314 still alive
sig.sh: line 8: 3314 Terminated: 15 sleep 300
then TERM -> 143
sig.sh: line 9: kill: (3312) - No such process
kill -0 on finished pid -> 1
## zsh
TERM -> wait status 143
KILL -> wait status 137
INT ignored: 3586 still alive
then TERM -> 143
sig.sh:kill:9: kill 3584 failed: no such process
kill -0 on finished pid -> 1
kill -0 PID は何も送らず、プロセスが存在してシグナルを送れるかだけを確かめます。「まだ動いているか」の定番の確認方法で、第6回ではロックが古いかどうかの判定に使います。
この実行と追加の確認(SIGPIPE は yes | head -1、SIGHUP は nohup)からまとめた表です。
| シグナル | 番号(macOS) | 既定の動作 | trap 可 | 観測した終了コード | よくある発生源 |
|---|---|---|---|---|---|
| HUP | 1 | 終了 | 可 | 129 | 端末を閉じた、SSH 切断 |
| INT | 2 | 終了 | 可 | 130 | Ctrl-C |
| KILL | 9 | 終了 | 不可 | 137 | kill -9、メモリ不足の強制終了、タイムアウト後のコンテナ実行環境 |
| PIPE | 13 | 終了 | 可 | 141 | 閉じたパイプへの書き込み(yes | head -1) |
| TERM | 15 | 終了 | 可 | 143 | kill PID、docker stop、サービス管理 |
| STOP / CONT | 17 / 19 | 一時停止・再開 | 不可 / 可 | — | kill -STOP。停止中は ps で T |
シグナルの番号はすべてが移植可能ではありません。kill -l USR1 は macOS で 30 でしたが、Linux では 10 です(ここでは未実行)。1・2・9・15 以外は必ず名前(kill -TERM、trap ... TERM)で書きます。
よくある間違い:バックグラウンドジョブに kill -INT
上の INT ignored: ... still alive に多くの人が驚きます。実際に起きていること:非対話シェル(スクリプト)が & でジョブを起動すると、その子の SIGINT と SIGQUIT を「無視」に設定します。端末の Ctrl-C がフォアグラウンドだけに届くようにするためです。起動時に無視されたシグナルは後から戻せないので、子の中で trap しても捕まえられません。
さらに妙なのは、& の直後に送った場合です。
# それぞれ3回
bash -c 'sleep 30 & p=$!; sleep 0.2; kill -INT $p; sleep 0.2; kill -0 $p 2>/dev/null && echo "alive after INT" || echo dead; ...'
bash -c 'sleep 30 & p=$!; kill -INT $p; sleep 0.2; kill -0 $p 2>/dev/null && echo "nosleep: alive" || echo "nosleep: dead"; ...'
alive after INT
alive after INT
alive after INT
nosleep: dead
nosleep: dead
nosleep: dead
待ちなしだと、fork() の後で子が「無視」を設定する前のわずかな隙間にシグナルが届き、130 で死にました。競合状態です。「速く送るから動いている」スクリプトは、忙しいマシンで壊れます。対策:バックグラウンドジョブには TERM を送ります。無視されず、trap で後始末でき、サービス管理ツールが送るのも TERM です。
なお、本当に SIGINT で死んだプロセスは 130 を返します。sh -c 'kill -INT $$' は 130、自分に割り込んだ Python のワンライナーも(KeyboardInterrupt を表示したあと)130 でした。
バックグラウンドジョブ・wait・終了コードの回収
cmd & はすぐに戻り、wait PID はそのジョブの終了を待ってそのジョブの終了コードを返します。引数なしの wait は全ジョブを待ちますが 0 を返し、コードを捨ててしまいます。
# waitall.sh
job() { sleep "$1"; return "$2"; }
job 0.3 0 & a=$!
job 0.1 3 & b=$!
job 0.2 0 & c=$!
fail=0
for p in $a $b $c; do
if wait "$p"; then echo "pid $p ok"; else rc=$?; echo "pid $p failed rc=$rc"; fail=1; fi
done
wait; echo "plain wait returns $? (exit codes lost)"
exit $fail
pid 20922 ok
pid 20923 failed rc=3
pid 20926 ok
plain wait returns 0 (exit codes lost)
script exit=1
zsh 5.9 も同じ結果でした。最初に終わったジョブ b(0.1秒)を2番目に待っても正しく報告されています。シェルは終わった子の状態を回収されるまで保持します。bash 4.3 で「次に終わったものを待つ」wait -n が入りましたが、bash 3.2 では wait: -n: invalid option でした。標準の Mac では上のように PID をループで待ちます。
trap:後始末と、残ってしまう子プロセス
trap 'コマンド' シグナル はシェルがシグナルを受けたときにコマンドを実行し、疑似シグナル EXIT はシェルの終了時に必ず実行します。まず間違った版です。
# trap.sh
tmp=$(mktemp "${TMPDIR:-/tmp}/demo.XXXXXX")
cleanup() { rm -f "$tmp"; echo "cleanup: removed temp file"; }
trap cleanup EXIT
trap 'echo "got TERM"; exit 143' TERM
echo "working, pid $$"
sleep 300 &
wait $!
$ bash trap.sh & tp=$!; sleep 0.5; kill -TERM $tp; wait $tp; echo "trap.sh exit=$?"
working, pid 8570
got TERM
cleanup: removed temp file
trap.sh exit=143
$ ps -o pid,ppid,command -p 8576
PID PPID COMMAND
8576 1 sleep 300
スクリプトは一時ファイルを消して 143 で終わり、設計どおりです。ところが子の sleep 300 は PPID 1 で動き続けました。launchd(Linux では init/systemd)に引き取られたのです。シグナルは1つのプロセスに届くもので、子には届きません。実務ではこの子が rsync や curl、Python のワーカーで、次の実行で2つ目が起動します(この孤児は PID を指定して止めました)。
対策は、子の PID を覚えておき、後始末で止めることです。
# trap2.sh
tmp=$(mktemp "${TMPDIR:-/tmp}/demo.XXXXXX")
child=""
cleanup() {
[ -z "$child" ] || kill "$child" 2>/dev/null
rm -f "$tmp"; echo "cleanup: removed temp file, stopped child"
}
trap cleanup EXIT
trap 'echo "got TERM"; exit 143' TERM
sleep 300 & child=$!
wait "$child"
PID PPID STAT COMMAND
99609 99607 SN bash trap2.sh
99613 99609 SN sleep 300
got TERM
cleanup: removed temp file, stopped child
trap2.sh exit=143
child still alive? no
なぜ素の sleep 300 ではなく sleep 300 & wait $! なのか。bash は trap をフォアグラウンドのコマンドが終わってから実行するからです。フォアグラウンドの sleep 中に届いた TERM は sleep の終わりまで待たされますが、wait はすぐ中断されます。第6回でこれを測ります(フォアグラウンドの sleep 5 で4秒遅れ、& wait で0秒)。
nohup・SIGHUP・端末を閉じたとき
端末や SSH セッションが閉じると、そこのプロセスに SIGHUP が届きます。nohup は SIGHUP を無視した状態でコマンドを起動します。
# hup.sh
sleep 300 & plain=$!
nohup sleep 300 & guarded=$!
sleep 0.3
kill -HUP "$plain" "$guarded"; sleep 0.3
for p in "$plain" "$guarded"; do
if kill -0 "$p" 2>/dev/null; then echo "$p alive after HUP"; else wait "$p"; echo "$p died, status $?"; fi
done
kill "$guarded"; wait "$guarded"; echo "guarded after TERM: $?"
ls nohup.out
hup.sh: line 4: 20654 Hangup: 1 sleep 300
20654 died, status 129
20655 alive after HUP
hup.sh: line 8: 20655 Terminated: 15 nohup sleep 300
guarded after TERM: 143
ls: nohup.out: No such file or directory
細かい点が2つ。nohup が守るのは HUP だけ(TERM では 143 で死にました)。また nohup.out は標準出力が端末のときだけ作られます。出力をすでにリダイレクトしているスクリプトではファイルが現れないので、nohup cmd > job.log 2>&1 & と明示します。再起動後も動き続けるべきものは、nohup ではなくサービス管理(macOS は launchd、Linux は systemd)に任せます。
SIGPIPE・141・pipefail
自分で送らなくても出会うシグナルがもう1つあります。
$ bash -c 'yes | head -1; echo "PIPESTATUS=${PIPESTATUS[*]}"'
y
PIPESTATUS=141 0
$ bash -c 'set -o pipefail; yes | head -1 >/dev/null; echo "pipefail rc=$?"'
pipefail rc=141
head は1行で終わり、yes は閉じたパイプに書いて SIGPIPE(141)で死にます。正常で無害ですが、set -o pipefail(第4回)の下ではパイプライン全体が 141 で「失敗」します。strict モードのスクリプトが ... | head で止まるなら原因はこれです。PIPESTATUS(bash)や pipestatus(zsh、こちらも 141 0)を見て、上流の 141 は許容します。
実測:xargs -P でどれだけ速くなるか
xargs -P N は最大 N 個のコマンドを同時に動かします。awk の srand(42) で生成したログファイル16個(各約11MB、合計176MB)を gzip -9 で圧縮し、別に sleep 0.5 するだけのタスクも16個走らせました。
printf '%s\0' in/*.log | xargs -0 -P "$P" -n 1 sh -c 'gzip -9 -c "$1" > /dev/null' _
| -P | gzip -9 ×16(3回の中央値) | 速度比 | sleep 0.5 ×16(1回) |
|---|---|---|---|
| 1 | 15.59 秒 | 1.0倍 | 8.19 秒 |
| 2 | 7.86 秒 | 2.0倍 | — |
| 4 | 4.19 秒 | 3.7倍 | 2.05 秒 |
| 8 | 3.89 秒 | 4.0倍 | — |
| 16 | 3.96 秒 | 3.9倍 | 0.54 秒 |
数字から読めること:
- 待ちが中心の処理(ネットワーク呼び出し、sleep)はほぼ理想どおり伸び、16並列で 0.54 秒、約15倍でした。
- CPU を使う処理は4並列まではよく伸び、その後は頭打ちです。M1 Pro は8コア(高性能6・高効率2)で、計測中は他の処理も動いていた(ロードアベレージ 8.6〜12.8)ため、8並列の gzip が空きコアを8つ得られませんでした。-P 8 は6回で 2.53〜4.64 秒の幅がありました(最初の3回は
ls in/*.log | xargs -P 8 -n 1 ...、後の3回は上のprintf ... -0形式。ファイル名に空白はないので渡る引数は同じです)。 - コア数を超える 16 並列は速くならず、メモリを使うだけでした。
エラー処理の注意点:16個のうち1つが失敗すると、macOS の BSD xargs は 1 で終わりました(man ページでも「その他のエラー」は 1)。GNU xargs は「コマンドが 1〜125 で終わった」場合に 123 を返します(ここでは未実行)。123 を確認しているスクリプトは Mac では失敗を見逃します。
どのシグナル、どの道具を使うか
| やりたいこと | 使うもの | 理由 |
|---|---|---|
| 後始末をして止まってほしい | kill -TERM PID |
trap できる。サービス管理も TERM を送る |
| TERM で数秒たっても止まらない | kill -KILL PID |
捕まえられない。後始末は動かない(137) |
| PID がまだ生きているか確認 | kill -0 PID |
何も送らない |
| 一時停止・再開 | kill -STOP / kill -CONT |
停止中は ps が T |
| 端末を閉じても続けたい | nohup cmd > log 2>&1 & |
無視するのは HUP だけ |
| N 個を同時に実行 | xargs -0 -P N、または & と wait PID |
wait PID なら個別の終了コードが残る |
| 自分の子プロセスを探す | pgrep -P $$ |
名前で照合しない |
正直な限界とバージョンの注意
- 実行したのは macOS(bash 3.2.57、zsh 5.9)だけです。
timeout・setsid・flockは macOS に入っていません(command -vでどれも見つかりませんでした)。Linux のtimeout 10 cmdはタイムアウト時に 124 を返します(ここでは未実行)。 - 128+N は bash と zsh が報告する値です。Docker や Kubernetes の「137」は、コンテナのメインプロセスが SIGKILL を受けたという意味で、メモリ不足が原因のことが多いですが、それはそれぞれの慣習です(ここでは未実行)。
wait -n(bash 4.3)とBASHPID(bash 4.0)は bash 3.2 にはなく、エラーを確認しました。バージョンは bash の NEWS によるもので、bash 4/5 は実行していません。- ベンチマークのマシンは他の処理と共用でした。中央値と幅を示し、sleep の計測は CPU 負荷の影響を受けません。
練習問題
- スクリプトが
./worker.sh &で起動し、後でkill -INT $!で止めようとしましたが、worker は動き続けます。なぜでしょう。代わりに何を送るべきですか。 - CI のあるステップは
exit code 143、別のステップは137で終わりました。それぞれ何が起きたのか、後始末の trap が動けたのはどちらですか。
解答
- 非対話シェルのバックグラウンドジョブは SIGINT を無視するので、シグナルは捨てられます。
kill -TERM "$pid"を送り、wait "$pid"で待ちます(ハンドラがなければ 143)。 - 143 = 128 + 15、SIGTERM を受けました(タイムアウトやキャンセルによる通常の停止)。
trap ... TERMやtrap ... EXITが動けます。137 = 128 + 9、SIGKILL です。TERM が無視された後や、メモリ不足による強制終了が多く、trap は動きません。
前回: 第4回 変数・クォート・終了コード ・ 次回: 第6回 壊れにくいスクリプト・cron・ログ ・ 以前:第3回 find・xargs・sed・awk
よくある質問
終了コード 130・137・143 は何を意味しますか?
128+シグナル番号です。130 は SIGINT(2、たいてい Ctrl-C)、137 は SIGKILL(9、kill -9 やメモリ不足による強制終了)、143 は SIGTERM(15、通常の kill)。バックグラウンドの sleep にそれぞれ送り、wait で3つとも確認しました。129(SIGHUP)と 141(SIGPIPE)も同じ規則です。
スクリプトから起動したバックグラウンドジョブに kill -INT が効かないのはなぜ?
非対話シェルは & で起動したジョブの SIGINT と SIGQUIT を無視に設定するからです。起動 0.2 秒後に送った kill -INT では3回ともジョブが生き残りました。fork 直後に送ると、子が無視を設定する前に届いて終了しました(競合状態)。バックグラウンドジョブには SIGTERM を送ります。
xargs -P で並列にするとどれくらい速くなりますか?
約11MBのファイル16個の gzip -9 で、-P 1 の 15.59 秒が -P 2 で 7.86 秒、-P 4 で 4.19 秒、-P 8 で 3.89 秒になりました(他の処理も動いていた8コアの M1 Pro、3回の中央値)。待ちが中心の処理(sleep 0.5)では -P 16 が -P 1 の15倍速でした。