本文へスキップ

fork・exec・waitで理解するプロセス生成の流れ

Unix系OSでプロセスがどのように作られ、別のプログラムとして実行され、親プロセスに回収されるのかを、fork・exec・waitの役割から順番に解説します。

読了目安: 約8分

この記事を共有

OSのプロセス生成は、はじめて見ると少し不思議です。

たとえばシェルで次のようにコマンドを実行したとします。

Terminal window
ls -la

直感的には「シェルが ls を実行した」と言いたくなります。 しかしUnix系OSでは、シェル自身がそのまま ls に置き換わるわけではありません。

大まかな流れは次の3段階です。

  1. fork() でシェルのコピーである子プロセスを作る
  2. 子プロセスが exec()ls に置き換わる
  3. 親プロセスであるシェルが wait() で子プロセスの終了を待つ
fork・exec・wait の全体像
親プロセス shell fork 子プロセスを作る exec 子を別プログラムに置換 wait 終了状態を回収

この記事では、シェルがコマンドを実行する場面を例に、fork()exec()wait() の役割を整理します。

プロセスとは何か

まず、プロセスは実行中のプログラムです。

プログラムはディスク上にあるファイルです。 プロセスは、そのプログラムがメモリに読み込まれ、CPUで実行されている状態です。

プロセスは次のような情報を持っています。

情報説明
PIDプロセスを識別する番号
メモリ空間コード、スタック、ヒープ、グローバル変数など
レジスタCPUが現在どこを実行しているかの状態
ファイルディスクリプタ標準入力、標準出力、開いているファイルなど
終了ステータス正常終了・異常終了などの結果

ここで重要なのは、OSがプログラムのファイルと実行中のプロセスを別物として扱うことです。

fork() はプロセスを増やし、exec() はプロセスの中身を別のプログラムに置き換え、wait() は子プロセスの終了結果を親が受け取るために使われます。

fork: 親プロセスを複製する

fork() は、現在実行中のプロセスを複製して子プロセスを作ります。

pid_t pid = fork();

fork() を呼び出すと、呼び出し元のプロセスとほぼ同じ状態を持った子プロセスが作られます。 メモリの内容、開いているファイル、実行位置などが引き継がれます。

ただし、親と子で完全に同じだと区別できません。 そこで fork() の戻り値が変わります。

戻り値どのプロセスか意味
正の値親プロセス作成された子プロセスのPID
0子プロセス自分は子プロセスである
-1親プロセス子プロセスの作成に失敗

つまり、fork() の後は、親と子の両方が同じ続きのコードを実行します。 どちらのプロセスで動いているかは、戻り値で判定します。

pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("child process\n");
} else {
printf("parent process: child pid = %d\n", pid);
}

この「同じコードの続きから、親と子が別々に走り出す」という性質が、fork() の一番重要な点です。

実際には全部コピーしているとは限らない

現代のOSでは、fork() の瞬間にメモリ全体を物理的にコピーするとは限りません。

多くの場合は Copy-on-Write という仕組みで、親と子が同じメモリページを共有し、どちらかが書き込んだ時点で必要な部分だけコピーします。

そのため、fork() は「論理的にはコピー」ですが、実装上は効率化されています。

exec: プロセスの中身を別プログラムに置き換える

fork() で子プロセスを作っただけでは、子は親と同じプログラムを実行しています。 シェルが ls を実行したいなら、子プロセスの中身を ls に置き換える必要があります。

そこで使うのが exec() 系のシステムコールです。

execlp("ls", "ls", "-la", NULL);

exec() は新しいプロセスを作る関数ではありません。 現在のプロセスのメモリ空間を、指定したプログラムで置き換える関数です。

exec() が成功すると、呼び出し元のプログラムには戻ってきません。 呼び出し元のコード自体が、新しいプログラムに置き換わるためです。

printf("before exec\n");
execlp("ls", "ls", "-la", NULL);
printf("after exec\n");

この例では、execlp() が成功すれば after exec は表示されません。 printf("after exec\n") を含む元のプログラムは、すでに ls に置き換わっているためです。

一方、exec() が失敗した場合だけ戻ってきます。 そのため、exec() の直後にはエラー処理を書くのが基本です。

execlp("ls", "ls", "-la", NULL);
perror("execlp");
return 1;

wait: 子プロセスの終了を待って回収する

親プロセスは fork() で子プロセスを作った後、子の終了を待つことができます。 そのために使うのが wait()waitpid() です。

int status;
pid_t finished = wait(&status);

wait() は、いずれかの子プロセスが終了するまで親プロセスを待たせます。 子プロセスが終了すると、そのPIDと終了ステータスを受け取ります。

