fork()
プロセスが自分自身を複製する
forkはC言語のライブラリというより、Unixのシステムコール。libcがラッパーを提供しているけど、本体はカーネルの中にある。やっていることは単純で、呼び出したプロセスのコピーを作る。それだけ。それだけなんだけど、Unixのプロセスモデルの根幹をなしていて、これがないとシェルもサーバもまともに動かない。
#include <unistd.h>
pid_t fork(void);
引数なし。戻り値が2つの意味を持つ。ここがforkの一番わかりにくいところ。
1回の呼び出し、2回の戻り
fork() を呼ぶと、カーネルが呼び出し元プロセス(親)のほぼ完全なコピーを作って、新しいプロセス(子)として走らせる。コード、データ、スタック、ファイルディスクリプタ、環境変数、全部コピーされる。
で、fork() は親と子の両方に戻り値を返す。親プロセスには子のPID(正の整数)が返る。子プロセスには0が返る。失敗したら-1が返って子は生まれない。
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("child: my pid = %d, parent = %d\n",
getpid(), getppid());
} else {
printf("parent: my pid = %d, child = %d\n",
getpid(), pid);
}
return 0;
}
fork() の直後、世界が2つに分裂する。同じコードの同じ行から、2つのプロセスがそれぞれ独立に実行を続ける。戻り値だけが違う。この戻り値で if 分岐して、親と子に異なる仕事をさせるのが基本パターン。実行すると出力の順序は不定。親が先に出るかもしれないし、子が先に出るかもしれない。スケジューラ次第。
コピーオンライト
「プロセスのコピーを作る」と言ったけど、本当に全メモリをコピーしていたら遅くてしょうがない。現代のカーネルはコピーオンライト(COW)を使っている。
fork直後は親と子が同じ物理メモリページを共有していて、ページテーブルに読み取り専用のマークがつく。どちらかが書き込もうとした瞬間にページフォルトが発生して、カーネルがそのページだけをコピーする。書き込まないページは永遠にコピーされない。
だから fork() 自体はかなり軽い。巨大なプロセスをforkしても、実際にメモリを食うのは書き換えた分だけ。fork() 直後に exec() するパターンだと、子のアドレス空間はすぐ捨てられるから、COWの恩恵が最大になる。
fork + exec
forkだけだと、親と子が同じプログラムを実行するだけで面白くない。Unixでは「新しいプログラムを実行する」のに exec ファミリを使う。
pid_t pid = fork();
if (pid == 0) {
execlp("ls", "ls", "-la", NULL);
perror("exec");
_exit(1);
}
exec は現在のプロセスのコード・データを新しいプログラムで上書きする。PIDは変わらない。成功したら戻ってこない。
シェルが ls を実行するとき、内部でやっているのはまさにこれ。fork() で子を作って、子の中で exec("ls", ...) して、親は wait() で子の終了を待つ。
fork + exec + wait。この3つの組み合わせがUnixのプロセス管理の基本形。
waitとゾンビ
親が子の終了を回収しないとどうなるか。子プロセスは終了しているのにプロセステーブルにエントリが残り続ける。これがゾンビプロセス。ps で見ると状態が Z になっている。
pid_t pid = fork();
if (pid == 0) {
printf("child: done\n");
_exit(0);
}
/* 親がwaitしない → 子はゾンビになる */
sleep(60);
この60秒の間に別のターミナルで ps aux | grep Z すると、ゾンビが見える。ゾンビ自体はメモリをほとんど消費しないけど、PIDとプロセステーブルのエントリを占有する。大量に溜まるとPIDが枯渇する。サーバプログラムでforkしまくって回収しないのはバグ。
回収するには wait() か waitpid() を呼ぶ。WIFEXITED(status) で正常終了したかどうか、WEXITSTATUS(status) で終了コードが取れる。シグナルで死んだ場合は WIFSIGNALED と WTERMSIG。
逆に親が先に死んだらどうなるか。子はみなしご(orphan)になる。みなしごプロセスはinitプロセス(PID 1)に引き取られる。initは定期的に wait() しているから、みなしごがゾンビになることはない。
(授業で教授が「forkで親を殺す」って言ったとき、教室がちょっとだけ笑ったんだけど、正直さむかった。OSの講義あるあるだと思う。kill -9とかsignal送って親プロセスを殺す、みたいな用語が物騒すぎて、初見だと「え?」ってなるやつ。まぁ慣れるけどね。)
だからデーモンプロセスを作るときの古典的なパターンは「forkして親を即exitする」。子がみなしごになってinitに引き取られ、制御端末から切り離される。
pid_t pid = fork();
if (pid > 0)
_exit(0); /* 親は即死 */
setsid();
/* 子がデーモンとして動き続ける */
現代では systemd が面倒を見てくれるから手で書くことは減ったけど、何が起きているかを知っておく価値はある。
forkの落とし穴
いくつか有名なハマりどころがある。地味に全部forkの「コピー」に起因しているのが面白い。
printf は内部でバッファリングしていて、fork時にバッファの中身もコピーされる。親と子が両方 exit() すると、同じ内容が2回フラッシュされて出力が重複する。対策はforkの前に fflush(stdout) するか、子では _exit() を使う。_exit はバッファをフラッシュしないから二重出力が起きない。
バッファの話をしたついでに言うと、ファイルディスクリプタもコピーされる。ただしファイルオフセットは共有している(親と子が同じファイルテーブルエントリを指しているから)。片方が read() するともう片方のオフセットも進む。これを意図的に使うのが pipe() + fork() のパターンで、意図せずハマると厄介。
一番凶悪なのはマルチスレッドとの組み合わせ。マルチスレッドのプロセスをforkすると、forkを呼んだスレッドだけが子にコピーされる。他のスレッドは消える。もしその消えたスレッドがmutexを持っていたら、子プロセスでデッドロックする。解決策はシンプルで、マルチスレッドのプログラムでforkするならfork直後に exec() する。アドレス空間ごと入れ替えればデッドロックの種は消える。
pipe + fork
プロセス間通信の最も原始的な形。pipe() でパイプを作って、fork() して、親と子がパイプの片端ずつを使う。
int fd[2];
pipe(fd);
pid_t pid = fork();
if (pid == 0) {
close(fd[0]);
write(fd[1], "hello", 5);
close(fd[1]);
_exit(0);
}
close(fd[1]);
char buf[16];
int n = read(fd[0], buf, sizeof(buf));
buf[n] = '\0';
printf("received: %s\n", buf);
close(fd[0]);
waitpid(pid, NULL, 0);
シェルの ls | grep foo は内部でこれをやっている。pipe() でパイプを作って、1回目のforkで ls を実行する子を作り(標準出力をパイプの書き込み端にリダイレクト)、2回目のforkで grep を実行する子を作る(標準入力をパイプの読み出し端にリダイレクト)。
forkは地味な機能だけど、Unixの設計思想の核。「1つのことをうまくやる小さなプログラムを、パイプで繋ぐ」という哲学は、fork + exec + pipeの3つがあって初めて成立する。