システムコールでプロセスを操る

第2章では、アプリがsyscall命令でカーネルに処理を頼む仕組みを見ました。 本章では、そのシステムコールを使ってプロセスがどう生まれ、どう動き、どう終わるのかを追います。 Kubernetesでの再起動、Ctrl+C で止まらないサーバー、<defunct> と表示される謎のプロセスなど、運用の現場で出会う現象の多くがここにつながっています。

プログラムとプロセスの違い

プログラムはディスク上のファイルです。/usr/local/bin/node という実行ファイルや、server.js というスクリプトがこれに当たります。プロセスは、そのプログラムを実行している最中の状態を指します。メモリ上に展開されたコードとデータ、今どの命令を実行しているか、どのファイルを開いているか、といった情報をまとめたものです。 同じ node を3回起動すれば、プログラムは1つでもプロセスは3つになります。 「プロセス」という言葉は、1966年にMITのJerome Saltzerが定義したことでも知られています。

task_struct — カーネルが持つプロセスの台帳

カーネルは各プロセスの情報を、教科書ではPCB(Process Control Block)と呼ばれる構造体で管理します。 Linuxでの実体は include/linux/sched.h に定義された task_struct です。 主なフィールドと、Node.jsから見える場所を対応させると次のようになります。

task_structが持つ情報内容Node.jsから見る方法
pid / tgidスレッドごとのID / スレッドグループ(プロセス)のIDprocess.pid(tgidに当たる)
親プロセスへの参照自分を生んだプロセスprocess.ppid
状態(__state)実行中・スリープ・停止・ゾンビなど外部からps コマンドで確認
mm(メモリ記述子)アドレス空間とページテーブルprocess.memoryUsage()(第5章)
files(FDテーブル)開いているファイルやソケットls /proc/PID/fd(第6章)
シグナル関連処理方法(ハンドラ)と保留中のシグナルprocess.on('SIGTERM', ...)
資格情報(cred)ユーザーID・グループIDprocess.getuid()

Linuxでは、プロセスもスレッドも同じ task_struct で表されます。 1つのプロセスに属するスレッドは同じtgid(thread group ID)を持ち、スレッドごとに別のpidを持ちます。 紛らわしいのですが、getpid() が返すのはtgidで、スレッド固有のIDは gettid() で取ります。 Node.jsのプロセスもlibuvのワーカースレッドなどを含む複数のスレッドでできていますが、process.pid はプロセス全体で1つです。 スレッドとプロセスの違いは第4章で掘り下げます。

プロセスの状態遷移

プロセスは一生の間に、いくつかの状態を行き来します。Linuxの ps コマンドは、状態を1文字で表示します。

stateDiagram-v2
  [*] --> R : fork で生まれる
  R --> S : sleep や ソケット待ち
  S --> R : データ到着・時間経過
  R --> D : ディスクI/O待ちなど
  D --> R : I/O完了
  R --> T : SIGSTOP / Ctrl+Z
  T --> R : SIGCONT
  R --> Z : exit で終了
  Z --> [*] : 親が wait で回収
Linuxのプロセス状態。R(実行中・実行可能)、S(割り込み可能なスリープ)、D(割り込み不可のスリープ)、T(停止)、Z(ゾンビ)
STAT意味Webサーバーでよく見る場面
R実行中、またはCPUの順番待ち(実行可能)JSONの処理など、CPUを使っている最中
S割り込み可能なスリープ。シグナルで起こせるイベントループがepoll_waitで接続を待っている
D割り込み不可のスリープ。シグナルでも起こせない応答しないNFSやディスクへのI/O待ち
T停止。SIGSTOPやCtrl+Zで止められた状態デバッガで止めている
Zゾンビ。終了したが親がまだ回収していない子プロセスの後始末漏れ
Iアイドル状態のカーネルスレッドps -e で大量に見えるカーネル側のスレッド

ps の読み方

# Linux での出力例
$ ps -o pid,stat,cmd -C node
    PID STAT CMD
   4211 Ssl  node server.js
   4290 Rl   node worker.js

STAT列の1文字目が状態で、2文字目以降は補足の印です。s はセッションリーダー、l はマルチスレッド、+ はフォアグラウンドのプロセスグループを表します。 待ち受け中のNode.jsサーバーは、多くの時間をイベントループの epoll_wait で過ごすので、普段は Sl 系の表示になります。 CPUを使い続けるループを回していると R になります。

