プロセス生成(fork/exec/wait)
Linuxで新しいプログラムを動かす基本は fork と exec の組み合わせです。fork は呼び出したプロセスをほぼ丸ごと複製し、親には子のPID、子には 0 を返すことで両者を区別させます。子プロセスが exec 系(execve など)を呼ぶと、自分の中身を別のプログラムに置き換えて実行を始めます。親は wait(waitpid)で子の終了を待ち受け、子の終了ステータスを回収します。これを怠ると子は「ゾンビプロセス」として残ります。シェルがコマンドを実行する仕組みも、まさにこの fork→exec→wait の流れです。
Linuxで「新しいプログラムを実行する」という当たり前の動作は、たった一つのシステムコールでは済まない。二つのシステムコール(syscall)の組み合わせで成り立っている。
一つはプロセスを複製するfork(fork)、もう一つは複製したプロセスの中身を別のプログラムに入れ替えるexec(exec)だ。
さらに、起動した子の最後を見届けるwaitを加えた三段構えが、自分でプロセスを起こすプログラムの基本骨格になる。
一見まわりくどいやり方に見えるが、この「いったん複製してから中身を入れ替える」という流れこそ、Unix系OSがプロセス生成を柔軟かつ堅牢に保ってきた設計の核心だ。
あなたがシェルでコマンドを打つたびに起きているのも、まさにこの流れそのものだ。まずは一つめの部品、複製から見ていこう。
👯 fork は分身したあと、どちらが自分かをどう知るのか
forkは、呼び出したプロセスをほぼ丸ごと複製し、もう一つのプロセスを生み出すシステムコールだ。複製した側を親プロセス、生み出された側を子プロセスと呼ぶ。
子は親のメモリ内容やプログラムカウンタをそっくり受け継ぐ。そのためforkが返った直後は、親も子も同じコードの同じ場所から実行を続ける。
ここで素朴な疑問が湧く。同じ場所から続くなら、どうやって自分が親なのか子なのかを見分けるのか。
鍵はforkの戻り値だ。親には、生まれた子のプロセスID(PID)という正の値が返り、子には0が返る。複製に失敗した場合は-1が返る。
この値の違いを使い、if (fork()) のように、戻り値が0以外なら親の処理、0なら子の処理、と条件分岐で書き分けるのが定石だ。
親子の違いは基本的にPIDと、親を指すPPID(親プロセスID)だけで、子のPPIDは親のPIDと一致する。これが親子関係の証拠になる。
丸ごと複製すると聞くと、メモリも倍食うのではと心配になるかもしれない。
なお現代のforkはコピーオンライトという賢い仕組みを使っていて、複製した直後は親子で同じメモリを共有し、どちらかが書き換えた瞬間に初めて、その書き換えた部分だけを実際に複製する。
これにより、大きなプロセスをforkしても無駄なメモリ消費を最小限に抑えられる。
🦋 exec は「作る」のではなく「化ける」
forkで作っただけの子は、まだ親のプログラムのコピーにすぎない。
ここでexec系のシステムコールを呼ぶと、そのプロセスの実行イメージが、指定した別の実行ファイルに丸ごと置き換わる。
中核となるのがexecveで、man 2 execveで引くと、実行ファイルのパス、コマンドライン引数の配列、環境変数の配列を渡すことが分かる。
ここで決定的に重要なのは、execは「新しいプロセスを作る」のではなく「いまのプロセスの中身を入れ替える」点だ。そのため、入れ替えてもプロセスIDは変わらない。生成ではなく変身なのだ。
そして成功するとイメージが完全に置き換わるため、execより後ろに書いたコードは二度と実行されない。
逆に言えば、execの直後のコードに処理が到達したということは、execが失敗した動かぬ証拠だ。だからexecの直後にはエラー処理を置く、というのがお決まりの書き方になる。
execveのほかにも、引数の渡し方や、環境変数を引き継ぐか、PATHからコマンドを探すかが異なるexecl・execlp・execv・execvpなどの仲間がある(末尾がpの系統はPATHを探索する)。
この「分身(fork)してから変身(exec)する」二段構えにより、子を起こす段階と、何のプログラムに化けさせるかという段階を分けて扱える。
🪦 子を見送らないと居座り続けるもの
子を起こしたら、親には最後の責任が残る。waitやwaitpidで子の終了を待ち受け、その終了ステータスを回収する後始末だ。
waitは子が終わるまで親を一時停止させ、終了した子のPIDを返す。
回収したステータスからは、専用のマクロを使って情報を取り出す。WIFEXITEDで正常終了かどうかを調べ、正常ならWEXITSTATUSで終了コード(0〜255の範囲)を取り出す。
シグナルによる終了かどうかはWIFSIGNALEDで、そのシグナル番号はWTERMSIGで分かる。
この後始末を怠るとどうなるか。終了した子は、終了情報だけを残した抜け殻、すなわちゾンビプロセスとして居座り続ける。
psで見るとステータス欄がZ、コマンド名のうしろに<defunct>と表示されるのが目印だ。
ゾンビが一つ二つあるだけなら実害は小さいが、待ち合わせを忘れたプログラムが子を量産し続けると、プロセス番号の枠を食い潰してシステムに支障をきたす。
では逆に、親が子より先に死んでしまったら、その子はゾンビになるのか。ならない。
親が子より先に終わってしまった場合、その子は孤児(orphan)と呼ばれ、ただちにPID 1の初期化プロセス(systemdなど)に引き取られる。引き取り先がきちんとwaitしてくれるため、孤児はゾンビにはならない。
🐚 シェルの中で三つが一本につながる
別々に学んだfork・exec・waitは、実は一連の流れとして組み合わさって使われる。
あなたがシェルでlsと打つたび、シェルは内部で次の三段階を踏んでいる。
まずforkで自分の分身(子)を作り、次にその子の中でexecしてlsという別プログラムに変身させ、最後に親であるシェルはwaitでlsの終了を待ってからプロンプトを返す。
コマンドの末尾に&を付けてバックグラウンド実行すると、最後の待ち合わせをすぐには行わず、プロンプトがすぐ戻ってくる、という違いになる。
この「分身→変身→待ち合わせ」のパターンは、自分でプロセスを起動する多くのプログラムに共通する土台だ。
組込みの世界でも、メインのプログラムが外部のコマンドや別の実行ファイルを起動するときには、内部でこの三段階が走っている。
実際の動きは、gccでコンパイルした自作プログラムをstrace -fで追うと自分の目で確かめられる。
-fは子プロセスまで追跡するオプションで、これを付けるとforkで分かれた先の動きまで見逃さずに追える。clone(現代のLinuxにおけるforkの実体)・execve・wait4(waitの実体)といった呼び出しが順番に並ぶ様子を観察できる。
fork・exec・waitという三つの部品が、頭の中の理屈ではなく、実際に発行される一連のシステムコールとしてつながって見えたとき、プロセス生成の仕組みが本当に自分のものになる。