第2章: プロセス制御理論とfork-execパラダイム - UNIXプロセス管理の計算機科学的基盤
2.1 プロセス生成モデルの比較分析
2.1.1 オペレーティングシステムにおけるプロセス生成
オペレーティングシステムにおいて、新しいプロセスを生成する方法は大きく2つのモデルに分類されます。この選択は、OSの設計思想全体に影響を与える重要な決定です。
【プロセス生成の2つのモデル】
1. Spawn モデル(Windows, VMS等)
┌─────────────────────────────────────────┐
│ CreateProcess(executable, args, ...) │
│ ↓ │
│ 新しいプロセスを作成 │
│ + 新しいプログラムをロード │
│ (単一の操作で完結) │
└─────────────────────────────────────────┘
2. Fork-Exec モデル(UNIX系)
┌─────────────────────────────────────────┐
│ fork() │
│ ↓ │
│ 現在のプロセスを複製 │
│ ↓ │
│ [中間状態: 親と同一のプログラム] │
│ ↓ │
│ execve() │
│ ↓ │
│ 新しいプログラムで置換 │
└─────────────────────────────────────────┘
2.1.2 Spawnモデルの特徴
Windows NTカーネル(David Cutler設計、1993年)は、VMS(Virtual Memory System)の経験を基にSpawnモデルを採用しました:
/* Windows APIによるプロセス生成(参考) */
CreateProcess(
"C:\\Windows\\notepad.exe", /* 実行ファイル */
"notepad.exe file.txt", /* コマンドライン */
NULL, /* プロセスセキュリティ */
NULL, /* スレッドセキュリティ */
FALSE, /* ハンドル継承 */
0, /* 作成フラグ */
NULL, /* 環境変数 */
NULL, /* 作業ディレクトリ */
&si, /* スタートアップ情報 */
&pi /* プロセス情報 */
);
Spawnモデルの利点:
- 単一のAPIコールでプロセス生成が完結
- 明示的なパラメータ指定により意図が明確
- 「無駄な」コピーが発生しない
Spawnモデルの欠点:
- APIが複雑になりがち(多くのパラメータ)
- プロセス生成前の柔軟な設定が困難
- 拡張性が低い
2.1.3 Fork-Execモデルの理論的美しさ
Ken ThompsonとDennis Ritchieは、プロセス生成を2つの直交する操作に分離しました。この設計決定は、ソフトウェア工学の関心の分離(Separation of Concerns)原則の見事な適用です。
【Fork-Execの直交性】
fork(): プロセス構造の複製
- メモリ空間のコピー
- ファイルディスクリプタのコピー
- 実行コンテキストのコピー
- プログラム自体は変更しない
exec(): プログラムの置換
- 新しいプログラムをロード
- メモリ空間を再初期化
- プロセス構造は維持
- FDやPIDは保持
この分離により:
1. 各操作が単純で理解しやすい
2. fork後、exec前に自由に設定変更可能
3. forkだけ、execだけを使うユースケースにも対応
Dennis Ritchieは1979年の論文「The Evolution of the Unix Time-sharing System」で、この設計について以下のように述べています:
> "The separation of fork and exec has proven to be extraordinarily versatile. It allows the parent to do things like redirect I/O before the new program runs, without the need for complex options in the process creation call."
2.1.4 Fork-Execの柔軟性
Fork-Execモデルの真価は、fork後からexec前の中間状態で任意の操作が可能な点にあります:
pid_t pid = fork();
if (pid == 0) {
/* 子プロセス: この中間状態で自由に設定可能 */
/* 1. ファイルディスクリプタの操作 */
close(STDIN_FILENO);
open("input.txt", O_RDONLY); /* FD 0 にバインド */
/* 2. 標準出力のリダイレクト */
int fd = open("output.txt", O_WRONLY | O_CREAT, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);
/* 3. 環境変数の変更 */
setenv("MY_VAR", "value", 1);
/* 4. シグナルハンドラのリセット */
signal(SIGINT, SIG_DFL);
/* 5. 作業ディレクトリの変更 */
chdir("/tmp");
/* 6. リソース制限の設定 */
struct rlimit rl = {1024, 1024};
setrlimit(RLIMIT_NOFILE, &rl);
/* 全ての設定完了後にexec */
execve("/bin/program", argv, envp);
exit(1);
}
この柔軟性こそが、UNIXパイプラインを可能にする基盤です。Pipexプロジェクトでは、この中間状態でパイプとファイルのリダイレクションを設定します。
2.2 仮想記憶とCopy-on-Write
2.2.1 仮想記憶(Virtual Memory)の基礎
fork()の効率的な実装を理解するためには、仮想記憶(Virtual Memory)の概念が不可欠です。仮想記憶は、1960年代にAtlas Computer(Manchester大学)で初めて実装され、現代の全てのOSの基盤となっています。
【仮想記憶の概念】
プロセスA プロセスB
┌─────────────────┐ ┌─────────────────┐
│ 仮想アドレス空間 │ │ 仮想アドレス空間 │
│ │ │ │
│ 0x0000_0000 │ │ 0x0000_0000 │
│ ... │ │ ... │
│ 0x7FFF_FFFF │ │ 0x7FFF_FFFF │
└────────┬────────┘ └────────┬────────┘
│ │
↓ ↓
┌─────────────────────────────────────────┐
│ ページテーブル │
│ 仮想アドレス → 物理アドレスの変換 │
└────────────────────┬────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 物理メモリ │
│ (プロセス間で共有される可能性あり) │
└─────────────────────────────────────────┘
各プロセスは独自の仮想アドレス空間を持ちますが、その背後にある物理メモリは共有されることがあります。これがCopy-on-Writeの鍵です。
2.2.2 Copy-on-Write(COW)メカニズム
Peter Denningが1970年代に提唱したCopy-on-Writeは、fork()の効率性を劇的に向上させました。
【Copy-on-Writeの動作】
Step 1: fork()直後
┌─────────────────┐ ┌─────────────────┐
│ 親プロセス │ │ 子プロセス │
│ ページテーブル │ │ ページテーブル │
├─────────────────┤ ├─────────────────┤
│ Page 0 → P100 │──────────────│ Page 0 → P100 │
│ Page 1 → P101 │──────────────│ Page 1 → P101 │
│ Page 2 → P102 │──────────────│ Page 2 → P102 │
└─────────────────┘ └─────────────────┘
│ │
└────────┬───────────────────────┘
↓
┌────────────────────────┐
│ 物理メモリ │
│ P100: 共有(読み取り専用)│
│ P101: 共有(読み取り専用)│
│ P102: 共有(読み取り専用)│
└────────────────────────┘
Step 2: 子プロセスがPage 1に書き込み
┌─────────────────┐ ┌─────────────────┐
│ 親プロセス │ │ 子プロセス │
├─────────────────┤ ├─────────────────┤
│ Page 0 → P100 │──────────────│ Page 0 → P100 │
│ Page 1 → P101 │ │ Page 1 → P200 │←(新規コピー)
│ Page 2 → P102 │──────────────│ Page 2 → P102 │
└─────────────────┘ └─────────────────┘
┌────────────────────────┐
│ 物理メモリ │
│ P100: 共有 │
│ P101: 親専用 │
│ P102: 共有 │
│ P200: 子専用(新規) │
└────────────────────────┘
COWにより、fork()はO(1)に近い時間で完了します。実際のメモリコピーは、書き込みが発生した時点でオンデマンドで行われます。
2.2.3 COWの実装メカニズム(Linux)
/* Linux カーネルでのCOW実装(概念的な説明) */
/* fork()時 */
for (each page in parent->mm) {
/* 読み取り専用フラグを設定 */
page->flags &= ~PAGE_WRITE;
/* 参照カウントを増加 */
page->ref_count++;
/* 子プロセスのページテーブルに同じページをマップ */
child->page_table[vaddr] = parent->page_table[vaddr];
}
/* 書き込み発生時(ページフォルト) */
void handle_page_fault(struct page *page) {
if (page->ref_count > 1) {
/* 複数プロセスが共有している */
/* 新しいページを割り当ててコピー */
struct page *new_page = alloc_page();
memcpy(new_page->data, page->data, PAGE_SIZE);
/* 現在のプロセスのページテーブルを更新 */
current->page_table[vaddr] = new_page;
/* 元のページの参照カウントを減少 */
page->ref_count--;
/* 新しいページに書き込み許可を付与 */
new_page->flags |= PAGE_WRITE;
} else {
/* 単独所有 - 書き込み許可を付与するだけ */
page->flags |= PAGE_WRITE;
}
}
2.2.4 fork()が高速な理由
従来の「素朴な」fork()実装と、COWベースの実装を比較します:
【素朴な実装(理論上)】
fork() {
for (all pages in parent) {
new_page = allocate_page();
memcpy(new_page, old_page, PAGE_SIZE);
map_page(child, new_page);
}
}
時間計算量: O(n) ※n = プロセスのメモリサイズ
1GBのプロセス → 数百ミリ秒
【COWベースの実装】
fork() {
for (all pages in parent) {
set_readonly(page);
increment_refcount(page);
map_page(child, same_page); /* 同じ物理ページを指す */
}
}
時間計算量: O(n) ※ただしnはページテーブルエントリ数
1GBのプロセス → マイクロ秒〜ミリ秒
(実際のコピーは書き込み時にのみ発生)
2.3 fork()システムコールの詳細
2.3.1 fork()のセマンティクス
#include <unistd.h>
pid_t fork(void);
fork()は、UNIXにおいて新しいプロセスを作成する唯一の方法です(clone()はLinux固有の拡張)。
【fork()の戻り値】
fork()
│
┌──────────┴──────────┐
↓ ↓
親プロセス 子プロセス
返り値: 子のPID 返り値: 0
(正の整数)
【エラー時】
返り値: -1
errno: EAGAIN (リソース不足) または ENOMEM (メモリ不足)
この「1回の呼び出しで2回の返り」という動作は、プログラミングにおいて極めて特異です。
2.3.2 fork()による複製の範囲
fork()は、親プロセスのほぼ全てを複製しますが、一部例外があります:
【複製されるもの】
✓ プロセスメモリ(テキスト、データ、ヒープ、スタック)
✓ ファイルディスクリプタテーブル
✓ シグナルハンドラ
✓ カレントディレクトリ
✓ umask
✓ 環境変数
✓ リソース制限
✓ 制御端末
【複製されないもの(または異なる値を持つ)】
✗ PID(子は新しいPIDを取得)
✗ PPID(子のPPIDは親のPID)
✗ ファイルロック(子には引き継がれない)
✗ 保留シグナル(子では空になる)
✗ プロセス時間(子では0にリセット)
✗ スレッド(子プロセスには呼び出しスレッドのみ)
2.3.3 ファイルディスクリプタの共有
fork()後、親子プロセスはファイルディスクリプタを共有します。これは重要な性質です:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(void)
{
int fd = open("test.txt", O_RDWR | O_CREAT, 0644);
pid_t pid = fork();
if (pid == 0) {
/* 子プロセス */
write(fd, "Child", 5);
lseek(fd, 0, SEEK_SET); /* 先頭に戻る */
exit(0);
} else {
/* 親プロセス */
wait(NULL);
write(fd, "Parent", 6); /* 子のlseekの影響を受ける! */
close(fd);
}
return 0;
}
/* 結果: ファイルには "Parent" が書かれる
(子のlseekで先頭に戻ったため、親の書き込みが上書き) */
【ファイルディスクリプタ共有の図解】
fork()前:
┌─────────────┐ ┌───────────────────┐ ┌──────────┐
│ 親プロセス │ │ オープンファイル │ │ ファイル │
│ FD 3 ────────├────→│ テーブルエントリ │────→│ inode │
└─────────────┘ │ offset: 0 │ └──────────┘
│ flags: O_RDWR │
└───────────────────┘
fork()後:
┌─────────────┐ ┌───────────────────┐ ┌──────────┐
│ 親プロセス │ │ オープンファイル │ │ ファイル │
│ FD 3 ────────├──┬─→│ テーブルエントリ │────→│ inode │
└─────────────┘ │ │ offset: 0 │ └──────────┘
│ │ flags: O_RDWR │
┌─────────────┐ │ │ ref_count: 2 │
│ 子プロセス │ │ └───────────────────┘
│ FD 3 ────────├──┘
└─────────────┘
※ 親子は同じオープンファイルテーブルエントリを共有
※ offsetの変更は互いに影響する
2.3.4 fork()の実践パターン
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid;
printf("開始: PID = %d\n", getpid());
pid = fork();
if (pid == -1) {
/* エラー処理 */
perror("fork");
return 1;
}
if (pid == 0) {
/* 子プロセスのコード */
printf("子: PID = %d, PPID = %d\n", getpid(), getppid());
/* 子プロセス固有の処理 */
sleep(1);
printf("子: 終了\n");
return 42; /* 終了コード */
}
/* 親プロセスのコード */
printf("親: PID = %d, 子のPID = %d\n", getpid(), pid);
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("親: 子の終了コード = %d\n", WEXITSTATUS(status));
}
return 0;
}
2.4 execファミリーの理論と実践
2.4.1 execの歴史と設計
exec系システムコールは、現在のプロセスを新しいプログラムで完全に置き換えます。成功すると呼び出し元には戻りません。
この「戻らない」性質は、初学者に混乱を招きますが、理論的には自然です:
【execの動作原理】
execve()呼び出し前:
┌─────────────────────────────────────┐
│ プロセス (PID: 1234) │
├─────────────────────────────────────┤
│ テキスト: 現在のプログラム │
│ データ: 現在のプログラムのデータ │
│ ヒープ: 現在の動的メモリ │
│ スタック: 現在の関数呼び出し │
├─────────────────────────────────────┤
│ FDテーブル: [0,1,2,3,...] │
│ PID: 1234 │
│ PPID: 1000 │
└─────────────────────────────────────┘
execve("/bin/ls", ...) 実行:
┌─────────────────────────────────────┐
│ プロセス (PID: 1234) │ ← 同じPID
├─────────────────────────────────────┤
│ テキスト: /bin/ls のコード │ ← 新しい
│ データ: /bin/ls のデータ │ ← 新しい
│ ヒープ: 空 │ ← リセット
│ スタック: main()から開始 │ ← リセット
├─────────────────────────────────────┤
│ FDテーブル: [0,1,2,3,...] │ ← 継承
│ PID: 1234 │ ← 同じ
│ PPID: 1000 │ ← 同じ
└─────────────────────────────────────┘
※ 元のプログラムは完全に消滅
※ 「戻る」場所が存在しない
2.4.2 execファミリーの分類
POSIX/UNIXは複数のexec関数を提供していますが、実際のシステムコールはexecve()のみです。他はライブラリ関数としてexecve()をラップしています:
/* 唯一のシステムコール */
int execve(const char *pathname, char *const argv[], char *const envp[]);
/* ライブラリ関数(execveのラッパー) */
int execl(const char *pathname, const char *arg, ... /*, NULL */);
int execlp(const char *file, const char *arg, ... /*, NULL */);
int execle(const char *pathname, const char *arg, ... /*, NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
【exec*関数の命名規則】
exec + [l|v] + [p] + [e]
l: 引数をリスト(可変長引数)で指定
v: 引数を配列(vector)で指定
p: PATHを検索してコマンドを探す
e: 環境変数を明示的に指定
例:
execl → リスト形式、パス必須、環境継承
execvp → 配列形式、PATH検索、環境継承
execve → 配列形式、パス必須、環境明示(システムコール)
2.4.3 execve()の詳細
#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
/*
* pathname: 実行ファイルの絶対パスまたは相対パス
* argv: 引数配列(argv[0]はプログラム名、NULL終端)
* envp: 環境変数配列("KEY=VALUE"形式、NULL終端)
*
* 戻り値:
* 成功時: 戻らない(新しいプログラムが実行される)
* 失敗時: -1 を返す
*/
/* execve()の正しい使用例 */
#include <unistd.h>
#include <stdio.h>
int main(void)
{
char *pathname = "/bin/ls";
char *argv[] = {
"ls", /* argv[0]: 慣例的にプログラム名 */
"-l", /* argv[1]: 第1引数 */
"-a", /* argv[2]: 第2引数 */
"/tmp", /* argv[3]: 第3引数 */
NULL /* 終端: 必須 */
};
char *envp[] = {
"PATH=/usr/bin:/bin",
"HOME=/home/user",
"LANG=C",
NULL /* 終端: 必須 */
};
printf("execve()を呼び出します...\n");
fflush(stdout); /* バッファをフラッシュ(重要) */
if (execve(pathname, argv, envp) == -1) {
perror("execve"); /* ここに来たらエラー */
return 1;
}
/* この行は実行されない */
printf("この行は表示されません\n");
return 0;
}
2.4.4 execが失敗するケース
#include <errno.h>
void handle_exec_error(const char *path)
{
switch (errno) {
case ENOENT:
/* ファイルが存在しない */
fprintf(stderr, "%s: No such file or directory\n", path);
exit(127); /* シェル慣例: コマンドが見つからない */
break;
case EACCES:
/* 実行権限がない */
fprintf(stderr, "%s: Permission denied\n", path);
exit(126); /* シェル慣例: 実行不可 */
break;
case ENOEXEC:
/* 実行可能フォーマットでない */
fprintf(stderr, "%s: Exec format error\n", path);
exit(126);
break;
case ENOMEM:
/* メモリ不足 */
fprintf(stderr, "%s: Cannot allocate memory\n", path);
exit(1);
break;
case E2BIG:
/* 引数リストが長すぎる */
fprintf(stderr, "%s: Argument list too long\n", path);
exit(1);
break;
default:
perror(path);
exit(1);
}
}
void execute_command(char *path, char **argv, char **envp)
{
execve(path, argv, envp);
handle_exec_error(path); /* execveが失敗した場合のみ実行 */
}
2.4.5 PATH環境変数の解決
シェルは、コマンド名からフルパスを解決するためにPATH環境変数を使用します:
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
/*
* PATH環境変数からコマンドを検索
*
* 例:
* PATH=/usr/local/bin:/usr/bin:/bin
* cmd = "grep"
*
* 検索順序:
* 1. /usr/local/bin/grep → access() で確認
* 2. /usr/bin/grep → access() で確認
* 3. /bin/grep → access() で確認
*/
char *resolve_path(const char *cmd, char **envp)
{
char *path_env = NULL;
char *path_copy;
char *dir;
char *full_path;
size_t cmd_len = strlen(cmd);
/* コマンドがパスを含む場合はそのまま使用 */
if (strchr(cmd, '/') != NULL) {
if (access(cmd, X_OK) == 0)
return strdup(cmd);
return NULL;
}
/* 環境変数からPATHを取得 */
for (int i = 0; envp[i]; i++) {
if (strncmp(envp[i], "PATH=", 5) == 0) {
path_env = envp[i] + 5;
break;
}
}
if (!path_env)
return NULL;
path_copy = strdup(path_env);
if (!path_copy)
return NULL;
/* ':'で分割して各ディレクトリを検索 */
dir = strtok(path_copy, ":");
while (dir != NULL) {
/* full_path = dir + "/" + cmd + "\0" */
size_t dir_len = strlen(dir);
full_path = malloc(dir_len + 1 + cmd_len + 1);
if (!full_path) {
free(path_copy);
return NULL;
}
strcpy(full_path, dir);
strcat(full_path, "/");
strcat(full_path, cmd);
if (access(full_path, X_OK) == 0) {
free(path_copy);
return full_path;
}
free(full_path);
dir = strtok(NULL, ":");
}
free(path_copy);
return NULL;
}
2.5 waitpid()とプロセスの終了処理
2.5.1 プロセスの終了とゾンビ
プロセスが終了しても、その情報は親プロセスが回収するまでカーネル内に残り続けます。この状態のプロセスをゾンビ(Zombie)と呼びます。
【プロセスのライフサイクル】
┌─────────┐
│ INIT │ ← fork()で作成された直後
└────┬────┘
│
↓
┌─────────┐
│ READY │ ← 実行可能状態
└────┬────┘
│
↓ (スケジューラにより選択)
┌─────────┐
│ RUNNING │ ← CPU上で実行中
└────┬────┘
│
│ (I/O待ち等)
↓
┌─────────┐
│ WAITING │ ← I/O完了等を待機
└────┬────┘
│
↓ (exit()呼び出し)
┌─────────┐
│ ZOMBIE │ ← 終了したが親がwait()していない
└────┬────┘
│
↓ (親がwait()を呼び出し)
┌─────────┐
│ REMOVED │ ← 完全に削除
└─────────┘
2.5.2 なぜゾンビプロセスが必要か
ゾンビプロセスは一見無駄に見えますが、重要な目的があります:
【ゾンビプロセスの役割】
親プロセスが知る必要がある情報:
1. 子プロセスの終了コード(成功/失敗)
2. 終了の原因(正常終了/シグナル)
3. リソース使用統計(CPU時間、メモリ等)
これらの情報は、子プロセスが終了した後も
保持されている必要がある。
→ 親がwait()を呼ぶまで、カーネルが情報を保持
→ これがゾンビ状態
ゾンビプロセスはほとんどリソースを消費しませんが(プロセスエントリのみ)、大量に蓄積するとプロセステーブルを圧迫します。
2.5.3 waitpid()の詳細
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *wstatus, int options);
/*
* pid:
* > 0 : 指定したPIDの子プロセスを待つ
* -1 : 任意の子プロセスを待つ(wait()と同等)
* 0 : 呼び出し元と同じプロセスグループの子を待つ
* < -1 : |pid|のプロセスグループに属する子を待つ
*
* wstatus:
* 終了ステータスを格納するポインタ(NULLも可)
*
* options:
* WNOHANG : ブロックせず即座に戻る
* WUNTRACED : 停止した子も報告
* WCONTINUED: 再開した子も報告(Linux拡張)
*
* 戻り値:
* > 0 : 状態変化した子のPID
* 0 : WNOHANG指定時に、まだ終了した子がいない
* -1 : エラー(errnoにエラーコード)
*/
2.5.4 終了ステータスの解析マクロ
#include <sys/wait.h>
int wstatus;
waitpid(pid, &wstatus, 0);
/* 正常終了したか */
if (WIFEXITED(wstatus)) {
/* 終了コード(exit()に渡した値)を取得 */
int exit_code = WEXITSTATUS(wstatus);
printf("正常終了: コード = %d\n", exit_code);
}
/* シグナルで終了したか */
if (WIFSIGNALED(wstatus)) {
/* 終了を引き起こしたシグナル番号 */
int sig = WTERMSIG(wstatus);
printf("シグナル終了: シグナル = %d\n", sig);
/* コアダンプが生成されたか(POSIX外だが広くサポート) */
#ifdef WCOREDUMP
if (WCOREDUMP(wstatus)) {
printf("コアダンプ生成\n");
}
#endif
}
/* 停止したか(WUNTRACED使用時) */
if (WIFSTOPPED(wstatus)) {
int sig = WSTOPSIG(wstatus);
printf("停止: シグナル = %d\n", sig);
}
/* 再開したか(WCONTINUED使用時、Linux拡張) */
#ifdef WIFCONTINUED
if (WIFCONTINUED(wstatus)) {
printf("再開\n");
}
#endif
2.5.5 複数の子プロセスの管理
Pipexでは複数の子プロセスを生成するため、適切な待機が必要です:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t pids[2];
int status;
/* 2つの子プロセスを生成 */
pids[0] = fork();
if (pids[0] == 0) {
/* 第1子 */
sleep(2);
printf("子1: 終了\n");
exit(1);
}
pids[1] = fork();
if (pids[1] == 0) {
/* 第2子 */
sleep(1);
printf("子2: 終了\n");
exit(2);
}
/* 親プロセス: 両方の子を待つ */
printf("親: 子プロセスを待機中...\n");
/* 方法1: 特定の順序で待つ */
waitpid(pids[0], &status, 0);
printf("子1が終了 (コード: %d)\n", WEXITSTATUS(status));
waitpid(pids[1], &status, 0);
printf("子2が終了 (コード: %d)\n", WEXITSTATUS(status));
/* 方法2: 終了順に処理(任意の子を待つ) */
/*
for (int i = 0; i < 2; i++) {
pid_t finished = waitpid(-1, &status, 0);
printf("PID %d が終了\n", finished);
}
*/
return 0;
}
2.5.6 ノンブロッキング待機
WNOHANGオプションを使用すると、子プロセスを待ちながら他の処理を続けられます:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid == 0) {
sleep(3);
exit(42);
}
/* ノンブロッキングで定期的にチェック */
int status;
pid_t result;
while (1) {
result = waitpid(pid, &status, WNOHANG);
if (result == -1) {
perror("waitpid");
break;
} else if (result == 0) {
/* 子はまだ実行中 */
printf("子プロセスを待機中...\n");
sleep(1);
} else {
/* 子が終了した */
if (WIFEXITED(status)) {
printf("子が終了: コード = %d\n", WEXITSTATUS(status));
}
break;
}
}
return 0;
}
2.6 孤児プロセスとinitの役割
2.6.1 孤児プロセスとは
親プロセスが子より先に終了すると、子プロセスは孤児(Orphan)になります:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid = fork();
if (pid == 0) {
/* 子プロセス */
printf("子: PID=%d, PPID=%d\n", getpid(), getppid());
sleep(2);
/* 親が終了した後 */
printf("子: PID=%d, PPID=%d\n", getpid(), getppid());
/* PPIDが1(init)または特定のサブシステムPIDに変わる */
} else {
/* 親プロセス: 即座に終了 */
printf("親: 終了します\n");
exit(0);
}
return 0;
}
2.6.2 initによるゾンビの回収
孤児プロセスはinitプロセス(PID 1)に引き取られ、initがwait()を呼んでゾンビを回収します:
【孤児プロセスの再親化(Re-parenting)】
Step 1: 通常の親子関係
Parent (PID: 100)
│
└── Child (PID: 101, PPID: 100)
Step 2: 親が終了
Parent (PID: 100) [終了]
│
└── Child (PID: 101, PPID: ???)
Step 3: initが養子として引き取り
init (PID: 1)
│
└── Child (PID: 101, PPID: 1)
※ 子が終了すると、initがwait()を呼んでゾンビを回収
2.7 fork-exec パターンの実装
2.7.1 基本パターン
Pipexで使用する基本的なfork-execパターン:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
void execute_command(char *cmd, char **args, char **envp)
{
pid_t pid;
int status;
pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子プロセス */
/* ここでリダイレクション等の設定 */
/* ... */
/* コマンドを実行 */
execve(cmd, args, envp);
/* execveが失敗した場合のみここに到達 */
perror("execve");
exit(EXIT_FAILURE);
}
/* 親プロセス */
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("終了コード: %d\n", WEXITSTATUS(status));
}
}
2.7.2 パイプと組み合わせたパターン
Pipexの核心となるパターン:
void run_pipeline(char *cmd1, char *cmd2, char **envp)
{
int pipefd[2];
pid_t pid1, pid2;
/* パイプを作成 */
if (pipe(pipefd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
/* 第1子プロセス: cmd1 */
pid1 = fork();
if (pid1 == 0) {
/* 標準出力をパイプの書き込み端に接続 */
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]); /* 読み込み端は不要 */
close(pipefd[1]); /* 複製したので元は不要 */
/* コマンド実行 */
char *args1[] = {cmd1, NULL};
execve(cmd1, args1, envp);
exit(EXIT_FAILURE);
}
/* 第2子プロセス: cmd2 */
pid2 = fork();
if (pid2 == 0) {
/* 標準入力をパイプの読み込み端に接続 */
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[1]); /* 書き込み端は不要 */
close(pipefd[0]); /* 複製したので元は不要 */
/* コマンド実行 */
char *args2[] = {cmd2, NULL};
execve(cmd2, args2, envp);
exit(EXIT_FAILURE);
}
/* 親プロセス: パイプを閉じて子を待つ */
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);
}
2.8 エラー処理とリソース管理
2.8.1 シェル互換の終了コード
#define EXIT_CMD_NOT_FOUND 127
#define EXIT_CMD_NOT_EXEC 126
#define EXIT_GENERAL_ERROR 1
void exec_with_proper_exit(char *path, char **argv, char **envp)
{
if (access(path, F_OK) == -1) {
/* ファイルが存在しない */
fprintf(stderr, "%s: command not found\n", argv[0]);
exit(EXIT_CMD_NOT_FOUND);
}
if (access(path, X_OK) == -1) {
/* 実行権限がない */
fprintf(stderr, "%s: permission denied\n", argv[0]);
exit(EXIT_CMD_NOT_EXEC);
}
execve(path, argv, envp);
/* execveが失敗した場合 */
perror(argv[0]);
exit(EXIT_GENERAL_ERROR);
}
2.8.2 リソースリークの防止
/* 子プロセスで確実にリソースをクリーンアップ */
void child_cleanup(int *fds, int fd_count, char **strings, char *path)
{
/* ファイルディスクリプタを閉じる */
for (int i = 0; i < fd_count; i++) {
if (fds[i] >= 0) {
close(fds[i]);
}
}
/* 文字列配列を解放 */
if (strings) {
for (int i = 0; strings[i]; i++) {
free(strings[i]);
}
free(strings);
}
/* パスを解放 */
if (path) {
free(path);
}
}
void safe_child_exec(char *cmd, char **envp, int *extra_fds, int fd_count)
{
char **args = parse_command(cmd);
char *path = resolve_path(args[0], envp);
if (!path) {
fprintf(stderr, "%s: command not found\n", args[0]);
child_cleanup(extra_fds, fd_count, args, NULL);
exit(127);
}
execve(path, args, envp);
/* execveが失敗 */
perror(args[0]);
child_cleanup(extra_fds, fd_count, args, path);
exit(126);
}
2.9 学習リソース
推奨書籍
- "The Linux Programming Interface"
- "Operating Systems: Three Easy Pieces"
歴史的資料
- Ritchie, D. M. (1979). "The Evolution of the Unix Time-sharing System"
- Thompson, K., & Ritchie, D. M. (1974). "The UNIX Time-Sharing System"
- Bach, M. J. (1986). "The Design of the UNIX Operating System"
2.10 次章への準備
次章では、ファイルディスクリプタとdup2()を詳しく学び、入出力のリダイレクションを実装します。以下の準備課題を実行してください:
/* 準備課題: ファイルディスクリプタの動作確認 */
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main(void)
{
int fd;
printf("標準FD: stdin=%d, stdout=%d, stderr=%d\n",
STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO);
/* ファイルを開いてFDを確認 */
fd = open("test.txt", O_CREAT | O_WRONLY, 0644);
printf("開いたファイルのFD: %d\n", fd);
/* FDを閉じて再度開く */
close(fd);
fd = open("test2.txt", O_CREAT | O_WRONLY, 0644);
printf("2番目のファイルのFD: %d\n", fd);
/* 質問: なぜ両方とも同じFD番号なのか? */
close(fd);
return 0;
}
---
まとめ: この章では、fork-execパラダイムの理論的基盤、Copy-on-Writeによる効率化、waitpid()によるプロセス終了処理を学びました。これらの知識は、Pipexプロジェクトでパイプラインを構築する際の基盤となります。次章では、ファイルディスクリプタの詳細とdup2()によるリダイレクションを実装レベルで解説します。