D状態が厄介な理由

D状態(uninterruptible sleep)のプロセスは、kill -9 を送っても即座には死にません。 カーネル内でI/Oの完了を待っている途中で、中断すると整合性が壊れるため、シグナルの処理が後回しになるからです。 ネットワーク越しのストレージが応答しなくなると、D状態のプロセスが積み上がり、ロードアベレージだけが上がり続けることがあります。 Linuxのロードアベレージは、Rに加えてDのプロセスも数えるためです。

fork・exec・wait — プロセスの生まれ方

Unix系OSでは、新しいプロセスを次の3つのシステムコールの組み合わせで作ります。

  • fork: 呼び出したプロセス(親)をまるごと複製し、子プロセスを作る。親には子のPIDが、子には0が返る。
  • exec(execve): 自分のプロセスの中身を、別のプログラムに置き換える。PIDはそのまま。
  • wait(waitpid): 子プロセスの終了を待ち、終了コードを受け取って後始末する。
sequenceDiagram
  participant P as 親プロセス<br/>(シェル PID 100)
  participant K as カーネル
  participant C as 子プロセス<br/>(PID 101)
  P->>K: fork()
  K->>C: 親を複製して子を作る<br/>メモリはコピーオンライト
  K-->>P: 戻り値 101
  K-->>C: 戻り値 0
  C->>K: execve(/bin/ls)
  K->>C: 中身を ls に置き換え<br/>PIDは101のまま
  P->>K: waitpid(101) で待つ
  C->>K: exit(0)
  Note over C: ここから回収まではゾンビ
  K-->>P: 子101の終了コード 0
  Note over K: task_structを解放
シェルでlsを実行するときのfork→exec→waitの流れ。子が終了してから親が回収するまでの間、子はゾンビ(Z)として残る

ターミナルで ls と打ったとき、シェルは自分をforkし、子がexecで ls に置き換わり、シェルはwaitで終了を待っています。 「複製してから置き換える」のは回り道に見えますが、forkとexecの間で子が標準出力の向き先を変えたり(リダイレクト)、パイプをつないだりできるという利点があります。

コピーオンライト — forkが速い理由

親のメモリが1GBあるとき、forkのたびに1GBをコピーしていたら時間がかかります。 そこでLinuxはコピーオンライト(CoW)を使います。fork直後の親と子は同じ物理メモリを読み取り専用で共有し、どちらかが書き込んだページだけをその時点でコピーします。 forkの直後にexecする場合は、ほとんどのページをコピーせずに済みます。 ページテーブルの仕組みとCoWの詳細は第5章で扱います。 開いているファイルディスクリプタも子に引き継がれますが、その共有の仕方は第6章で見ます。

Node.jsの child_process.spawn は内部で何をしているか

Node.jsで spawn('ls', ['-l']) を呼ぶと、libuvの uv_spawn が子プロセスを作ります。 中でやっていることは基本的に上の図と同じで、子を作ってexecし、標準入出力をパイプでつなぎ、終了したらwaitで回収します。

ただ、ヒープを何GBも使っているNode.jsプロセスをforkすると、コピーオンライトでもページテーブルの複製に時間がかかります。 そこでlibuv 1.53.0(2026年9月24日リリース)では、uv_spawn が posix_spawn を使うようになりました。posix_spawn は「子を作ってすぐ別プログラムを実行する」をまとめて頼むAPIで、親のアドレス空間を複製する手間を省きやすくなります。 2026年10月時点のNode.js 26.13.0が同梱しているのはlibuv 1.52.1なので、Node.jsにこの変更が届くのはもう少し先です。

シグナル — プロセスへの割り込み通知

シグナルは、カーネルや他のプロセスからプロセスへ送られる短い通知です。 受け取ったプロセスは、シグナルごとに決まった既定動作(終了・無視・停止など)をするか、自分で登録したハンドラを実行します。 ただしSIGKILLとSIGSTOPだけは、ハンドラを登録することも無視することもできません。

