シェルのプロセス・ジョブ・シグナル:終了コード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 まで巻き込みます。

$? と set -e は第4回で扱いました。新人向けの全体像は学習ロードマップにあります。

プロセス・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)で書きます。

流れ図。スクリプトにシグナルが送られ、どのシグナルかで分かれる。INT・HUP・TERM なら trap が動き、終了コードはそれぞれ 130・129・143。KILL なら trap も後始末も動かず 137。スクリプトのバックグラウンドジョブに送った INT は無視され、ジョブは動き続ける。
スクリプトにシグナルが届いたときの流れ。終了コードは 128+シグナル番号。クリーンアップの trap を飛ばすのは KILL だけで、バックグラウンドジョブは INT を無視する。

よくある間違い:バックグラウンドジョブに 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 秒
gzip 16件の合計時間の棒グラフ。-P 1:15.59秒。-P 2:7.86秒。-P 4:4.19秒。-P 8:3.89秒。-P 16:3.96秒。
約11MBのファイル16個を xargs -P で gzip -9。8コアの M1 Pro(他の処理も実行中)で3回の中央値。コア数あたりで頭打ちになる。

数字から読めること:

  • 待ちが中心の処理(ネットワーク呼び出し、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 負荷の影響を受けません。

練習問題

  1. スクリプトが ./worker.sh & で起動し、後で kill -INT $! で止めようとしましたが、worker は動き続けます。なぜでしょう。代わりに何を送るべきですか。
  2. CI のあるステップは exit code 143、別のステップは 137 で終わりました。それぞれ何が起きたのか、後始末の trap が動けたのはどちらですか。

解答

  1. 非対話シェルのバックグラウンドジョブは SIGINT を無視するので、シグナルは捨てられます。kill -TERM "$pid" を送り、wait "$pid" で待ちます(ハンドラがなければ 143)。
  2. 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倍速でした。

  • #シェル
  • #bash
  • #zsh
  • #シグナル
  • #プロセス
  • #xargs
  • #上級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