シェルでコマンドを実行したとき、コマンドが終わるまで次のプロンプトが表示されないのは、親であるシェルが子プロセスを wait() しているからです。

$ ls -la
... ls の出力 ...
$

ls が終わるまで、シェルは次の入力を受け付けません。 ls が終了し、シェルがその終了状態を回収してから、次のプロンプトを表示します。

waitしないとゾンビプロセスが残る

子プロセスが終了しても、親が終了ステータスを回収するまでは、OSは最小限のプロセス情報を残します。 この状態のプロセスをゾンビプロセスと呼びます。

ゾンビプロセスはすでに実行を終えていますが、親が wait() していないため、プロセステーブル上に終了結果だけが残っています。

シェルがコマンドを実行する流れ

ここまでの内容を、シェルが ls -la を実行する場面に戻して整理します。

shell が ls -la を実行する流れ
shell ユーザー入力を読む fork() shell の子プロセスを作る child exec ls -la parent wait で終了を待つ shell プロンプトを再表示

親プロセスであるシェルは、まず fork() で子プロセスを作ります。 子プロセスは exec()ls に置き換わります。 親プロセスは wait()ls の終了を待ちます。

これをCで書くと、最小構成は次のようになります。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
execlp("ls", "ls", "-la", NULL);
perror("execlp");
return 1;
}
int status;
if (waitpid(pid, &status, 0) < 0) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
printf("child exited with status %d\n", WEXITSTATUS(status));
}
return 0;
}

このコードのポイントは、fork() の戻り値を使って親と子の処理を分けていることです。

場所処理
子プロセスexeclp("ls", "ls", "-la", NULL)ls に置き換わる
親プロセスwaitpid(pid, &status, 0) で特定の子を待つ

wait() はどれか1つの子を待ちますが、waitpid() は待つ対象のPIDを指定できます。 実際のプログラムでは、どの子プロセスを待つのかを明確にできる waitpid() の方が扱いやすい場面が多いです。

なぜforkとexecが分かれているのか

ここで疑問になるのは、なぜ「新しいプログラムを実行する」処理が fork()exec() に分かれているのかです。 最初から「別プログラムを子プロセスとして起動する」関数が1つあればよさそうに見えます。

分かれていることで、fork()exec() の間に親から引き継いだ状態を調整できます。

たとえばシェルは、子プロセスで exec() する前に次のような準備をします。

  • 標準入力をファイルに差し替える
  • 標準出力をファイルに差し替える
  • パイプの読み書き口をつなぐ
  • 環境変数を設定する
  • カレントディレクトリを変更する

リダイレクトはその代表例です。

Terminal window
ls -la > files.txt

この場合、シェルは子プロセスを fork() した後、子の標準出力を files.txt に差し替えてから exec() します。 ls 自体は「自分の標準出力が端末なのかファイルなのか」を強く意識しなくても、普通に標準出力へ書き込めばよいわけです。

リダイレクトができる理由
fork 子を作る dup2 stdout を files.txt へ exec ls を実行 files.txt 出力先

この分離により、プログラムを起動する仕組みと、入出力や環境を組み替える仕組みを柔軟に組み合わせられます。

バックグラウンド実行ではwaitしない

通常のコマンドでは、シェルは子プロセスを wait() します。 しかし、末尾に & を付けたバックグラウンド実行では挙動が変わります。

Terminal window
sleep 10 &

この場合、シェルは子プロセスを起動した後、すぐにプロンプトを返します。 つまり、フォアグラウンド実行のようには待ちません。

$ sleep 10 &
[1] 12345
$

ただし、まったく回収しなくてよいわけではありません。 バックグラウンドジョブが終了したとき、シェルは後でその終了状態を回収する必要があります。 そのため、実際のシェルはシグナルなどを使って、終了した子プロセスを適切に処理します。

まとめ

fork()exec()wait() は、それぞれ役割がはっきり分かれています。

システムコール役割イメージ
fork()子プロセスを作る親プロセスのコピーを作る
exec()別プログラムに置き換える子プロセスが ls などに変身する
wait()子の終了を回収する親が結果を受け取る

シェルがコマンドを実行するときの基本形は、次の流れです。

  1. 親であるシェルが fork() する
  2. 子プロセスが exec() でコマンドに置き換わる
  3. 親プロセスが wait() で子の終了を待つ
  4. 終了状態を回収して、次のプロンプトを表示する

この流れを押さえると、リダイレクト、パイプ、バックグラウンド実行、ゾンビプロセスといったOSの話題もつながって理解しやすくなります。

コメント

感想や補足をお寄せください。承認後に公開されます。

コメントを読み込んでいます…