シグナル番号(Linux x86-64)既定動作よく出会う場面
SIGINT2終了ターミナルでCtrl+C
SIGTERM15終了kill コマンドの既定。Kubernetesが停止時に最初に送る
SIGKILL9終了(捕捉不可)kill -9、OOM Killer、猶予期間を過ぎたPod
SIGHUP1終了端末が切れたとき。設定の再読み込みに使うデーモンも多い
SIGCHLD17無視子プロセスが終了・停止したことを親に知らせる
SIGPIPE13終了相手が閉じたパイプやソケットに書き込んだ。Node.jsは既定で無視する
SIGSTOP / SIGCONT19 / 18停止 / 再開Ctrl+Z(実際はSIGTSTP)やデバッガ
SIGUSR110終了Node.jsではインスペクタ(デバッガ)の起動用に予約

シグナル番号はOSやアーキテクチャで異なる場合があります。たとえばmacOSではSIGCHLDが20、SIGUSR1が30です。 SIGINT(2)、SIGKILL(9)、SIGTERM(15)は共通なので、この3つは番号ごと覚えておくと役に立ちます。

終了コード 128+N の正体

プロセスが自分で exit(3) すれば終了コードは3です。一方、シグナルで殺されたプロセスには終了コードがありません。 waitで親が受け取るのは「シグナルNで死んだ」という情報です。 シェルやDocker、Kubernetesはこれを人間に見せるとき、128 + シグナル番号という慣習の値に変換します。

表示される終了コード計算意味
130128 + 2SIGINT(Ctrl+C)で終了
137128 + 9SIGKILLで強制終了。KubernetesのOOMKilledもこれ
143128 + 15SIGTERMで終了。ハンドラを登録していないNode.jsを止めたとき

Node.jsの child_process では、この2つの情報が別々に届きます。'exit' イベントの引数は (code, signal) で、自分で終了した子なら code に数値が入り、シグナルで死んだ子なら code が null、signal にシグナル名が入ります。

Node.jsでSIGTERMを受け取る

Node.jsはSIGINTとSIGTERMに既定のハンドラを持っていて、端末の状態を戻してから128+Nで終了します。 ここで process.on('SIGTERM', ...) を登録すると、既定の終了動作は無効になります。 ハンドラの中で後始末をして、最後に自分で process.exit() を呼ぶ必要があります。

import http from "node:http";

const server = http.createServer((req, res) => res.end("ok"));
server.listen(3000);

// ハンドラを登録した時点で、SIGTERM の既定動作(即終了)は無効になる
process.on("SIGTERM", () => {
  console.log("SIGTERM を受信。新規接続の受付を止めます");
  server.close(() => {
    // 処理中のリクエストが終わったら自分で終了する
    process.exit(0);
  });
});

ハンドラを登録したのに process.exit() を呼び忘れると、SIGTERMを送っても止まらないサーバーになります。 Kubernetesでは猶予期間(既定30秒)が過ぎるとSIGKILLが送られ、終了コード137で強制終了されます。 keep-alive接続の扱いやPID 1の問題など、graceful shutdownの詳細は第11章で扱います。 なお、処理されなかったPromiseのrejectionは、Node.js 15以降は既定で終了コード1のクラッシュになります。こちらはシグナルとは関係のない、Node.js自身の終了です。

ゾンビと孤児

ゾンビ — 終わったのに消えないプロセス

子プロセスが終了すると、カーネルはメモリなどの資源を解放しますが、task_struct の一部とPID、終了コードだけは残します。 親がwaitで終了コードを受け取るまで、この終了済みで情報だけが残った状態がゾンビ(STATがZ、psでは <defunct>)です。 ゾンビはCPUもメモリもほとんど使いませんが、PIDを1つ占有します。親が回収しないままゾンビが溜まり続けると、PIDが枯渇して新しいプロセスを作れなくなります。

孤児 — 親が先に死んだ子

逆に、子より先に親が終了した場合、その子は孤児になります。孤児はPID 1のinit(systemdなど)か、subreaperとして登録されたプロセスに引き取られ、新しい親が最終的にwaitで回収します。 コンテナの中では、アプリ自身がPID 1になることが多く、この「孤児を引き取ってゾンビを回収する」役目を負わされます。 Node.jsは自分が起動した子しか回収しないため、tini や docker run --init を使います。詳しくは第11章で扱います。

実測: イベントループが止まるとゾンビが残る

