第1章: UNIXプロセスモデルとパイプライン理論 - プロセス間通信の計算機科学的基盤
1.1 計算機における「プロセス」概念の誕生
1.1.1 バッチ処理時代の計算モデル(1940年代-1960年代初頭)
コンピュータが誕生した当初、「プロセス」という概念は存在しませんでした。ENIAC(1945年)やUNIVAC(1951年)の時代、計算機は単一のプログラムを実行する専用機械として運用されていました。
【1950年代の計算モデル】
オペレータ → パンチカード投入 → 計算機 → 結果出力
↓
1つのプログラムが
マシン全体を占有
(数時間〜数日)
この時代の計算機利用は極めて非効率でした。IBM 7094(1962年)のようなメインフレームは、プログラムのロード、実行、結果の印刷を順番に行うバッチ処理(Batch Processing)で運用され、CPUはI/O待ちの間アイドル状態になっていました。
1.1.2 マルチプログラミングとプロセス概念の登場
1960年代初頭、Fernando Corbatóが率いるMITのプロジェクトMAC(1963年設立)において、時分割処理(Time-Sharing)の研究が本格化しました。この研究から、コンピュータ科学史上最も重要なオペレーティングシステムMultics(Multiplexed Information and Computing Service, 1964-1969)が誕生します。
Multicsプロジェクトにおいて、「プロセス」という抽象化が明確に定義されました。プロセスとは:
【プロセスの形式的定義(Saltzer, 1966)】
プロセス = (プログラムコード, データ, 実行状態, リソース)
- プログラムコード: 実行される命令列
- データ: プログラムが操作するメモリ領域
- 実行状態: プログラムカウンタ、レジスタ値
- リソース: ファイル、I/Oデバイスへのアクセス権
Jerome Saltzerは1966年の論文「Traffic Control in a Multiplexed Computer System」において、プロセスを「実行中のプログラムのインスタンス」として定義し、この定義は現代まで使用されています。
1.1.3 プロセス状態遷移モデル
Multicsの設計チームは、プロセスの状態遷移モデル(State Transition Model)を確立しました。これは後の全てのオペレーティングシステムの基盤となります:
【古典的3状態モデル(Dijkstra, 1968)】
ディスパッチ
┌─────────────────────────────────┐
│ ↓
┌───────┐ I/O完了 ┌───────┐ プリエンプション ┌───────┐
│ BLOCKED├──────────────→│ READY │←──────────────────│RUNNING│
└───────┘ └───────┘ └───────┘
↑ │
└────────────────────────────────────────────────────┘
I/O要求
【現代の5状態モデル】
NEW → READY ⇄ RUNNING → TERMINATED
↑ ↓
└─ WAITING ─┘
Edsger Dijkstraは1968年の論文「The Structure of the "THE"-Multiprogramming System」において、プロセスの並行実行とその同期問題を体系的に分析し、セマフォ(Semaphore)という同期プリミティブを発明しました。
1.2 UNIXの誕生とプロセスモデルの革新(1969-1973)
1.2.1 UNIXプロジェクトの始まり
1969年、AT&T Bell Laboratories(ベル研究所)のKen ThompsonとDennis Ritchieは、Multicsプロジェクトの経験を基に、より軽量で実用的なオペレーティングシステムの開発を開始しました。これがUNIXです。
UNIXという名前は、Multicsへの皮肉として同僚のBrian Kernighanが命名しました(Multi- → Uni-)。しかし、この「単純化」こそがUNIXの最大の強みとなりました。
【Multicsの複雑さ vs UNIXの簡潔さ】
Multics:
- 複雑なセグメント化メモリモデル
- 精緻なアクセス制御機構(リングプロテクション)
- 多層のファイルシステム
- 巨大なコードベース(PL/I言語)
UNIX:
- シンプルなプロセスモデル
- 最小限のシステムコール
- 階層的ファイルシステム
- 簡潔なコード(アセンブリ→C言語)
1.2.2 fork-execモデルの発明
UNIXの最も革新的な設計決定の一つが、fork-exec モデルでした。Multicsや他の多くのシステムでは、新しいプロセスの作成は単一の操作でした。しかし、Thompson と Ritchie は、プロセス作成を2つの独立した操作に分離しました:
/* fork(): プロセスの複製 */
pid_t fork(void);
/* exec(): プロセスの変換 */
int execve(const char *pathname, char *const argv[], char *const envp[]);
fork() は、呼び出したプロセスの完全なコピーを作成します。exec() は、現在のプロセスを新しいプログラムで置き換えます。この分離には深い意味がありました:
【fork-execの利点(Ritchie & Thompson, 1974)】
1. シンプルさ: 各操作が単純で理解しやすい
2. 柔軟性: fork後、exec前にプロセスを設定できる
- ファイルディスクリプタのリダイレクト
- 環境変数の変更
- シグナルハンドラの設定
3. 効率性: Copy-on-Write(後述)により、
実際のコピーを遅延できる
4. UNIXパイプの基盤: fork後にパイプを設定し、
execで別のコマンドを実行
Dennis Ritchieは1978年のチューリング賞講演「Reflections on Software Research」において、この設計について「単純さは美徳であり、UNIXの成功はこの原則への忠実さによる」と述べています。
1.2.3 プロセス階層とinit
UNIXは、全てのプロセスが単一の祖先プロセスから派生するプロセスツリー構造を採用しました:
【UNIXプロセス階層】
init (PID 1)
│
┌────────┼────────┐
│ │ │
login login daemon
│ │ │
shell shell 子プロセス群
│ │
ユーザー ユーザー
プロセス群 プロセス群
initプロセス(PID 1)は、カーネルが起動後に最初に生成するユーザー空間プロセスです。全ての他のプロセスは、直接または間接的にinitから派生します。
/* プロセス関係を確認するコード */
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t pid = getpid(); /* 現在のプロセスID */
pid_t ppid = getppid(); /* 親プロセスID */
printf("PID: %d, Parent PID: %d\n", pid, ppid);
/* 親プロセスを辿っていくと、最終的にPID 1に到達 */
return 0;
}
この階層構造は、孤児プロセス(Orphan Process)の問題を解決します。親プロセスが終了すると、子プロセスはinitに「養子」として引き取られます(re-parenting)。
1.3 Doug McIlroyとパイプの発明(1973)
1.3.1 ソフトウェア工学の危機と解決策
1960年代後半から1970年代初頭にかけて、ソフトウェア産業は「ソフトウェア危機(Software Crisis)」に直面していました。プロジェクトの遅延、予算超過、品質問題が蔓延し、1968年のNATO Software Engineering Conferenceでこの問題が公式に認識されました。
この危機への対応として、様々なソフトウェア工学の原則が提唱されました。その中で、AT&T Bell LabsのDoug McIlroyは、ソフトウェアの再利用と組み合わせに焦点を当てました。
1.3.2 McIlroyのパイプ提案
1973年、McIlroyはKen Thompsonに対して、歴史的なメモを送りました:
【McIlroyのメモ(1973年)- 意訳】
「プログラムを庭のホースのようにつなげたい。
データを処理する方法が必要なとき、
既存のプログラムを連結して新しい用途に
対応できるべきだ。」
このアイデアを受けて、ThompsonはわずかR一晩でパイプをUNIXに実装しました(UNIX Version 3, 1973年)。Ritchieは後に「一晩でパイプが実装され、翌日には全員がパイプを使っていた」と回想しています。
1.3.3 パイプの数学的基盤
パイプの概念は、数学の関数合成(Function Composition)と深い関連があります:
【数学的な関数合成】
f: A → B (関数fはAからBへの写像)
g: B → C (関数gはBからCへの写像)
(g ∘ f)(x) = g(f(x)) (合成関数)
【UNIXパイプライン】
cmd1: stdin → stdout (cmd1は入力を出力に変換)
cmd2: stdin → stdout (cmd2は入力を出力に変換)
cmd1 | cmd2 (パイプによる合成)
パイプラインは結合法則(Associativity)を満たします:
# 結合法則: (a | b) | c = a | (b | c)
(cat file | grep pattern) | wc -l
# は以下と等価
cat file | (grep pattern | wc -l)
この数学的構造により、パイプラインは予測可能で組み合わせ可能な振る舞いを持ちます。
1.3.4 UNIXの哲学
パイプの発明は、UNIX哲学の確立に直接つながりました。Doug McIlroyは1978年にこれを明文化しました:
【UNIXの哲学(McIlroy, 1978)】
1. 一つのことをうまくやれ
"Make each program do one thing well."
2. 協調を前提とせよ
"Expect the output of every program to become
the input to another, as yet unknown, program."
3. 早く試作せよ
"Build and test prototypes quickly."
4. 道具を使え
"Use tools in preference to unskilled help."
Eric S. Raymondは「The Art of UNIX Programming」(2003年)で、これらの原則を17の設計ルールに拡張しました:
【UNIX設計ルール(Raymond, 2003)抜粋】
- モジュール性の原則: 簡潔なインターフェースで接続
- 明快さの原則: 巧妙さより明快さを優先
- 組み合わせの原則: プログラム間で連携できるよう設計
- 分離の原則: ポリシーとメカニズムを分離
- 単純性の原則: 複雑さは可能な限り避ける
- 節約の原則: 大きなプログラムは必要になるまで書かない
- 透明性の原則: 検査とデバッグを容易にする設計
- 堅牢性の原則: 透明性と単純性から堅牢性は生まれる
1.4 プロセス間通信(IPC)の理論と分類
1.4.1 IPC問題の形式化
複数のプロセスが協調して動作する場合、プロセス間通信(Inter-Process Communication: IPC)が必要になります。IPCの問題は、以下の要件を満たす必要があります:
【IPC要件(Andrews & Schneider, 1983)】
1. 通信(Communication):
プロセス間でデータを転送できること
2. 同期(Synchronization):
プロセスの実行順序を制御できること
3. 相互排除(Mutual Exclusion):
共有リソースへの同時アクセスを防げること
1.4.2 IPCメカニズムの分類
IPCメカニズムは、通信の性質によって分類できます:
【IPCメカニズムの分類】
┌─────────────────────────────────────────────────────────┐
│ IPC メカニズム │
├─────────────────────────────────────────────────────────┤
│ データ転送型 │ 共有メモリ型 │
│ (Message Passing) │ (Shared Memory) │
├─────────────────────────────────────────────────────────┤
│ ・パイプ(Pipes) │ ・共有メモリセグメント │
│ ・名前付きパイプ(FIFO) │ ・メモリマップドファイル │
│ ・メッセージキュー │ │
│ ・ソケット │ │
├─────────────────────────────────────────────────────────┤
│ 特徴: │ 特徴: │
│ ・データがカーネルを経由 │ ・データを直接共有 │
│ ・自動的な同期 │ ・明示的な同期が必要 │
│ ・コピーオーバーヘッド │ ・高速なデータアクセス │
└─────────────────────────────────────────────────────────┘
1.4.3 パイプの位置づけ
パイプは、データ転送型IPCの中で最も単純なメカニズムです:
【パイプの特性】
1. 単方向(Unidirectional):
- データは書き込み端から読み込み端へ一方向に流れる
- 双方向通信には2本のパイプが必要
2. バイトストリーム(Byte Stream):
- メッセージ境界を保持しない
- データは連続したバイト列として扱われる
3. 有限バッファ(Finite Buffer):
- カーネル内に固定サイズのバッファを持つ
- Linuxでは通常64KB(PIPE_BUF = 4096バイトはアトミック書き込み保証)
4. ブロッキングセマンティクス:
- 読み込み: データがなければブロック
- 書き込み: バッファが満杯ならブロック
- 端が閉じられれば適切なシグナル/エラー
/* パイプの作成と基本的な使用法 */
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int pipefd[2];
/* パイプを作成 */
if (pipe(pipefd) == -1)
{
perror("pipe");
return 1;
}
/*
* pipefd[0]: 読み込み端(read end)
* pipefd[1]: 書き込み端(write end)
*
* データの流れ:
* 書き込み側 → [pipefd[1]] → カーネルバッファ → [pipefd[0]] → 読み込み側
*/
printf("Read FD: %d, Write FD: %d\n", pipefd[0], pipefd[1]);
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
1.4.4 無名パイプ vs 名前付きパイプ
UNIXには2種類のパイプが存在します:
【パイプの種類】
無名パイプ(Anonymous Pipe):
- pipe()システムコールで作成
- 親子関係のあるプロセス間でのみ使用可能
- ファイルシステム上に名前を持たない
- Pipexプロジェクトで使用するのはこちら
名前付きパイプ(Named Pipe / FIFO):
- mkfifo()またはmknod()で作成
- ファイルシステム上にパス名を持つ
- 任意のプロセス間で通信可能
- シェルからは: mkfifo /tmp/myfifo
/* 名前付きパイプの例(参考) */
#include <sys/stat.h>
/* FIFOの作成 */
mkfifo("/tmp/myfifo", 0644);
/* 別プロセスから通信可能 */
/* プロセスA: */ open("/tmp/myfifo", O_WRONLY);
/* プロセスB: */ open("/tmp/myfifo", O_RDONLY);
1.5 「全てはファイル」抽象化とファイルディスクリプタ
1.5.1 UNIX I/Oモデルの革新
UNIXの最も重要な設計決定の一つが、「全てはファイルである(Everything is a File)」という抽象化です。ディスク上のファイル、端末、パイプ、ネットワーク接続を、全て同一のインターフェースで扱えるようにしました:
/* 統一されたI/Oインターフェース */
int fd = open(...); /* ファイルディスクリプタを取得 */
read(fd, buf, n); /* 読み込み */
write(fd, buf, n); /* 書き込み */
close(fd); /* クローズ */
この抽象化により、プログラムは入出力の詳細を知らずにデータを処理できます。これがパイプラインを可能にする基盤です。
1.5.2 ファイルディスクリプタの内部構造
ファイルディスクリプタは、カーネル内の複雑な構造への単純な整数インデックスです:
【ファイルディスクリプタの実装(Linux)】
ユーザー空間 カーネル空間
プロセスA ┌───────────────────────────┐
┌─────────┐ │ オープンファイルテーブル │
│ FDテーブル│ │ (system-wide) │
├────┬────┤ ├─────────┬─────────────────┤
│ 0 │ ●──├────────────────────→│ entry 0 │ mode, offset... │
├────┼────┤ ├─────────┼─────────────────┤
│ 1 │ ●──├────────────────────→│ entry 1 │ mode, offset... │
├────┼────┤ ├─────────┼─────────────────┤
│ 2 │ ●──├────────────────────→│ entry 2 │ mode, offset... │
├────┼────┤ ┌─────────────→│ entry 3 │ mode, offset... │
│ 3 │ ●──├──────┘ └─────────┴────────┬────────┘
└────┴────┘ │
↓
┌─────────────────────────────┐
│ inode テーブル │
│ (ファイルの実体への参照) │
└─────────────────────────────┘
重要な点:
- ファイルディスクリプタは非負の整数(0, 1, 2, 3, ...)
- 最小の未使用番号が新しいFDに割り当てられる
- fork()時、子プロセスはFDテーブルのコピーを継承
- dup2()で任意のFD番号に複製可能
1.5.3 標準ストリームの意義
UNIXは、全てのプロセスに3つの標準ストリームを自動的に割り当てます:
【標準ストリーム】
FD 0: 標準入力(stdin) - プログラムへの入力
FD 1: 標準出力(stdout) - 通常の出力
FD 2: 標準エラー(stderr)- エラーメッセージ
この規約により:
- プログラムは入力元を知る必要がない
- プログラムは出力先を知る必要がない
- シェルがリダイレクトを自由に制御可能
/* プログラムは入出力の詳細を知らない */
int main(void)
{
char buf[1024];
ssize_t n;
/* 入力がキーボードかファイルかパイプかは関係ない */
while ((n = read(STDIN_FILENO, buf, sizeof(buf))) > 0)
{
/* 出力がターミナルかファイルかパイプかは関係ない */
write(STDOUT_FILENO, buf, n);
}
return 0;
}
この設計は、関心の分離(Separation of Concerns)の原則を体現しています。プログラムはデータの処理に集中し、入出力のルーティングはシェルが担当します。
1.6 シェルパイプラインの実行モデル
1.6.1 シェルによるパイプライン構築
シェルがパイプラインを実行する際の処理を理解することは、Pipexの実装に不可欠です:
# ユーザーが入力するコマンド
< input.txt grep "error" | wc -l > output.txt
【シェルの処理手順】
1. コマンドライン解析
├── リダイレクション: < input.txt, > output.txt
├── パイプ: |
└── コマンド: grep "error", wc -l
2. パイプ作成
pipe(pipefd) → pipefd[0]=読み込み, pipefd[1]=書き込み
3. 子プロセス1の生成(grep)
├── fork()
├── dup2(input_fd, STDIN_FILENO) ← 入力をファイルから
├── dup2(pipefd[1], STDOUT_FILENO) ← 出力をパイプへ
├── close(不要なFD)
└── execve("/bin/grep", ["grep", "error", NULL], envp)
4. 子プロセス2の生成(wc)
├── fork()
├── dup2(pipefd[0], STDIN_FILENO) ← 入力をパイプから
├── dup2(output_fd, STDOUT_FILENO) ← 出力をファイルへ
├── close(不要なFD)
└── execve("/usr/bin/wc", ["wc", "-l", NULL], envp)
5. 親プロセス(シェル)
├── close(pipefd[0]) ← パイプの両端をクローズ
├── close(pipefd[1])
├── waitpid(pid1, &status1, 0) ← 子プロセスの終了を待つ
└── waitpid(pid2, &status2, 0)
1.6.2 dup2()によるリダイレクション
dup2()システムコールは、ファイルディスクリプタの複製と置換を行います:
#include <unistd.h>
int dup2(int oldfd, int newfd);
/*
* 動作:
* 1. newfdが開いていれば、まずcloseする
* 2. oldfdをnewfdにコピー(同じファイルを指すようにする)
* 3. 成功時はnewfdを返す
*/
/* 標準出力をファイルにリダイレクトする例 */
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
int fd;
fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
/* stdout (FD 1) をファイルに置き換える */
dup2(fd, STDOUT_FILENO); /* fd → STDOUT_FILENO にコピー */
close(fd); /* 元のfdは不要になったのでクローズ */
/* 以降のprintfやwrite(1, ...)は全てファイルに書かれる */
printf("This goes to the file\n");
return 0;
}
1.6.3 パイプの端のクローズが重要な理由
パイプを使用する際、不要なファイルディスクリプタを確実にクローズすることが極めて重要です:
【パイプのクローズが必要な理由】
問題1: 読み込み側がEOFを検出できない
プロセスA パイプ プロセスB
(書き込み) (読み込み)
│ │
│ ← 書き込み端が開いている限り、 │
│ 読み込み側はEOFを受け取れず │
│ 永久にブロックする │
解決: 書き込み側が終了したら、全ての書き込み端をクローズ
→ 読み込み側がread()で0(EOF)を受け取る
問題2: SIGPIPEを受け取れない
プロセスA パイプ プロセスB
(書き込み) (終了済み)
│ ×
│ ← 読み込み端が開いている限り、
│ 書き込み側はSIGPIPEを受け取れず
│ 永久にブロックする可能性
解決: 読み込み側が終了したら、全ての読み込み端をクローズ
→ 書き込み側がwrite()でSIGPIPEを受け取る
/* 正しいパイプの使用パターン */
int pipefd[2];
pipe(pipefd);
pid_t pid = fork();
if (pid == 0)
{
/* 子プロセス: 読み込み側 */
close(pipefd[1]); /* 書き込み端は不要なのでクローズ */
/* パイプから読み込む処理 */
char buf[1024];
while (read(pipefd[0], buf, sizeof(buf)) > 0)
{
/* 処理 */
}
close(pipefd[0]);
exit(0);
}
else
{
/* 親プロセス: 書き込み側 */
close(pipefd[0]); /* 読み込み端は不要なのでクローズ */
/* パイプに書き込む処理 */
write(pipefd[1], "Hello", 5);
close(pipefd[1]); /* 書き込み終了を通知 */
waitpid(pid, NULL, 0);
}
1.7 Pipexプロジェクトの要件と設計
1.7.1 プロジェクト概要
ここまでの理論的基盤を踏まえて、Pipexプロジェクトの具体的な要件を見ていきましょう。Pipexは、シェルのパイプ機能を再実装するプロジェクトです:
# シェルコマンド(これを再現する)
< file1 cmd1 | cmd2 > file2
# Pipexプログラム
./pipex file1 cmd1 cmd2 file2
1.7.2 必須要件
【必須パート】
プログラム名: pipex
使用方法:
./pipex file1 cmd1 cmd2 file2
動作:
シェルコマンド < file1 cmd1 | cmd2 > file2 と同等
許可される関数:
- open, close, read, write
- malloc, free
- perror, strerror
- access
- dup, dup2
- execve
- exit
- fork
- pipe
- unlink
- wait, waitpid
- ft_printf / libft関数
禁止事項:
- グローバル変数
- メモリリーク
- 未定義動作
1.7.3 ボーナス要件
【ボーナスパート】
1. 複数パイプのサポート
./pipex file1 cmd1 cmd2 cmd3 ... cmdn file2
↔ < file1 cmd1 | cmd2 | cmd3 | ... | cmdn > file2
2. Here-documentのサポート
./pipex here_doc LIMITER cmd1 cmd2 file
↔ << LIMITER cmd1 | cmd2 >> file
- here_doc: 第1引数がリテラル文字列 "here_doc"
- LIMITER: 入力終了を示す区切り文字列
- >>: 追記モード(O_APPEND)
1.7.4 処理フローの設計
Pipexの実行フローを設計します:
【Pipex処理フロー】
main(argc, argv, envp)
│
├─ 引数検証 (argc == 5?)
│
├─ ファイルオープン
│ ├─ infile = open(argv[1], O_RDONLY)
│ └─ outfile = open(argv[4], O_WRONLY | O_CREAT | O_TRUNC, 0644)
│
├─ パイプ作成
│ └─ pipe(pipefd)
│
├─ 子プロセス1の生成と実行
│ └─ fork() → child1
│ ├─ dup2(infile, STDIN_FILENO)
│ ├─ dup2(pipefd[1], STDOUT_FILENO)
│ ├─ close(不要なFD)
│ └─ execute_cmd(argv[2], envp)
│
├─ 子プロセス2の生成と実行
│ └─ fork() → child2
│ ├─ dup2(pipefd[0], STDIN_FILENO)
│ ├─ dup2(outfile, STDOUT_FILENO)
│ ├─ close(不要なFD)
│ └─ execute_cmd(argv[3], envp)
│
├─ 親プロセスでのクリーンアップ
│ ├─ close(pipefd[0])
│ ├─ close(pipefd[1])
│ ├─ close(infile)
│ └─ close(outfile)
│
└─ 子プロセスの終了待機
├─ waitpid(child1, &status1, 0)
└─ waitpid(child2, &status2, 0)
1.7.5 コマンド実行の設計
コマンドを実行するためには、PATHの解決が必要です:
/* コマンドパス解決の概念 */
/* 入力: "grep" */
/* 出力: "/usr/bin/grep" */
/*
* 1. 環境変数からPATHを取得
* PATH=/usr/local/bin:/usr/bin:/bin
*
* 2. PATHを':'で分割
* paths[0] = "/usr/local/bin"
* paths[1] = "/usr/bin"
* paths[2] = "/bin"
*
* 3. 各パスとコマンドを結合してアクセス可能か確認
* access("/usr/local/bin/grep", X_OK) → -1 (失敗)
* access("/usr/bin/grep", X_OK) → 0 (成功!)
*
* 4. 成功したパスを返す
* return "/usr/bin/grep"
*/
/* 基本的なコマンド実行関数の骨格 */
void execute_cmd(char *cmd, char **envp)
{
char **args;
char *path;
/* コマンド文字列を引数配列に分割 */
args = ft_split(cmd, ' ');
if (!args)
error_exit("malloc");
/* 実行可能ファイルのパスを検索 */
path = find_executable_path(args[0], envp);
if (!path)
{
ft_putstr_fd("command not found: ", 2);
ft_putendl_fd(args[0], 2);
free_array(args);
exit(127);
}
/* プロセスを置き換えて実行 */
execve(path, args, envp);
/* execveが返ってきたらエラー */
perror("execve");
free(path);
free_array(args);
exit(126);
}
1.8 エラー処理の設計原則
1.8.1 シェルのエラー処理を模倣する
Pipexはシェルの振る舞いを模倣する必要があります。シェルのエラー処理を観察しましょう:
# 入力ファイルが存在しない場合
$ < nonexistent grep hello | wc -l
bash: nonexistent: No such file or directory
0
# コマンドが存在しない場合
$ < input.txt invalidcmd | wc -l
bash: invalidcmd: command not found
0
# 出力ファイルに書き込めない場合
$ < input.txt grep hello | wc -l > /root/output.txt
bash: /root/output.txt: Permission denied
1.8.2 終了ステータスの設計
UNIX/POSIXでは、終了ステータスに意味があります:
【終了ステータスの慣例】
0 : 成功
1 : 一般的なエラー
2 : シェル組み込みコマンドの不正使用
126 : コマンドは見つかったが実行できない(権限なし)
127 : コマンドが見つからない
128+N : シグナルNで終了
130 : Ctrl+C (SIGINT = 2) で終了
/* 適切な終了ステータスを返す例 */
void execute_cmd(char *cmd, char **envp)
{
/* ... パス解決 ... */
if (!path)
{
/* コマンドが見つからない */
ft_putstr_fd(args[0], 2);
ft_putstr_fd(": command not found\n", 2);
exit(127);
}
if (access(path, X_OK) == -1)
{
/* 実行権限がない */
perror(path);
exit(126);
}
execve(path, args, envp);
perror("execve");
exit(1);
}
1.8.3 リソースリークの防止
システムプログラミングでは、リソースリークは深刻な問題です:
/* リソースリークを防ぐパターン */
/* 悪い例: エラー時にリソースが解放されない */
int bad_function(void)
{
int fd1 = open("file1", O_RDONLY);
if (fd1 == -1)
return -1;
int fd2 = open("file2", O_RDONLY);
if (fd2 == -1)
return -1; /* fd1がリークする! */
/* ... */
close(fd1);
close(fd2);
return 0;
}
/* 良い例: 全てのパスでリソースを解放 */
int good_function(void)
{
int fd1 = -1;
int fd2 = -1;
int result = -1;
fd1 = open("file1", O_RDONLY);
if (fd1 == -1)
goto cleanup;
fd2 = open("file2", O_RDONLY);
if (fd2 == -1)
goto cleanup;
/* 成功処理 */
result = 0;
cleanup:
if (fd1 != -1)
close(fd1);
if (fd2 != -1)
close(fd2);
return result;
}
(注: 42ではgotoは推奨されませんが、概念を示すために使用。実際は関数分割で対応)
1.9 プロジェクト構成の推奨
1.9.1 ファイル構成
pipex/
├── Makefile
├── includes/
│ └── pipex.h
├── srcs/
│ ├── main.c # エントリーポイント、引数処理
│ ├── pipex.c # メインのパイプライン処理
│ ├── execute.c # コマンド実行
│ ├── path.c # PATHの解決
│ ├── utils.c # ユーティリティ関数
│ └── error.c # エラー処理
├── srcs_bonus/
│ ├── pipex_bonus.c # 複数パイプ対応
│ └── heredoc_bonus.c # Here-doc対応
└── libft/
└── ...
1.9.2 ヘッダファイルの設計
/* includes/pipex.h */
#ifndef PIPEX_H
# define PIPEX_H
# include <unistd.h>
# include <stdlib.h>
# include <fcntl.h>
# include <sys/wait.h>
# include <stdio.h>
# include <string.h>
# include <errno.h>
# include "../libft/libft.h"
/* エラーコード */
# define ERR_USAGE 1
# define ERR_CMD_NOT_FOUND 127
# define ERR_EXEC_FAIL 126
/* 構造体定義 */
typedef struct s_pipex
{
int infile_fd;
int outfile_fd;
int pipe_fd[2];
char **envp;
char **paths; /* PATHを分割したもの */
int cmd_count; /* コマンド数(ボーナス用) */
int here_doc; /* Here-docモード(ボーナス用) */
} t_pipex;
/* main.c */
void init_pipex(t_pipex *px, int argc, char **argv, char **envp);
/* pipex.c */
void run_pipeline(t_pipex *px, char **argv);
/* execute.c */
void execute_cmd(char *cmd, t_pipex *px);
/* path.c */
char *find_path(char *cmd, char **paths);
char **parse_paths(char **envp);
/* error.c */
void error_exit(char *msg);
void cmd_not_found(char *cmd);
/* utils.c */
void free_array(char **arr);
void close_pipes(t_pipex *px);
#endif
1.10 デバッグとトラブルシューティング
1.10.1 デバッグ戦略
/* デバッグマクロ */
#ifdef DEBUG
# define DEBUG_PRINT(fmt, ...) \
fprintf(stderr, "[DEBUG %s:%d] " fmt "\n", \
__FILE__, __LINE__, ##__VA_ARGS__)
#else
# define DEBUG_PRINT(fmt, ...) ((void)0)
#endif
/* 使用例 */
void child_process(t_pipex *px, char *cmd)
{
DEBUG_PRINT("Child PID=%d, PPID=%d", getpid(), getppid());
DEBUG_PRINT("Executing: %s", cmd);
DEBUG_PRINT("stdin=%d, stdout=%d",
STDIN_FILENO, STDOUT_FILENO);
/* ... */
}
1.10.2 よくある問題と解決策
【問題1: プログラムがハングする】
原因: パイプの端が正しくクローズされていない
診断: strace -e trace=read,write ./pipex ...
解決: 不要なFDを確実にクローズ
【問題2: コマンドが見つからない】
原因: PATHの解決が正しくない
診断: echo $PATH でパスを確認
解決: 環境変数の解析とパス結合を確認
【問題3: 出力が空になる】
原因: リダイレクションの順序が間違っている
診断: 各段階でログを出力
解決: dup2の順序とタイミングを確認
【問題4: ゾンビプロセスが残る】
原因: waitpidを呼んでいない
診断: ps aux | grep defunct
解決: 全ての子プロセスをwaitpidで回収
1.10.3 システムコールのトレース
# 全てのシステムコールをトレース
strace -f ./pipex input.txt "cat" "wc -l" output.txt
# 特定のシステムコールのみ
strace -f -e trace=fork,execve,pipe,dup2,close \
./pipex input.txt "cat" "wc -l" output.txt
# ファイルディスクリプタのトレース
strace -f -e trace=open,close,read,write,dup2 \
./pipex input.txt "cat" "wc -l" output.txt
1.11 学習リソース
推奨書籍
- "The Linux Programming Interface"
- "Operating Systems: Three Easy Pieces"
歴史的論文
- Thompson, K., & Ritchie, D. (1974). "The UNIX Time-Sharing System"
- Ritchie, D. (1984). "The Evolution of the Unix Time-sharing System"
- McIlroy, M. D. (1978). "UNIX Time-Sharing System: Foreword"
- Linux man pages:
man 2 fork,man 2 pipe,man 2 execve,man 2 dup2 - POSIX仕様: https://pubs.opengroup.org/onlinepubs/9699919799/
- "The Art of UNIX Programming": http://www.catb.org/~esr/writings/taoup/
オンラインリソース
1.12 次章への準備
次章では、プロセス管理の中核となる3つのシステムコール(fork、execve、waitpid)を詳しく学びます。以下の準備課題を実行して、次章に備えましょう:
/* 準備課題1: forkの動作確認 */
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("Before fork: PID = %d\n", getpid());
pid_t pid = fork();
if (pid == 0)
printf("Child: PID = %d, Parent PID = %d\n",
getpid(), getppid());
else
printf("Parent: PID = %d, Child PID = %d\n",
getpid(), pid);
return 0;
}
/* 質問: なぜ出力が3行あるのか? */
/* 準備課題2: execveの動作確認 */
#include <stdio.h>
#include <unistd.h>
int main(void)
{
char *args[] = {"/bin/echo", "Hello from execve", NULL};
printf("Before execve\n");
execve("/bin/echo", args, NULL);
printf("After execve\n"); /* この行は実行される? */
return 0;
}
/* 準備課題3: パイプの動作確認 */
#include <stdio.h>
#include <unistd.h>
int main(void)
{
int pipefd[2];
char buf[5];
pipe(pipefd);
write(pipefd[1], "test", 4);
close(pipefd[1]);
int n = read(pipefd[0], buf, 4);
buf[n] = '\0';
printf("Read from pipe: %s\n", buf);
close(pipefd[0]);
return 0;
}
---
まとめ: この章では、UNIXの誕生からパイプの発明までの歴史、プロセスモデルの理論的基盤、IPCの分類、そしてPipexプロジェクトの設計を学びました。次章では、fork-execモデルの詳細と、実際にプロセスを生成・制御する方法を実装レベルで解説します。