はじめに:
前回の記事では、Linux ファイルシステムとディレクトリ操作の中核となる内容を詳しく解説しました。本記事からは、システムプログラミングの中核モジュールである「プロセス管理」に本格的に入っていきます。プロセスは、オペレーティングシステムがリソースを割り当てる基本単位であり、マルチタスク並行処理の中核となる存在です。本記事では、プロセスの本質的な概念から出発し、fork によるプロセス生成の低レベルな仕組みを深く掘り下げ、書き込み時コピー(WRC)の核心的な原理を詳細に解説し、親プロセスと子プロセスのリソース共有と相違点を整理します。これらは、マルチプロセス並行処理や、その後のプロセス制御・通信を理解するための基礎となります。
一、プロセスの核心概念
1. プログラムとプロセスの違い
- プログラム:ディスク上に保存された実行可能ファイルであり、静的なコードとデータの集合体です。永続的に存在しますが、システムのリソースを消費することはありません。
- プロセス:プログラムの1回の実行プロセスであり、オペレーティングシステムがリソースを割り当てる基本単位です。動的なものであり、独立したライフサイクルと実行状態を持ちます。
簡単に言えば、同一のプログラムから複数の独立したプロセスを同時に起動することができ、各プロセスは独立したメモリ空間と実行コンテキストを持ち、互いに干渉しない。例えば、2つのターミナルウィンドウを同時に開き、lsコマンドを実行する場合、これは同一のプログラムに対応する2つの独立したプロセスである。
2. プロセス制御ブロック(PCB)
オペレーティングシステムのカーネルは、プロセス制御ブロック(Process Control Block)を通じてシステム内のすべてのプロセスを管理します。これはプロセスを記述する中核となる構造体であり、プロセスのすべての属性と状態情報が記録されています。Linux では、PCB の具体的な実装はtask_struct構造体です。
PCBには以下の重要な情報が含まれています:
- プロセス識別子:プロセスID(PID)、親プロセスID(PPID)、ユーザーID、グループID
- プロセス状態:実行中、準備完了、ブロック中、停止中、ゾンビなどの状態フラグ
- メモリポインタ:プロセスの仮想アドレス空間を指し、コードセグメント、データセグメント、ヒープ、スタックの位置を含む
- ファイル記述子テーブル:プロセスが開いているすべてのファイルのインデックステーブル
- レジスタコンテキスト:プロセス切り替え時に保存される CPU レジスタの状態。実行を再開するために使用される
- スケジューリング優先度:プロセスのスケジューリングにおける優先度とタイムスライス情報
3. プロセスの3つの基本状態
プロセスはライフサイクル全体を通じて、異なる状態間を遷移し、オペレーティングシステムのスケジューラによって一元的に管理される:
- 実行状態:現在CPUを占有して命令を実行している状態。シングルコアCPUでは、同時に1つのプロセスしか実行状態にはなれない
- 待機状態:実行の準備が整っており、CPUスケジューラによるタイムスライスの割り当てを待機している状態
- ブロック状態:特定のイベント(IOの完了、シグナル、ロックなど)の発生を待機しており、一時的にCPUスケジューリングに参加しない
状態遷移の核心となるロジック:実行状態のプロセスは、タイムスライスが終了すると待機状態に戻る。実行状態のプロセスがIOなどのブロック操作を開始すると、ブロック状態に入る。I/Oが完了すると、ブロック状態のプロセスは呼び起こされ、準備状態に戻ってスケジューリングを待つ。
4. プロセスIDと基本関数
各プロセスには、プロセスのシステム識別子として一意の非負整数PIDが割り当てられている。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); // 現在のプロセスのPIDを取得
pid_t getppid(void); // 現在のプロセスの親プロセスのPIDを取得
Linuxには2つの特別なプロセスがあります:
- PID 0:idle(アイドル)プロセス。カーネルの起動時に作成され、CPUがアイドル状態のときに実行される
- PID 1:init/systemd プロセス。すべてのユーザーモードプロセスの祖先であり、システムの初期化と孤児プロセスの引き取りを担当する
二、プロセスの作成:fork 関数の詳細解説
forkは、Linux におけるプロセス生成の中核となるシステムコールであり、筆記試験や面接で 100% 出題される頻出問題です。
1. 関数のプロトタイプと主要な特性
#include <unistd.h>
pid_t fork(void);
戻り値のルール:1回の呼び出しで2つの戻り値が返される
- 親プロセスへの戻り値:新しく作成された子プロセスのPID(0より大きい整数)
- 子プロセスへの戻り値:0
- 作成失敗時の戻り値:-1、errnoを設定
fork を実行すると、カーネルはまったく新しい子プロセスを作成します。親プロセスと子プロセスはそれぞれ独立して実行され、戻り値はそれぞれのプロセスのアドレス空間に返されるため、戻り値の違いによって異なるコード分岐に進むことができます。
2. 基本的なコード例
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf(「プロセスが起動しました。現在のPID=%d\\n」, getpid());
pid_t pid = fork();
if (pid == -1) {
perror(「forkが失敗しました」);
return 1;
} else if (pid == 0) {
// 子プロセスが実行する分岐
printf(「私は子プロセスです。PID=%d、親プロセスのPID=%d\\n」, getpid(), getppid());
} else {
// 親プロセスが実行する分岐
printf(「私は親プロセスです。PID=%d、子プロセスのPID=%d\\n」, getpid(), pid);
}
// 親プロセスと子プロセスの両方がここまで実行される
printf(「PID=%d プログラム終了\\n」, getpid());
return 0;
}
実行に関する説明:fork 後の親プロセスと子プロセスの実行順序は、完全にオペレーティングシステムのスケジューラによって決定されます。どちらが先に実行され、どちらが先に終了するかは不確定であるため、プログラミングの際には、いかなる固定された実行順序にも依存してはなりません。
3. 書き込み時コピー(Copy On Write)
書き込み時コピーは fork の核心となる低レベルなメカニズムであり、面接で必ず出題される問題でもあります。
forkによって子プロセスが作成された後、親プロセスの物理メモリ全体が即座に完全に複製されるわけではなく、親プロセスと子プロセスが同一の物理メモリページを共有し、カーネルがそのメモリページを読み取り専用に設定します。いずれかのプロセスがメモリの変更を試みた場合にのみ、カーネルは変更対象のページを独立したコピーとして複製し、2つのプロセスがそれぞれ独立したメモリを所有できるようにすることで、変更が互いに影響し合わないようにします。
主な利点:
- forkの実行効率を大幅に向上させ、メモリの完全コピーに伴う莫大なオーバーヘッドを回避します
- ほとんどの場合、fork後の子プロセスは直ちにexecを実行してプログラムを置き換えるため、完全コピーは完全に無駄であり、書き込み時コピーはこのシナリオに完璧に適合します
- 読み取り専用データ(コードセグメントなど)は全行程で共有され、メモリリソースをさらに節約
4. 親プロセスと子プロセスのリソースの共通点と相違点
| リソースの観点 | 親プロセスと子プロセスの関係 |
|---|---|
| コードセグメント | 完全に共有され、内容は完全に一致 |
| グローバルデータ、ヒープ、スタック | 書き込み時コピー(WRC)。変更前は共有され、変更後はそれぞれ独立 |
| ファイル記述子テーブル | コピーされ、同一のファイルテーブルエントリを共有し、ファイルオフセットも共有される |
| プロセスID | それぞれ独立しており、PID、PPIDはいずれも異なる |
| メモリマッピング領域 | 書き込み時コピーによる共有 |
| シグナル処理方式 | 子プロセスは親プロセスのシグナル処理方式を継承する |
| バッファ内のデータ | 親プロセスのユーザーモードバッファは子プロセスにコピーされる |
3. 親プロセスと子プロセスのファイル共有特性
これまでのファイル I/O の知識と照らし合わせると、fork 後の親プロセスと子プロセスにおけるファイル記述子の共有は非常に重要な特性であり、頻繁にミスが起きやすいポイントでもあります。
1. 低レベルの共有メカニズム
fork によって子プロセスが作成されると、親プロセスのファイル記述子テーブルが複製されますが、両プロセスで同じ番号の fd は、カーネル内の同じファイルエントリを指します。これは、以下のことを意味します:
- 親プロセスと子プロセスは、同じファイルの読み書きオフセットを共有します
- 一方がデータを書き込むと、もう一方のファイルポインタも同期して進みます
- 一方がファイルの状態フラグを変更すると、もう一方にも影響が及ぶ
2. コード検証例
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main(void) {
int fd = open(「test.txt」, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror(「open failed」);
return 1;
}
pid_t pid = fork();
if (pid == 0) {
// 子プロセスの書き込み
write(fd, 「hello child\\n」, 12);
printf(「子プロセスの書き込み完了\\n」);
close(fd);
return 0;
}
// 親プロセスが子プロセスの書き込み完了を待つ
sleep(1);
// 親プロセスが書き込みを行う。子プロセスの内容の後に追加される
write(fd, 「hello parent\\n」, 13);
printf(「親プロセスの書き込み完了\\n」);
close(fd);
return 0;
}
を実行すると、ファイルには2行の内容が同時に存在し、互いに上書きされることはありません。これは、両者がファイルのオフセットを共有していることを証明しています。それぞれ独立した書き込み位置が必要な場合は、fork後にそれぞれファイルを再度開く必要があります。
4. 応用:vfork と fork の違い
vfork は初期のプロセス生成関数であり、コピーオンライトの仕組みが普及する以前、fork による完全コピーに伴うパフォーマンス問題を解決するために使用されていました。現在の実務開発ではほとんど使用されていませんが、面接で時折質問されることがあります。
| 比較項目 | fork | vfork |
|---|---|---|
| アドレス空間 | 書き込み時コピーによる物理メモリの共有 | 親プロセスのアドレス空間を完全に共有し、コピーは行わない |
| 実行順序 | 親プロセスと子プロセスのスケジューリング順序は不定 | 子プロセスが先に実行され、親プロセスは子プロセスが exit または exec を行うまでブロックされる |
| データの変更 | 書き込み時のコピー後、それぞれ独立 | 子プロセスがデータを変更すると、親プロセスに直接影響する |
| 適用シナリオ | 一般的なプロセスの作成 | fork後に直ちにexecを行うシナリオに特化して最適化 |
| 現状 | 主流の標準的な使い方 | すでに廃止されており、使用は推奨されない |
注意:vfork で生成された子プロセスでは、親プロセスのデータを変更してはならず、main 関数内で return による戻り値の返しもできません。必ず _exit を呼び出して終了する必要があります。そうしないと、親プロセスのスタック構造が破壊されます。
五、面接で頻出する出題ポイントと落とし穴
1. 代表的な面接の質疑応答
Q1:プログラムとプロセスの本質的な違いは何ですか?
回答: プログラムはディスク上に保存された静的な実行可能ファイルであり、コードとデータの集合体です。永続的に存在し、実行リソースを消費しません。一方、プロセスはプログラムの1回の実行プロセスであり、動的で独自のライフサイクルを持ち、オペレーティングシステムがリソースを割り当てる基本単位です。メモリやCPUなどのシステムリソースを消費します。1つのプログラムに対して複数の独立したプロセスが存在し、各プロセスは独立したメモリ空間を持っています。
Q2:fork関数の主な特徴は何ですか?「書き込み時コピー(Write-Once, Read-Many)」とは何ですか?
回答:forkの主な特徴は、「1回の呼び出しで2回の戻り値」である。親プロセスには子プロセスのPIDを返し、子プロセスには0を返す。この戻り値によって、親と子の実行分岐を区別する。「書き込み時コピー(Write-Once-Copy-On-Write)」は、forkの基盤となるメモリメカニズムである。fork後、親プロセスと子プロセスは物理メモリを共有し、メモリは読み取り専用に設定される。いずれかの側がメモリの変更を試みた場合にのみ、カーネルは変更されたページを独立したコピーとして複製し、それぞれが独立したメモリを持つようになる。これにより、forkの効率が大幅に向上し、無意味なメモリ全体のコピーが回避されます。
Q3:fork後、親プロセスと子プロセスではどのリソースが共有され、どのリソースが独立していますか?
回答:共有されるリソース:コードセグメントは常に共有される。データセグメント、ヒープ、スタックは書き込み時にコピーされ、変更前は共有される。ファイル記述子は同一のファイルエントリを指し、ファイルオフセットを共有する。独立したリソース:プロセスID、プロセス状態、独立したプロセス制御ブロック。メモリは変更後にそれぞれ独立する。各プロセスは独自のレジスタコンテキストを持つ。
Q4:fork 実行後、親プロセスと子プロセスのファイル記述子にはどのような特性がありますか?どのような影響がありますか?
回答:fork により親プロセスのファイル記述子テーブルが複製されます。親プロセスと子プロセスで番号が同じ fd は、カーネル内の同一のファイルエントリを指し、ファイルオフセットとファイル状態を共有します。これによる影響:一方がデータを書き込むと、もう一方のファイルポインタも同期して移動し、その後の書き込みで上書きされることはない。独立した読み書きが必要な場合は、fork後にそれぞれファイルを再度開く必要がある。
Q5:forkとvforkの根本的な違いは何ですか?
回答:
- アドレス空間:fork は書き込み時コピーを採用し、メモリは読み取り専用で共有される。vfork は親プロセスのアドレス空間を完全に共有し、コピーは行われない。
- 実行順序:fork では親プロセスと子プロセスの実行順序は不定である。vfork では子プロセスが先に実行され、親プロセスは子プロセスが終了するか exec を実行するまでブロックされる。
- データへの影響:forkでは、データを変更すると「書き込み時コピー」がトリガーされ、互いに影響しません。vforkでは、子プロセスがデータを変更すると、親プロセスに直接影響します。現在、書き込み時コピーを採用したforkのパフォーマンスはすでに非常に高いため、vforkは基本的に廃れてしまいました。
2. よくある落とし穴
- fork 後に親子プロセスの実行順序が固定されていると誤解しているが、実際にはOSのスケジューリングによって決定され、ランダム性がある
- fork 後にファイルディスクリプタのオフセットがそれぞれ独立していると誤解し、複数プロセスでファイルに書き込みを行う際にデータが混乱する
- 子プロセスがグローバル変数を変更すると親プロセスに影響すると考え、書き込み時複製メカニズムを無視している。変更後はメモリがすでに独立している
- forkの戻り値の確認を怠り、親プロセスと子プロセスの分岐を区別しないため、ロジックが混乱する
- 子プロセスでvforkを使用した後、直接returnで戻ると、親プロセスのスタック構造が破壊され、プログラムが異常終了する
- forkの前にprintfで改行を含まない内容を出力すると、fork後に子プロセスがバッファをコピーするため、出力が2回繰り返される
- 親プロセスが子プロセスを解放しないため、子プロセスが終了後にゾンビプロセスとなり、システムのPIDリソースを占有する
以上が、Linuxプロセスの基礎とforkによるプロセス生成に関するすべての中核的な内容です。プロセスの本質と生成メカニズムを理解することは、その後のプロセス制御、シグナル、プロセス間通信を学ぶための前提となります。次の記事では、プロセスの終了とリソースの回収について解説し、wait/waitpid、exec 関数群、およびゾンビプロセス、孤児プロセスの核心的な原理を詳しく説明します。
作成には手間がかかっています。もし役に立ったなら、いいねやブックマークで応援していただけると嬉しいです。