Node.jsでは、子プロセスの回収もイベントループの仕事です。libuvは子の終了を知らせるSIGCHLDを受け取ると、イベントループの処理の中でwaitpidを呼んで回収します。 ということは、イベントループがブロックされている間は、終了した子が回収されずにゾンビのまま残るはずです。次のコードで確かめます。

import { spawn, execFileSync } from "node:child_process";

// すぐ終了する子プロセスを起動
const child = spawn("true");
console.log("child pid", child.pid);
child.on("exit", (code) => console.log("exit event code=", code));

setTimeout(() => {
  // イベントループを2秒間ブロックする(子はこの間に終了している)
  const end = Date.now() + 2000;
  while (Date.now() < end) {}

  // ブロック中に子の状態を ps で見る
  const ps = (pid: number) =>
    execFileSync("ps", ["-o", "pid,stat,command", "-p", String(pid)]).toString();
  console.log(ps(child.pid!));
}, 0);

手元のMac(Node.js 24.6.0)で実行した結果です。

child pid 98423
  PID STAT COMMAND
98423 Z    <defunct>

exit event code= 0

子は起動直後に終了しているのに、ブロック中の ps ではZ(ゾンビ)と表示されています。 ブロックが終わってイベントループが回り始めると、libuvが子を回収して 'exit' イベントが届き、その後に ps を実行すると子はもう見つかりません。 第10章で扱う「イベントループを止めてはいけない」理由は、HTTPの応答が遅れることだけではありません。こうしたOSとのやり取りの後始末も止まってしまいます。 Docker上のLinuxで同じことを試すと、ps のCMD列に [true] <defunct> のように表示されます。

手を動かす: 終了コードとシグナルを観察する

最後に、子プロセスの終わり方によって 'exit' イベントの値がどう変わるかを観察します。 ESMのトップレベルawaitを使うので、.mts として保存し、npx tsx exit.mts などで実行してください。

import { spawn } from "node:child_process";

function run(cmd: string, args: string[], killWith?: NodeJS.Signals) {
  return new Promise<void>((resolve) => {
    const child = spawn(cmd, args, { stdio: "inherit" });
    console.log(`spawned pid=${child.pid} ${cmd} ${args.join(" ")}`);
    // 100ms 後に指定のシグナルを送る
    if (killWith) setTimeout(() => child.kill(killWith), 100);
    child.on("exit", (code, signal) => {
      console.log(`  exit: code=${code} signal=${signal}`);
      resolve();
    });
  });
}

await run("sh", ["-c", "exit 3"]);      // 自分で終了コード3
await run("sleep", ["10"], "SIGTERM");  // SIGTERM で終了
await run("sleep", ["10"], "SIGKILL");  // SIGKILL で終了
spawned pid=97997 sh -c exit 3
  exit: code=3 signal=null
spawned pid=98017 sleep 10
  exit: code=null signal=SIGTERM
spawned pid=98133 sleep 10
  exit: code=null signal=SIGKILL

Node.jsの 'exit' イベントでは、シグナルで死んだ子の code は137や143ではなく null です。 128+Nはあくまでシェルやコンテナランタイムが表示用に作る値だと分かります。シェルで確かめると、こちらは137になります。

$ sh -c 'kill -9 $$'; echo "exit code: $?"
# (Killed などのメッセージはシェルによって表示が異なる)
exit code: 137

まとめ

プロセスは実行中のプログラムで、Linuxでは task_struct がPID、状態、メモリ、FDテーブル、シグナルの設定などをまとめて管理しています。 状態はR・S・D・T・Zで表され、D状態は kill -9 でもすぐには止まりません。 プロセスはforkで複製され、execで別のプログラムに置き換わり、親がwaitで回収するまではゾンビとして残ります。 シグナルで終わったプロセスは、シェルやコンテナでは128+Nの終了コードとして表示されます。Node.jsでSIGTERMのハンドラを登録したら、自分で終了処理を書く必要があります。

次の第4章では、プロセスの中で動くスレッドを扱います。forkの親戚であるclone()の共有フラグを見ながら、「何を共有するか」でプロセスとスレッドが分かれること、そしてLinuxのスケジューラがCPUをどう配っているかを見ていきます。

理解度チェック

問題 0 / 50%
Q1

psのSTAT列が「D」のプロセスについて、正しい説明はどれですか?

キーボード: 1〜4 で選択、Enter で回答