Skilore

プロセスの概念と管理

プロセスとは「実行中のプログラム」であり、OSが管理する最も基本的な実行単位である。

127 分で読めます63,141 文字

プロセスの概念と管理

プロセスとは「実行中のプログラム」であり、OSが管理する最も基本的な実行単位である。 本章では、プロセスの内部構造、状態遷移、生成モデル(fork/exec)、プロセス間通信(IPC)、 スケジューリングまでを体系的に扱い、OSの根幹メカニズムを理解する。

この章で学ぶこと

  • プロセスの定義と構成要素(PCB、メモリレイアウト)を説明できる
  • プロセスの5状態モデルとコンテキストスイッチを理解する
  • fork/execモデルの設計意図とCopy-on-Writeの仕組みを説明できる
  • CPUスケジューリングアルゴリズムの特徴と比較ができる
  • プロセス間通信(パイプ、共有メモリ、シグナル)を実装できる
  • ゾンビプロセス・孤児プロセスの発生原因と対処法を理解する

前提知識

このガイドを読む前に、以下の知識があると理解が深まります:

  • 基本的なプログラミングの知識
  • 関連する基礎概念の理解

1. プロセスとは何か

1.1 プログラムとプロセスの根本的な違い

「プログラム」と「プロセス」は明確に異なる概念である。プログラムはディスク上に格納された命令列(バイナリファイル)であり、それ自体は静的な存在である。一方、プロセスはそのプログラムがメモリに読み込まれ、CPUの実行コンテキスト(レジスタ値、プログラムカウンタ等)を伴って動作している動的な実体を指す。

この関係は、レシピ(プログラム)と調理中の料理(プロセス)に似ている。同じレシピから複数の料理を同時に作れるように、1つのプログラムから複数のプロセスを同時に生成できる。

プログラム vs プロセス:

  プログラム: ディスク上の実行ファイル(静的)
    - /usr/bin/python3, /usr/bin/bash 等
    - 実行権限を持つELFバイナリまたはスクリプト
    - それ自体はCPU時間を消費しない

  プロセス:   メモリに読み込まれて実行中のプログラム(動的)
    - PID(プロセスID)で一意に識別される
    - 専用のメモリ空間(仮想アドレス空間)を持つ
    - CPUレジスタの現在値、開いているファイル等の状態を持つ

  1つのプログラム → 複数のプロセスになりうる
  (例: Chromeのタブごとに独立プロセス)
  (例: 複数ユーザーが同時にbashを実行)

  逆は成り立たない:
  1つのプロセスは常に1つのプログラムに対応する
  (exec()で途中で別プログラムに変身することはあるが、
   ある瞬間には1つのプログラムを実行している)

1.2 プロセスのメモリレイアウト

各プロセスは独立した仮想アドレス空間を持つ。この仮想アドレス空間は、カーネルのメモリ管理ユニット(MMU)によって物理メモリにマッピングされる。仮想アドレス空間が存在する理由は、プロセス間のメモリ保護と、物理メモリ以上のアドレス空間の利用を可能にするためである。

プロセスの仮想アドレス空間(Linux x86-64の場合):
├────────────────────┤
func_b()のフレーム
└────────────────────┘
┌────────────────────┐
malloc()で確保した
動的メモリ
└────────────────────┘
(典型的な開始アドレス)

  なぜBSSとDataを分けるのか?
  → BSSは「ゼロで埋める」という情報だけ持てばよいため、
    実行ファイルのサイズを削減できる。
    例: int arr[1000000]; はBSSに入り、実行ファイルには
    「4MBをゼロ初期化せよ」という指示だけが記録される。

1.3 PCB(Process Control Block)

PCB(プロセス制御ブロック)は、OSがプロセスを管理するための中核データ構造である。コンテキストスイッチの際、現在のプロセスのCPU状態はPCBに保存され、次のプロセスのPCBからCPU状態が復元される。PCBが存在しなければ、OSはプロセスの状態を追跡できず、マルチタスクは不可能となる。

PCB(Process Control Block)の構成:
PCB (Linux では task_struct 構造体)
[識別情報]
├── PID (プロセスID): 一意の整数値
├── PPID (親プロセスID)
├── UID/GID (所有者/グループ)
└── セッションID、プロセスグループID
[CPUコンテキスト]
├── プログラムカウンタ (PC/RIP)
├── スタックポインタ (SP/RSP)
├── 汎用レジスタ (RAX, RBX, RCX, ...)
├── フラグレジスタ (RFLAGS)
└── FPU/SSE/AVXレジスタ
[スケジューリング情報]
├── プロセス状態 (Running/Ready/Blocked/...)
├── 優先度 (nice値: -20 〜 +19)
├── スケジューリングポリシー (CFS/RT/FIFO)
└── CPU使用時間の統計
[メモリ管理情報]
├── ページテーブルのベースアドレス (CR3)
├── 仮想メモリ領域のリスト (vm_area_struct)
├── コード/データ/ヒープ/スタックの境界
└── 共有ライブラリのマッピング情報
[I/O・ファイル情報]
├── ファイルディスクリプタテーブル
fd 0 → stdin
fd 1 → stdout
fd 2 → stderr
fd 3 → /tmp/data.txt (例)
├── カレントディレクトリ
└── umask
[シグナル情報]
├── 保留中のシグナルマスク
├── シグナルハンドラのテーブル
└── ブロックされたシグナルマスク
Linuxでのtask_structのサイズ:
  想定されるサイズは数KBから十数KB程度。
  カーネルはスラブアロケータを用いてこれを効率的に管理する。

1.4 コード例1: プロセス情報の取得(C言語)

以下のCプログラムは、自プロセスの基本情報を取得して表示する。getpid()getppid()getuid()はPOSIXシステムコールであり、PCBに格納された情報を返す。

/* process_info.c
 * コンパイル: gcc -Wall -o process_info process_info.c
 * 実行: ./process_info
 *
 * 目的: プロセスの基本的な属性(PID、PPID、UID等)を取得し、
 *       プロセスが持つ情報の一部を確認する。
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/resource.h>
#include <limits.h>
 
int global_initialized = 42;  /* Dataセグメントに配置される */
int global_uninitialized;     /* BSSセグメントに配置される */
 
int main(void)
{
    int stack_variable = 100;               /* スタックに配置 */
    int *heap_variable = malloc(sizeof(int)); /* ヒープに配置 */
    if (heap_variable == NULL) {
        perror("malloc");
        return 1;
    }
    *heap_variable = 200;
 
    printf("=== プロセス基本情報 ===\n");
    printf("PID  (プロセスID):   %d\n", getpid());
    printf("PPID (親プロセスID): %d\n", getppid());
    printf("UID  (ユーザーID):   %d\n", getuid());
    printf("GID  (グループID):   %d\n", getgid());
    printf("SID  (セッションID): %d\n", getsid(0));
 
    printf("\n=== メモリアドレスの確認 ===\n");
    printf("テキスト (main関数):      %p\n", (void *)main);
    printf("データ (初期化済み):      %p\n", (void *)&global_initialized);
    printf("BSS  (未初期化):          %p\n", (void *)&global_uninitialized);
    printf("ヒープ (malloc):          %p\n", (void *)heap_variable);
    printf("スタック (ローカル変数):   %p\n", (void *)&stack_variable);
 
    /* アドレスの大小関係を検証:
     * テキスト < データ < BSS < ヒープ < ... < スタック
     * が典型的な配置であることを確認する */
    printf("\n=== アドレス順序の検証 ===\n");
    if ((void *)main < (void *)&global_initialized)
        printf("テキスト < データ: 正常な配置\n");
    if ((void *)&global_uninitialized < (void *)heap_variable)
        printf("BSS < ヒープ: 正常な配置\n");
    if ((void *)heap_variable < (void *)&stack_variable)
        printf("ヒープ < スタック: 正常な配置\n");
 
    /* リソース制限の確認 */
    struct rlimit rl;
    if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
        printf("\n=== リソース制限 ===\n");
        printf("開けるファイル数: soft=%lu, hard=%lu\n",
               (unsigned long)rl.rlim_cur, (unsigned long)rl.rlim_max);
    }
    if (getrlimit(RLIMIT_STACK, &rl) == 0) {
        printf("スタックサイズ:   soft=%lu bytes (%lu MB)\n",
               (unsigned long)rl.rlim_cur,
               (unsigned long)rl.rlim_cur / (1024 * 1024));
    }
 
    free(heap_variable);
    return 0;
}

想定される出力:

=== プロセス基本情報 ===
PID  (プロセスID):   12345
PPID (親プロセスID): 12300
UID  (ユーザーID):   1000
GID  (グループID):   1000
SID  (セッションID): 12200

=== メモリアドレスの確認 ===
テキスト (main関数):      0x401196
データ (初期化済み):      0x404030
BSS  (未初期化):          0x404038
ヒープ (malloc):          0x1a3b2a0
スタック (ローカル変数):   0x7ffd5a3c1e4c

=== アドレス順序の検証 ===
テキスト < データ: 正常な配置
BSS < ヒープ: 正常な配置
ヒープ < スタック: 正常な配置

=== リソース制限 ===
開けるファイル数: soft=1024, hard=1048576
スタックサイズ:   soft=8388608 bytes (8 MB)

2. プロセスの状態遷移

2.1 5状態モデル

プロセスは、生成から終了までの間に複数の状態を遷移する。最も基本的なモデルは以下の5状態モデルである。なぜ5つの状態が必要なのかを理解するために、各状態の存在意義を考える。

  • New(生成): プロセスが作成されつつある状態。OSがPCBを割り当て、メモリ空間を準備している途中。この状態が存在する理由は、プロセス生成が即座に完了しないケース(リソース不足等)に対処するためである。
  • Ready(実行可能): CPUの割り当てを待っている状態。プロセスは実行に必要なリソースを全て持っているが、他のプロセスがCPUを使用中であるため待機している。
  • Running(実行中): CPUで命令を実行中の状態。シングルコアシステムでは、任意の瞬間にRunning状態のプロセスは最大1つだけである。マルチコアでは、コア数と同数のプロセスが同時にRunning状態になりうる。
  • Blocked(待機/ブロック): I/O完了やイベント発生を待っている状態。例えばディスク読み取りを要求したプロセスは、データが準備されるまでCPUを使う必要がないため、この状態に移される。Blocked状態が存在しなければ、I/O待ちのプロセスがCPUを無駄に占有し続けることになる。
  • Terminated(終了): 実行が完了した状態。OSがリソースを回収中である。exit()が呼ばれた後、親プロセスがwait()するまでの間もこの状態に留まる。
5状態モデルの状態遷移図:
  ┌───────┐    admit     ┌───────┐
  │  New  │─────────────→│ Ready │←─────────────────┐
  └───────┘              └───┬───┘                  │
dispatch │                    │ preempt
                     (スケジューラ│                    │(タイムスライス
                       が選択)   ↓                    │  満了/優先度)
                          ┌──────────┐               │
                          │ Running  │───────────────┘
                          └────┬──┬──┘
I/O要求   │  │  exit()/
                     /イベント │  │  シグナル
                     待ち      │  │
                              ↓  ↓
BlockedTerminated
I/O完了│
                  /イベント
                  発生    │
                         ↓
Ready
重要: Blocked → Running への直接遷移は存在しない。
  I/Oが完了したプロセスは、まずReady状態に移り、
  スケジューラによって選択されて初めてRunning状態になる。
  この設計により、スケジューラがCPU割り当ての一元管理を行える。

2.2 7状態モデル(スワッピング対応)

物理メモリが不足した場合、OSはプロセスの一部をディスク(スワップ領域)に退避させる。この機構を表現するために、5状態モデルに2つの状態を追加した7状態モデルが使われる。

7状態モデル(スワッピング対応):
  ┌───────┐         ┌───────┐
  │  New  │────────→│ Ready │←──────────────────────┐
  └───────┘         └──┬──┬─┘                       │
│  │                          │ preempt
                       │  │  swap out                │
                       │  ↓                          │
                       │  ┌────────────────┐         │
                       │  │ Ready/Suspend  │         │
                       │  │  (ディスク上)   │         │
                       │  └───────┬────────┘         │
                       │     swap in │                │
                       │          ↓                   │
                  dispatch│  ┌───────┐               │
                       ↓  │  │ Ready │               │
                    ┌──────────┐                      │
                    │ Running  │──────────────────────┘
                    └──┬───┬──┘
│   │
              I/O要求  │   │ exit()
                      ↓   ↓
BlockedTerminated
I/O完了│  │ swap out
                 ↓  ↓
ReadyBlocked/Suspend
└──────┬───────────┘
                    I/O完了 │
                           ↓
Ready/Suspend
Ready/Suspend:  実行可能だがメモリからスワップアウトされた状態
  Blocked/Suspend: I/O待ちかつスワップアウトされた状態

  なぜBlocked/Suspendが必要か?
  → メモリ不足時、Blockedプロセスはすぐには実行されないため、
    スワップアウトの最適な候補となる。I/O完了後はReady/Suspendに
    移り、必要に応じてスワップインされる。

2.3 コンテキストスイッチの詳細

コンテキストスイッチとは、CPUが現在実行中のプロセスを切り替える操作である。この操作がなければマルチタスクは実現できないが、純粋なオーバーヘッドであるため、高速に実行される必要がある。

コンテキストスイッチの手順:

  時間 →

  Process A (Running)          カーネル           Process B (Ready)
  ─────────────────          ─────────          ────────────────
       │                         │                     │
  [1] 割り込み/                   │                     │
      システムコール発生           │                     │
       │                         │                     │
       │  ──── trap ──────→     │                     │
       │                    [2] Process Aの            │
       │                        CPUコンテキストを      │
       │                        PCB-Aに保存            │
       │                        ・汎用レジスタ          │
       │                        ・プログラムカウンタ    │
       │                        ・スタックポインタ      │
       │                        ・RFLAGS              │
       │                        ・FPU/SSEレジスタ      │
       │                         │                     │
       │                    [3] スケジューラが          │
       │                        次のプロセスを選択     │
       │                        → Process Bを選択     │
       │                         │                     │
       │                    [4] アドレス空間の切替     │
       │                        ・CR3レジスタを        │
       │                         Process Bの           │
       │                         ページテーブルに変更  │
       │                        ・TLBフラッシュ        │
       │                         │                     │
       │                    [5] PCB-Bから              │
       │                        CPUコンテキストを復元  │
       │                         │                     │
       │                         │  ──── return ──→   │
       │                         │                [6] Process B
       │                         │                    実行再開
  (Ready状態)                     │                (Running状態)

コンテキストスイッチのコスト要因:

コスト要因 説明 影響度
レジスタ保存/復元 汎用レジスタ、FPU等の退避と復元
TLBフラッシュ ページテーブルキャッシュの無効化
キャッシュ汚染 L1/L2/L3キャッシュの中身が新プロセスのデータで置換
パイプラインフラッシュ CPU命令パイプラインのクリア
カーネルデータ構造の更新 ランキュー、統計情報等の更新

想定されるコンテキストスイッチの所要時間は、最新のハードウェアで数マイクロ秒程度である。ただし、キャッシュのウォームアップ(コールドキャッシュからの回復)を含めると、間接的な影響は数十マイクロ秒に及ぶ場合がある。

2.4 コード例2: プロセス状態の観察(Python)

以下のPythonスクリプトは、プロセスの状態遷移を擬似的に観察する。子プロセスを生成し、各段階での状態を /proc ファイルシステムから読み取る(Linux環境が必要)。

#!/usr/bin/env python3
"""process_states.py
プロセスの状態遷移を観察するデモプログラム。
Linux環境で実行すること(/procファイルシステムを使用するため)。
 
実行方法: python3 process_states.py
"""
import os
import sys
import time
import signal
 
def get_process_state(pid):
    """指定PIDのプロセス状態を/procから取得する。
 
    /proc/[pid]/statusファイルにはプロセスの状態が記録されている。
    State行の値は以下の通り:
      R: Running (実行中/実行可能)
      S: Sleeping (割り込み可能なスリープ = Blocked)
      D: Disk sleep (割り込み不可のスリープ)
      T: Stopped (停止)
      Z: Zombie (ゾンビ)
    """
    try:
        with open(f"/proc/{pid}/status", "r") as f:
            for line in f:
                if line.startswith("State:"):
                    return line.strip()
    except FileNotFoundError:
        return "プロセスが存在しない(終了済み)"
    except PermissionError:
        return "権限不足で読み取れない"
    return "状態不明"
 
def main():
    print(f"親プロセス PID: {os.getpid()}")
    print(f"親の状態: {get_process_state(os.getpid())}")
    print()
 
    # 子プロセスを生成
    pid = os.fork()
 
    if pid == 0:
        # --- 子プロセス ---
        print(f"[子] PID={os.getpid()} が生成された (Ready → Running)")
 
        # I/O操作でBlocked状態に遷移することをデモ
        print(f"[子] I/O操作(sleep)でBlocked状態へ...")
        time.sleep(2)  # sleepはSleeping(S)状態になる
 
        print(f"[子] I/O完了 → Ready → Running")
        print(f"[子] 正常終了 → Terminated")
        sys.exit(0)
    else:
        # --- 親プロセス ---
        time.sleep(0.5)  # 子が確実にsleep状態に入るのを待つ
 
        # 子プロセスの状態を観察
        state = get_process_state(pid)
        print(f"[親] 子プロセス(PID={pid})の状態: {state}")
        print(f"      → sleep中のため S (Sleeping) のはず")
        print()
 
        # 子が終了するのを待つが、すぐにwait()しない
        time.sleep(3)  # 子はすでに終了しているはず
 
        # wait()する前にゾンビ状態を確認
        state = get_process_state(pid)
        print(f"[親] 子プロセスの状態(wait前): {state}")
        print(f"      → wait()前なので Z (Zombie) の可能性がある")
 
        # wait()でゾンビを回収
        result_pid, status = os.waitpid(pid, 0)
        print(f"\n[親] waitpid完了: 子PID={result_pid}, 終了ステータス={os.WEXITSTATUS(status)}")
 
        # wait()後はプロセスが消滅
        state = get_process_state(pid)
        print(f"[親] 子プロセスの状態(wait後): {state}")
 
if __name__ == "__main__":
    if sys.platform != "linux":
        print("注意: このプログラムはLinux環境専用です(/procを使用)")
        print("macOSでは /proc が存在しないため、状態取得部分は動作しません")
        print("代わりに ps コマンドでプロセス状態を確認できます")
        print()
    main()

3. プロセスの生成: fork/exec モデル

3.1 設計思想: なぜforkとexecを分離するのか

Unix/Linuxのプロセス生成モデルは、fork()exec()という2つのシステムコールの組み合わせで構成される。WindowsのCreateProcess()のように1つのAPIで新プロセスを起動する方式と比較して、fork/execモデルには以下の利点がある。

  1. 柔軟なリダイレクション: fork()後、exec()前にファイルディスクリプタを操作することで、子プロセスの標準入出力を自由にリダイレクトできる。シェルのパイプ(ls | grep foo)はこの仕組みで実現されている。

  2. 環境の事前設定: 子プロセスの環境変数、ワーキングディレクトリ、シグナルマスク、リソース制限等をexec()前に設定できる。

  3. セキュリティ: exec()前に不要なファイルディスクリプタを閉じたり、権限を制限したりできる。

fork/exec モデルの全体像:

  親プロセス (PID=1000, /bin/bash)
       │
  [1]  │ fork() システムコール
       │
       ├────────────────────────────────────┐
       │                                    │
  親プロセス (PID=1000)              子プロセス (PID=1001)
  fork() は子のPID                   fork() は 0 を返す
  (= 1001) を返す                         │
       │                            [2]    │ exec()前の準備
       │                                   │ ・fd 1を出力ファイルに
       │                                   │   付け替え(リダイレクト)
       │                                   │ ・不要なfdをclose
       │                                   │ ・環境変数の設定
       │                                   │
       │                            [3]    │ execvp("ls", ["ls", "-la"])
       │                                   │
       │                            [4] プロセスのメモリ空間が
       │                                "ls" のバイナリで上書きされる
       │                                (PID は変わらない)
       │                                   │
       │                                   │ ls が実行される
       │                                   │
       │                            [5]    │ exit(0)
       │                                   │
  [6] waitpid(1001, &status, 0)            │
       │ ← 子の終了ステータスを受け取る    ↓ 消滅
       │
  (次のコマンドの処理へ)

  重要な性質:
  ・fork()後の子プロセスは親のほぼ完全なコピー
    (メモリ内容、ファイルディスクリプタ、シグナルハンドラ等)
  ・exec()はプロセスのメモリ空間を新しいプログラムで置換する
  ・exec()成功後、元のプログラムのコードには二度と戻らない
  ・PIDは fork() で変わり、exec() では変わらない

3.2 Copy-on-Write(CoW)

fork()は親プロセスのメモリ空間を子にコピーする必要があるが、全てのメモリを即座にコピーするのは極めて非効率である。現代のOSはCopy-on-Write(CoW)技術を用いて、この問題を解決している。

Copy-on-Write の仕組み:

  [fork()直後]

  親プロセスのページテーブル     物理メモリ     子プロセスのページテーブル
vaddr A → pf 1────────→page f 1←────vaddr A → pf 1
(read-only)"Hello"(read-only)
vaddr B → pf 2────────→page f 2←────vaddr B → pf 2
(read-only)data...(read-only)
→ 両プロセスは同じ物理ページを共有
  → 全ページがread-onlyにマークされている

  [子プロセスが vaddr A に書き込み]

  親プロセスのページテーブル     物理メモリ     子プロセスのページテーブル
vaddr A → pf 1────────→page f 1vaddr A → pf 3
(read-write)"Hello"(read-write)
vaddr B → pf 2────────→page f 2←────vaddr B → pf 2
(read-only)data...(read-only)
│ page f 3 │
                              │ "World"  │ ← 新しいコピー
                              └──────────┘

  → 書き込みが発生したページだけがコピーされる
  → 書き込まれないページは共有されたまま

  CoWの利点:
  1. fork()が高速(ページテーブルのコピーのみ、数十マイクロ秒)
  2. fork()+exec()パターンでは、exec()が全ページを置換するため、
     fork()時にコピーしたページは全て無駄になる。CoWはこの無駄を回避。
  3. メモリ使用量の削減(読み取り専用ページは共有のまま)

3.3 コード例3: fork/exec によるコマンド実行(C言語)

以下のプログラムは、シェルが内部で行っているfork/execの基本動作を再現する。リダイレクションの実装も含む。

/* fork_exec_demo.c
 * コンパイル: gcc -Wall -o fork_exec_demo fork_exec_demo.c
 * 実行: ./fork_exec_demo
 *
 * 目的: fork/execモデルでコマンドを実行し、
 *       リダイレクションの仕組みを理解する。
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
 
/* 指定されたコマンドを子プロセスで実行する。
 * output_file が非NULLの場合、stdoutをそのファイルにリダイレクトする。
 *
 * なぜfork後exec前にリダイレクト設定をするのか?
 * → exec()は現在のプロセスのプログラムを置換するが、
 *   ファイルディスクリプタテーブルは(FD_CLOEXECが設定されていない限り)
 *   そのまま引き継がれる。そのため、exec前にfd 1(stdout)を
 *   ファイルに付け替えておけば、新しいプログラムの標準出力が
 *   自動的にそのファイルに向く。
 */
int run_command(char *const argv[], const char *output_file)
{
    pid_t pid = fork();
 
    if (pid < 0) {
        /* fork失敗。考えられる原因:
         * - プロセス数上限(RLIMIT_NPROC)に達した
         * - メモリ不足
         * - PID空間の枯渇(極めて稀) */
        perror("fork");
        return -1;
    }
 
    if (pid == 0) {
        /* --- 子プロセス --- */
 
        /* リダイレクションが指定されている場合 */
        if (output_file != NULL) {
            /* 出力ファイルを開く
             * O_WRONLY: 書き込み専用
             * O_CREAT:  存在しなければ作成
             * O_TRUNC:  既存の内容を削除
             * 0644:     rw-r--r-- パーミッション */
            int fd = open(output_file, O_WRONLY | O_CREAT | O_TRUNC, 0644);
            if (fd < 0) {
                perror("open");
                _exit(1);  /* 子プロセスでは _exit() を使う。
                            * exit() を使うと、親プロセスで登録された
                            * atexit ハンドラが実行される可能性がある。 */
            }
 
            /* fd 1(stdout)をファイルに付け替え */
            if (dup2(fd, STDOUT_FILENO) < 0) {
                perror("dup2");
                _exit(1);
            }
 
            /* 元のfdはもう不要なので閉じる
             * (fd 1が同じファイルを指しているため) */
            close(fd);
        }
 
        /* プログラムを実行(現在のプロセスの内容が置換される) */
        execvp(argv[0], argv);
 
        /* execvpが返ってきた場合、エラーが発生している */
        fprintf(stderr, "execvp失敗: %s: %s\n", argv[0], strerror(errno));
        _exit(127);  /* コマンドが見つからない場合は127を返す慣習 */
    }
 
    /* --- 親プロセス --- */
    int status;
    if (waitpid(pid, &status, 0) < 0) {
        perror("waitpid");
        return -1;
    }
 
    if (WIFEXITED(status)) {
        int exit_code = WEXITSTATUS(status);
        printf("[親] 子プロセス(PID=%d)が終了コード %d で終了\n",
               pid, exit_code);
        return exit_code;
    } else if (WIFSIGNALED(status)) {
        int sig = WTERMSIG(status);
        printf("[親] 子プロセス(PID=%d)がシグナル %d で終了\n",
               pid, sig);
        return -1;
    }
 
    return -1;
}
 
int main(void)
{
    /* コマンド1: ls -la を実行(標準出力へ) */
    printf("=== ls -la の実行 ===\n");
    char *cmd1[] = {"ls", "-la", "/tmp", NULL};
    run_command(cmd1, NULL);
 
    /* コマンド2: ls -la の出力をファイルにリダイレクト */
    printf("\n=== ls -la > /tmp/ls_output.txt ===\n");
    char *cmd2[] = {"ls", "-la", "/tmp", NULL};
    run_command(cmd2, "/tmp/ls_output.txt");
    printf("出力が /tmp/ls_output.txt に書き込まれた\n");
 
    /* コマンド3: 存在しないコマンドを実行(エラー処理のデモ) */
    printf("\n=== 存在しないコマンドの実行 ===\n");
    char *cmd3[] = {"nonexistent_command", NULL};
    run_command(cmd3, NULL);
 
    return 0;
}

3.4 プロセスツリーとプロセス階層

Unix/Linuxでは全てのプロセスが親子関係で結ばれたツリー構造を形成する。このツリーの根はPID 1のinitプロセス(現代のLinuxではsystemd)である。

典型的なLinuxシステムのプロセスツリー:

  systemd (PID=1)  ← 全ユーザー空間プロセスの祖先
    │
    ├── systemd-journald (PID=200)    ← ログ管理デーモン
    │
    ├── systemd-udevd (PID=210)       ← デバイス管理
    │
    ├── sshd (PID=500)                ← SSHデーモン
    │   │
    │   └── sshd (PID=1500)           ← SSHセッション(特権分離)
    │       │
    │       └── bash (PID=1501)       ← ログインシェル
    │           │
    │           ├── vim (PID=1600)    ← ユーザーが起動したエディタ
    │           │
    │           └── python3 (PID=1700) ← ユーザーが起動したスクリプト
    │               │
    │               └── python3 (PID=1701) ← fork()で生成された子
    │
    ├── nginx (PID=600)               ← Webサーバー(マスタープロセス)
    │   ├── nginx (PID=601)           ← ワーカープロセス
    │   ├── nginx (PID=602)           ← ワーカープロセス
    │   ├── nginx (PID=603)           ← ワーカープロセス
    │   └── nginx (PID=604)           ← ワーカープロセス
    │
    ├── cron (PID=700)                ← 定期実行デーモン
    │
    └── docker (PID=800)              ← コンテナランタイム
        └── containerd-shim (PID=900)
            └── sleep (PID=901)       ← コンテナ内プロセス

  プロセスグループとセッション:
セッション (SID=1501)
┌───────────────────────────────────────────────┐
フォアグラウンドプロセスグループ (PGID=1600)
vim (PID=1600)
└───────────────────────────────────────────────┘
┌───────────────────────────────────────────────┐
バックグラウンドプロセスグループ (PGID=1700)
python3 (PID=1700)
python3 (PID=1701)
└───────────────────────────────────────────────┘
セッションリーダー: bash (PID=1501)
制御端末: /dev/pts/0
Ctrl+C は フォアグラウンドプロセスグループ全体にSIGINTを送る。
  バックグラウンドプロセスグループには影響しない。

4. 特殊なプロセス状態

4.1 ゾンビプロセス

ゾンビプロセスは、子プロセスが終了したにもかかわらず、親プロセスがその終了ステータスをwait()で回収していない状態で発生する。ゾンビプロセスはCPU時間やメモリをほとんど消費しないが、PCB(プロセスID含む)を保持し続けるため、大量に生成されるとPID空間が枯渇する恐れがある。

ゾンビが存在する理由: 子の終了ステータスを親に伝えるための仕組みである。もしOSが子の終了と同時にPCBを完全に削除すると、親がwait()を呼んだ時に子の終了コードを取得できなくなる。

4.2 孤児プロセス

親プロセスが子プロセスより先に終了した場合、子は「孤児プロセス」となる。孤児プロセスはPID 1(init/systemd)に養子として引き取られ、init/systemdが代わりにwait()を行ってリソースを回収する。

4.3 デーモンプロセス

デーモンは、制御端末を持たずバックグラウンドで動作するプロセスである。Webサーバー(nginx)、データベース(MySQL)、ログ管理(syslogd)等のシステムサービスがデーモンとして動作する。

デーモン化の古典的な手順:

  [1] fork() → 親を exit()
      → 子は孤児になり、initに引き取られる
      → シェルは親の終了を検知してプロンプトに戻る

  [2] setsid()
      → 新しいセッションを作成し、セッションリーダーになる
      → 制御端末から切り離される

  [3] 2回目のfork() → 最初の子を exit()
      → セッションリーダーでなくなり、
        制御端末を再取得できなくなる(安全策)

  [4] chdir("/")
      → カレントディレクトリをルートに変更
      → デーモンが特定のディレクトリをロックしないように

  [5] umask(0)
      → ファイル作成マスクをクリア
      → デーモンが作成するファイルのパーミッションを明示制御

  [6] stdin/stdout/stderr を /dev/null にリダイレクト
      → 制御端末がないため、これらのfdは使えない
      → ログはsyslogまたは専用ログファイルに出力

  現代的な方法:
  systemdの場合、Type=simpleでサービスを登録すれば
  上記の複雑な手順は不要。systemdが管理する。

4.4 コード例4: ゾンビプロセスの生成と回収(C言語)

/* zombie_demo.c
 * コンパイル: gcc -Wall -o zombie_demo zombie_demo.c
 * 実行: ./zombie_demo
 *
 * 目的: ゾンビプロセスの発生メカニズムと回収方法を理解する。
 *       実行中に別ターミナルで ps aux | grep zombie で確認すると、
 *       Z状態のプロセスが見える。
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
 
/* === 方法1: 明示的にwait()で回収 === */
void demo_explicit_wait(void)
{
    printf("\n=== 方法1: 明示的なwait() ===\n");
    pid_t pid = fork();
 
    if (pid == 0) {
        /* 子プロセス: すぐに終了 */
        printf("[子] PID=%d 終了します\n", getpid());
        _exit(42);
    }
 
    /* 親プロセス: 5秒間wait()しない → ゾンビが発生 */
    printf("[親] 子(PID=%d)が終了。5秒間wait()しません...\n", pid);
    printf("[親] この間に 'ps aux | grep zombie' で Z 状態を確認してください\n");
    sleep(5);
 
    /* wait()でゾンビを回収 */
    int status;
    waitpid(pid, &status, 0);
    printf("[親] 子を回収。終了コード=%d\n", WEXITSTATUS(status));
}
 
/* === 方法2: SIGCHLDシグナルハンドラで自動回収 === */
volatile sig_atomic_t child_count = 0;
 
void sigchld_handler(int signo)
{
    (void)signo;  /* 未使用パラメータ警告を抑制 */
 
    /* wait()を非ブロッキングで呼び、全ての終了済み子を回収する。
     * なぜwhileループか? → 複数の子が同時に終了した場合、
     * SIGCHLDは1回しか配送されないことがあるため、
     * ループで全て回収する必要がある。 */
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        child_count++;
        /* シグナルハンドラ内ではasync-signal-safe関数のみ使用可能。
         * printf()はasync-signal-safeでないが、デモのため使用。
         * 本番コードではwrite()を使うべき。 */
    }
}
 
void demo_sigchld_handler(void)
{
    printf("\n=== 方法2: SIGCHLDハンドラによる自動回収 ===\n");
 
    /* SIGCHLDハンドラを設定 */
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;  /* システムコールの自動再起動 */
    sigaction(SIGCHLD, &sa, NULL);
 
    /* 5つの子プロセスを生成 */
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            printf("[子%d] PID=%d 終了します\n", i, getpid());
            _exit(0);
        }
        printf("[親] 子%d(PID=%d)を生成\n", i, pid);
    }
 
    /* ハンドラが全ての子を回収するのを待つ */
    sleep(2);
    printf("[親] 回収された子プロセス数: %d\n", child_count);
}
 
/* === 方法3: SIGCHLDをSIG_IGNに設定 === */
void demo_sigign(void)
{
    printf("\n=== 方法3: SIGCHLD を SIG_IGN に設定 ===\n");
 
    /* SIGCHLDをSIG_IGNに設定すると、子プロセスは終了時に
     * 自動的に回収され、ゾンビにならない。
     * ただし、wait()で子の終了ステータスを取得できなくなる。
     * この動作はPOSIXで明確に定義されている。 */
    signal(SIGCHLD, SIG_IGN);
 
    pid_t pid = fork();
    if (pid == 0) {
        printf("[子] PID=%d 終了します\n", getpid());
        _exit(0);
    }
 
    sleep(1);
    printf("[親] ゾンビは発生しない(自動回収される)\n");
 
    /* デフォルトに戻す */
    signal(SIGCHLD, SIG_DFL);
}
 
int main(void)
{
    printf("ゾンビプロセスのデモ (親PID=%d)\n", getpid());
 
    demo_explicit_wait();
    demo_sigchld_handler();
    demo_sigign();
 
    printf("\n全てのデモが完了\n");
    return 0;
}

5. CPUスケジューリング

5.1 スケジューリングの必要性

マルチタスクOSでは、複数のReady状態のプロセスがCPUの割り当てを待っている。スケジューラは、どのプロセスに次にCPUを割り当てるかを決定するOSコンポーネントである。スケジューリングアルゴリズムの選択は、システムの応答性、スループット、公平性に直接影響する。

5.2 スケジューリングの評価基準

評価基準 定義 最適化の方向
CPU使用率 CPUが有用な作業を行っている時間の割合 最大化(理想: 100%)
スループット 単位時間あたりに完了するプロセス数 最大化
ターンアラウンド時間 プロセスの到着から完了までの全時間 最小化
待ち時間 プロセスがReadyキューで待った合計時間 最小化
応答時間 リクエストから最初の応答までの時間 最小化(対話型システムで重要)

5.3 主要なスケジューリングアルゴリズム

[1] FCFS (First-Come, First-Served)
    到着順に実行する。最も単純。

    Ready キュー: [P1(24ms)] [P2(3ms)] [P3(3ms)]
                   ← 先頭                    末尾 →

    ガントチャート:
    │ P1                        │P2 │P3 │
    0                          24  27  30

    待ち時間: P1=0, P2=24, P3=27
    平均待ち時間: (0+24+27)/3 = 17ms

    問題: コンボイ効果(Convoy Effect)
    → CPU集約型の長いプロセスが先に来ると、
      短いプロセスが長時間待たされる。

[2] SJF (Shortest Job First)
    実行時間が最短のプロセスを優先。
    理論上、平均待ち時間を最小化する(証明済み)。

    Ready キュー: [P1(24ms)] [P2(3ms)] [P3(3ms)]

    SJFによる実行順序:
    │P2 │P3 │ P1                        │
    0   3   6                          30

    待ち時間: P1=6, P2=0, P3=3
    平均待ち時間: (6+0+3)/3 = 3ms  ← FCFSの17msから大幅改善

    問題: 実行時間の予測が困難。
    → 過去の実行履歴から指数平均で推定する手法がある。
    → 長いプロセスが飢餓(starvation)する可能性がある。

[3] Round Robin (RR)
    各プロセスにタイムクォンタム(時間量子)を割り当て、
    その時間が経過したら次のプロセスに切り替える。

    タイムクォンタム q=4ms の場合:

    │P1 │P2 │P3 │P1 │P1 │P1 │P1 │P1 │
    0   4   7  10  14  18  22  26  30

    特性:
    ・q → ∞ ならFCFSと同じ
    ・q → 0 ならプロセッサ共有(理想的な公平性、
      ただしコンテキストスイッチのオーバーヘッドが支配的になる)
    ・想定される適切なq: 10ms〜100ms程度
    ・コンテキストスイッチのコストを考慮すると、
      qはスイッチコストの100倍以上が望ましい

[4] 優先度スケジューリング
    各プロセスに優先度を割り当て、高優先度のプロセスを先に実行。

    問題: 低優先度プロセスの飢餓(starvation)
    解決: エイジング(aging)
    → 待ち時間が長くなるほど優先度を徐々に上げる

[5] マルチレベルフィードバックキュー (MLFQ)
    複数の優先度キューを持ち、プロセスの振る舞いに応じて
    キュー間を移動させる。現代的なOSスケジューラの基盤。

    キュー0 (最高優先度, q=8ms):   [P_new1] [P_new2]
    キュー1 (中優先度,   q=16ms):  [P_mid1]
    キュー2 (低優先度,   q=32ms):  [P_long1] [P_long2]

    ルール:
    1. 新しいプロセスは最高優先度キューに入る
    2. タイムクォンタムを使い切ったら1段下のキューに降格
    3. I/O待ちで自発的にCPUを返したら現在のキューに留まる
    4. 一定期間ごとに全プロセスを最高優先度キューに戻す
       (飢餓防止のため: Priority Boost)

5.4 スケジューリングアルゴリズムの比較表

アルゴリズム プリエンプティブ 飢餓 応答性 実装複雑度 適用領域
FCFS No No 低い 非常に低い バッチ処理
SJF(非プリエンプティブ) No Yes 低い 中(予測が必要) バッチ処理
SRTF(SJFのプリエンプティブ版) Yes Yes 理論的な最適解
Round Robin Yes No 高い 低い 対話型・汎用
優先度 Yes/No Yes 優先度依存 低い リアルタイム
MLFQ Yes No(ブースト有) 高い 高い 汎用OS
CFS(Linux) Yes No 高い 高い Linux標準

5.5 Linux CFS(Completely Fair Scheduler)

Linux 2.6.23以降で採用されているCFSは、全てのプロセスに「公平な」CPU時間を分配することを目標とする。赤黒木(Red-Black Tree)を用いてReady状態のプロセスを管理し、最もCPU時間を使っていないプロセス(仮想実行時間 vruntime が最小のプロセス)を次に実行する。

CFS の仮想実行時間(vruntime):

  vruntime = 実際の実行時間 × (NICE_0_LOAD / プロセスの重み)

  nice値が低い(優先度が高い)プロセスは重みが大きいため、
  同じ実際の実行時間でもvruntimeの増加が少なく、
  結果的により多くのCPU時間を得る。

  赤黒木の構造:
[vruntime=50] ← root
╱ ╲
[vruntime=30] [vruntime=70]
╱ ╲ ╱ ╲
[20] [40] [60] [80]
最左ノード [vruntime=20] が次に実行される
→ O(1) でアクセス可能(キャッシュ済み)
→ 赤黒木への挿入/削除は O(log n)
CFSのタイムスライス計算:
  タイムスライス = (プロセスの重み / 全Readyプロセスの重みの合計)
                  × スケジューリング周期

  スケジューリング周期のデフォルト: 6ms × Readyプロセス数
  (ただし最小粒度は0.75ms)

6. プロセス間通信(IPC)

6.1 IPCの必要性と分類

プロセスは独立したアドレス空間を持つため、他のプロセスのメモリに直接アクセスできない。プロセス間でデータを交換したり、動作を同期したりするためにIPC(Inter-Process Communication)メカニズムが必要となる。

IPC メカニズムの分類:
IPC メカニズム
メッセージパッシング共有メモリ
(データのコピー)(メモリの共有)
・パイプ (pipe)・POSIX共有メモリ
・名前付きパイプ(shm_open)
(FIFO)・System V共有メモリ
・メッセージキュー(shmget/shmat)
・ソケット・mmap (ファイル共有)
・シグナル
特徴:特徴:
・カーネルが仲介・カーネルの仲介は最初だけ
・データのコピーが・データコピーなし(高速)
発生(低速)・同期は自分で管理
・同期が容易(セマフォ、ミューテックス等)

6.2 パイプ(Pipe)

パイプはUnix最古のIPCメカニズムの1つであり、シェルの | 記号で日常的に使用されている。パイプは一方向のバイトストリームであり、書き込み側が閉じられると読み取り側にEOFが通知される。

6.3 コード例5: パイプによるプロセス間通信(C言語)

以下のプログラムは、ls -la | grep ".txt" と同等の処理をC言語で実装する。2つの子プロセスをパイプで接続する方法を示す。

/* pipe_demo.c
 * コンパイル: gcc -Wall -o pipe_demo pipe_demo.c
 * 実行: ./pipe_demo
 *
 * 目的: パイプを使ったプロセス間通信の仕組みを理解する。
 *       ls | grep のようなシェルパイプラインの内部動作を再現する。
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
 
int main(void)
{
    int pipefd[2];
 
    /* pipe() はファイルディスクリプタのペアを作成する。
     * pipefd[0]: 読み取り用(パイプの出口)
     * pipefd[1]: 書き込み用(パイプの入口)
     *
     * なぜ2つのfdが必要か?
     * → パイプは一方向通信。データの流れの方向を明確にするため、
     *   書き込み用と読み取り用を別々のfdで管理する。
     */
    if (pipe(pipefd) < 0) {
        perror("pipe");
        return 1;
    }
 
    printf("パイプ作成: read_fd=%d, write_fd=%d\n", pipefd[0], pipefd[1]);
 
    /* === 第1子プロセス: ls -la /tmp === */
    pid_t pid1 = fork();
    if (pid1 < 0) {
        perror("fork");
        return 1;
    }
 
    if (pid1 == 0) {
        /* 子プロセス1: ls コマンド
         *
         * stdout (fd 1) をパイプの書き込み端に付け替える。
         * lsの出力がパイプに流れ込むようになる。
         */
        close(pipefd[0]);  /* 読み取り端は使わないので閉じる。
                            * 閉じないと、パイプの読み取り端を持つプロセスが
                            * 残り続け、grep側でEOFを検知できなくなる。 */
 
        dup2(pipefd[1], STDOUT_FILENO);  /* fd 1 → パイプ書き込み端 */
        close(pipefd[1]);  /* dup2後は元のfdは不要 */
 
        execlp("ls", "ls", "-la", "/tmp", NULL);
        perror("execlp ls");
        _exit(1);
    }
 
    /* === 第2子プロセス: grep === */
    pid_t pid2 = fork();
    if (pid2 < 0) {
        perror("fork");
        return 1;
    }
 
    if (pid2 == 0) {
        /* 子プロセス2: grep コマンド
         *
         * stdin (fd 0) をパイプの読み取り端に付け替える。
         * パイプから流れてくるデータをgrepの入力にする。
         */
        close(pipefd[1]);  /* 書き込み端は使わないので閉じる */
 
        dup2(pipefd[0], STDIN_FILENO);  /* fd 0 → パイプ読み取り端 */
        close(pipefd[0]);
 
        /* "." を含む行を検索(任意のファイルにマッチ) */
        execlp("grep", "grep", "--color=auto", "\\.", NULL);
        perror("execlp grep");
        _exit(1);
    }
 
    /* === 親プロセス === */
    /* 親プロセスはパイプの両端を閉じる必要がある。
     * 特に書き込み端を閉じないと、パイプに書き込むプロセスが
     * 残り続けることになり、grep側がEOFを受け取れない。 */
    close(pipefd[0]);
    close(pipefd[1]);
 
    /* 両方の子プロセスの終了を待つ */
    int status1, status2;
    waitpid(pid1, &status1, 0);
    waitpid(pid2, &status2, 0);
 
    printf("\n--- パイプライン完了 ---\n");
    printf("ls  終了コード: %d\n", WEXITSTATUS(status1));
    printf("grep 終了コード: %d\n", WEXITSTATUS(status2));
 
    return 0;
}

6.4 シグナル

シグナルは、プロセスに非同期的にイベントを通知するソフトウェア割り込みである。シグナルの種類は限られているが、プロセスの制御や異常終了の処理に不可欠な仕組みである。

主要なシグナル一覧:

  シグナル    番号  デフォルト動作   発生契機
  ──────────────────────────────────────────────────────
  SIGHUP       1   終了           制御端末のハングアップ
  SIGINT       2   終了           Ctrl+C
  SIGQUIT      3   コアダンプ     Ctrl+\
  SIGILL       4   コアダンプ     不正な命令の実行
  SIGABRT      6   コアダンプ     abort() の呼び出し
  SIGFPE       8   コアダンプ     浮動小数点例外(ゼロ除算等)
  SIGKILL      9   終了           強制終了(キャッチ・無視不可)
  SIGSEGV     11   コアダンプ     不正なメモリアクセス
  SIGPIPE     13   終了           読み手のいないパイプへの書き込み
  SIGALRM     14   終了           alarm() タイマー満了
  SIGTERM     15   終了           終了要求(キャッチ可能)
  SIGCHLD     17   無視           子プロセスの状態変化
  SIGCONT     18   再開           停止プロセスの再開
  SIGSTOP     19   停止           プロセスの停止(キャッチ不可)
  SIGTSTP     20   停止           Ctrl+Z

  SIGKILL と SIGSTOP の2つだけは、キャッチも無視もブロックもできない。
  これは、管理者がどんなプロセスも確実に停止/終了できることを保証するため。

6.5 IPC メカニズムの比較表

メカニズム 通信方向 関連プロセス 速度 永続性 典型的な用途
パイプ 単方向 親子 なし シェルパイプライン
名前付きパイプ (FIFO) 単方向 任意 ファイルシステム デーモンとの通信
メッセージキュー 単方向 任意 カーネル 構造化メッセージ
共有メモリ 双方向 任意 カーネル 大量データ共有
ソケット (UNIX) 双方向 任意 中〜高 なし ローカルサーバー通信
ソケット (TCP/IP) 双方向 任意(ネットワーク越し) なし ネットワーク通信
シグナル 単方向 任意 なし イベント通知
ファイル 双方向 任意 ディスク 永続データ共有

6.6 コード例6: 共有メモリによるプロセス間通信(C言語)

共有メモリはIPCの中で最も高速な手段である。カーネルを介したデータのコピーが不要なため、大量のデータを交換する場合に適している。ただし、同期処理を自分で管理する必要がある点に注意が必要である。

/* shared_memory_demo.c
 * コンパイル: gcc -Wall -o shared_memory_demo shared_memory_demo.c -lrt -lpthread
 * 実行: ./shared_memory_demo
 *
 * 目的: POSIX共有メモリとセマフォを使った
 *       プロセス間通信の仕組みを理解する。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <semaphore.h>
 
/* 共有メモリ上に配置するデータ構造 */
typedef struct {
    sem_t sem_producer;  /* 書き手の同期用セマフォ */
    sem_t sem_consumer;  /* 読み手の同期用セマフォ */
    int   data;          /* 共有データ */
    int   done;          /* 終了フラグ */
} shared_data_t;
 
#define SHM_NAME "/process_demo_shm"
 
int main(void)
{
    /* POSIX共有メモリオブジェクトを作成する。
     * shm_open()はファイルディスクリプタを返す。
     * このfdは/dev/shm以下のファイルに対応する(Linuxの場合)。
     *
     * なぜファイルベースのAPIなのか?
     * → "全てはファイル"というUnix哲学に従い、
     *   既存のファイル操作API(ftruncate, mmap等)を
     *   再利用できるようにしている。 */
    int shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
    if (shm_fd < 0) {
        perror("shm_open");
        return 1;
    }
 
    /* 共有メモリのサイズを設定 */
    if (ftruncate(shm_fd, sizeof(shared_data_t)) < 0) {
        perror("ftruncate");
        return 1;
    }
 
    /* 共有メモリをプロセスのアドレス空間にマッピング */
    shared_data_t *shm = mmap(NULL, sizeof(shared_data_t),
                              PROT_READ | PROT_WRITE,
                              MAP_SHARED, shm_fd, 0);
    if (shm == MAP_FAILED) {
        perror("mmap");
        return 1;
    }
    close(shm_fd);  /* mmapした後はfdを閉じてよい */
 
    /* プロセス間で共有可能なセマフォを初期化。
     * 第2引数の1はプロセス間共有を意味する(0ならスレッド間のみ)。 */
    sem_init(&shm->sem_producer, 1, 1);  /* 最初は書き手が動ける */
    sem_init(&shm->sem_consumer, 1, 0);  /* 読み手はデータ待ち */
    shm->done = 0;
 
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
 
    if (pid == 0) {
        /* --- 子プロセス(消費者/Consumer) --- */
        printf("[Consumer PID=%d] 開始\n", getpid());
 
        while (1) {
            sem_wait(&shm->sem_consumer);  /* データが来るまで待つ */
 
            if (shm->done) {
                printf("[Consumer] 終了シグナルを受信\n");
                break;
            }
 
            printf("[Consumer] 受信データ: %d\n", shm->data);
            sem_post(&shm->sem_producer);  /* 書き手に次を許可 */
        }
 
        _exit(0);
    }
 
    /* --- 親プロセス(生産者/Producer) --- */
    printf("[Producer PID=%d] 開始\n", getpid());
 
    for (int i = 1; i <= 5; i++) {
        sem_wait(&shm->sem_producer);  /* 書き込み許可を待つ */
 
        shm->data = i * 10;
        printf("[Producer] 送信データ: %d\n", shm->data);
 
        sem_post(&shm->sem_consumer);  /* 読み手にデータ到着を通知 */
        usleep(100000);  /* 100ms待機(動作を見やすくするため) */
    }
 
    /* 終了通知 */
    sem_wait(&shm->sem_producer);
    shm->done = 1;
    sem_post(&shm->sem_consumer);
 
    /* 子プロセスの終了を待つ */
    waitpid(pid, NULL, 0);
 
    /* クリーンアップ */
    sem_destroy(&shm->sem_producer);
    sem_destroy(&shm->sem_consumer);
    munmap(shm, sizeof(shared_data_t));
    shm_unlink(SHM_NAME);
 
    printf("[Producer] 全データの送受信が完了\n");
    return 0;
}

想定される出力:

[Producer PID=5000] 開始
[Consumer PID=5001] 開始
[Producer] 送信データ: 10
[Consumer] 受信データ: 10
[Producer] 送信データ: 20
[Consumer] 受信データ: 20
[Producer] 送信データ: 30
[Consumer] 受信データ: 30
[Producer] 送信データ: 40
[Consumer] 受信データ: 40
[Producer] 送信データ: 50
[Consumer] 受信データ: 50
[Consumer] 終了シグナルを受信
[Producer] 全データの送受信が完了

6.7 コード例7: Pythonによるマルチプロセス処理

#!/usr/bin/env python3
"""multiprocess_demo.py
Pythonのmultiprocessingモジュールを用いたプロセス間通信のデモ。
 
実行方法: python3 multiprocess_demo.py
 
multiprocessingモジュールは内部的にfork()/exec()やパイプ、
共有メモリなどのOS機構を抽象化して提供している。
"""
import multiprocessing
import os
import time
import sys
 
def producer(queue, count):
    """キューにデータを送信する生産者プロセス。
 
    multiprocessing.Queueは内部的にパイプとロックを使用している。
    プロセス間で安全にデータを受け渡すための高レベルAPIである。
    """
    print(f"[Producer PID={os.getpid()}] 開始")
    for i in range(count):
        item = f"item-{i:03d}"
        queue.put(item)
        print(f"[Producer] 送信: {item}")
        time.sleep(0.1)
    # 終了を示すセンチネル値を送信
    queue.put(None)
    print(f"[Producer] 全アイテム送信完了")
 
def consumer(queue, result_dict, worker_id):
    """キューからデータを受信する消費者プロセス。
 
    result_dictはmultiprocessing.Managerが提供する共有辞書。
    プロセス間で辞書を共有するために、Managerプロセスが仲介する。
    """
    print(f"[Consumer-{worker_id} PID={os.getpid()}] 開始")
    processed = []
    while True:
        item = queue.get()  # データが来るまでブロック
        if item is None:
            # センチネル値を受け取ったら終了
            # 他のConsumerにも終了を伝えるためキューに戻す
            queue.put(None)
            break
        result = f"{item} -> processed by worker-{worker_id}"
        processed.append(result)
        print(f"[Consumer-{worker_id}] 処理: {item}")
        time.sleep(0.05)  # 処理のシミュレーション
 
    result_dict[worker_id] = processed
    print(f"[Consumer-{worker_id}] 終了. 処理件数={len(processed)}")
 
def demo_shared_value():
    """共有メモリを使ったカウンターのデモ。
 
    multiprocessing.Value は内部的にmmap()を使用して
    プロセス間で共有されるメモリ領域を作成している。
    ロックが内蔵されているため、競合状態を自動的に防止する。
    """
    print("\n=== 共有メモリカウンターのデモ ===")
 
    counter = multiprocessing.Value('i', 0)  # 共有整数値
    barrier = multiprocessing.Barrier(4)      # 4プロセスの同期用
 
    def increment(shared_counter, sync_barrier, n):
        """共有カウンターをn回インクリメントする。"""
        sync_barrier.wait()  # 全プロセスの準備完了を待つ
        for _ in range(n):
            with shared_counter.get_lock():
                # get_lock()でロックを取得してから値を変更する。
                # ロックなしだと、読み取り→加算→書き込みの間に
                # 他のプロセスが割り込み、更新が失われる
                # (lost update問題)。
                shared_counter.value += 1
 
    processes = []
    increments_per_process = 10000
    for i in range(4):
        p = multiprocessing.Process(
            target=increment,
            args=(counter, barrier, increments_per_process)
        )
        processes.append(p)
        p.start()
 
    for p in processes:
        p.join()
 
    expected = 4 * increments_per_process
    actual = counter.value
    print(f"期待値: {expected}, 実際値: {actual}")
    if expected == actual:
        print("ロックによる排他制御が正しく機能した")
    else:
        print("競合状態が発生(ロックが不十分)")
 
def main():
    print(f"=== メインプロセス PID={os.getpid()} ===\n")
 
    # === Producer-Consumer パターン ===
    print("=== Producer-Consumer パターン ===")
    queue = multiprocessing.Queue()
 
    # Managerを使ったプロセス間での辞書共有
    manager = multiprocessing.Manager()
    result_dict = manager.dict()
 
    # 1つのProducerと2つのConsumerを起動
    prod = multiprocessing.Process(target=producer, args=(queue, 10))
    cons1 = multiprocessing.Process(target=consumer, args=(queue, result_dict, 1))
    cons2 = multiprocessing.Process(target=consumer, args=(queue, result_dict, 2))
 
    prod.start()
    cons1.start()
    cons2.start()
 
    prod.join()
    cons1.join()
    cons2.join()
 
    print(f"\n処理結果サマリー:")
    for worker_id, items in result_dict.items():
        print(f"  Worker-{worker_id}: {len(items)}件処理")
 
    # === 共有メモリデモ ===
    demo_shared_value()
 
    print(f"\n=== 全デモ完了 ===")
 
if __name__ == "__main__":
    # multiprocessingモジュールを使う場合、
    # __name__ == "__main__" ガードが必須。
    # なぜか? → fork()やspawn()で子プロセスが生成される際、
    # モジュールが再importされるため、ガードがないと
    # 子プロセスでもmain()が再実行されてしまう。
    main()

7. アンチパターンと落とし穴

7.1 アンチパターン1: ゾンビプロセスの放置

問題: 子プロセスを生成した後、wait()waitpid()を呼ばずに放置すると、子プロセスが終了してもPCBが残り続ける(ゾンビプロセス)。長時間稼働するサーバープロセスでこれを繰り返すと、PID空間が枯渇し、新たなプロセスを生成できなくなる。

/* アンチパターン: wait()を忘れたサーバー */
/* !!!!! このコードは悪い例です !!!!! */
while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    pid_t pid = fork();
    if (pid == 0) {
        handle_client(client_fd);
        _exit(0);
    }
    close(client_fd);
    /* ここで wait() を呼んでいない!
     * 子プロセスが終了するたびにゾンビが蓄積する。
     * 1日に10,000リクエストを処理するサーバーなら、
     * 1日で10,000個のゾンビが発生する。
     * /proc/sys/kernel/pid_max (通常32768)を超えると
     * fork()がENOSPC/EAGAINで失敗し始める。 */
}
 
/* 正しいパターン: SIGCHLDハンドラで自動回収 */
/* この方法なら、メインループをブロックせずにゾンビを回収できる */
void sigchld_handler(int sig) {
    (void)sig;
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;  /* 全ての終了済み子プロセスを回収 */
}
 
/* main()の初期化部分で: */
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);

なぜ危険か: ゾンビプロセス自体はCPUやメモリをほぼ消費しないが、以下のリソースを保持し続ける。

  • PIDエントリ(/proc/[pid] ディレクトリ)
  • カーネル内のtask_struct構造体の一部
  • プロセスIDそのもの(PID空間は有限)

検出方法: ps aux | grep Z でゾンビプロセスを確認できる。STATカラムに Z+ と表示される。

7.2 アンチパターン2: fork()後のファイルディスクリプタリーク

問題: fork()は親プロセスの全てのファイルディスクリプタを子プロセスにコピーする。不要なfdを閉じずにexec()すると、子プロセスが意図しないファイルやソケットを保持し続ける。

/* アンチパターン: fdリーク */
/* !!!!! このコードは悪い例です !!!!! */
int log_fd = open("/var/log/app.log", O_WRONLY | O_APPEND);
int db_fd = connect_to_database();  /* データベース接続ソケット */
 
pid_t pid = fork();
if (pid == 0) {
    /* 子プロセスは log_fd と db_fd をそのまま継承している。
     * exec()でプログラムを置換しても、FD_CLOEXECが設定されて
     * いなければ、新しいプログラムからこれらのfdにアクセスできる。
     *
     * 問題点:
     * 1. 子プロセスがDB接続を意図せず保持する
     *    → コネクションプールの枯渇
     * 2. ログファイルへの書き込みが競合する可能性
     * 3. セキュリティリスク(子プロセスが信頼されない
     *    プログラムを実行する場合) */
    execvp(cmd[0], cmd);
    _exit(1);
}
 
/* 正しいパターン: 不要なfdをclose、またはO_CLOEXECを使う */
 
/* 方法A: fork後、exec前にclose */
pid_t pid2 = fork();
if (pid2 == 0) {
    close(log_fd);
    close(db_fd);
    execvp(cmd[0], cmd);
    _exit(1);
}
 
/* 方法B: O_CLOEXECフラグ(推奨)
 * ファイルを開く時にO_CLOEXECを設定しておけば、
 * exec()の際に自動的に閉じられる。
 * fork()後にclose()を忘れる心配がない。 */
int safe_fd = open("/var/log/app.log",
                   O_WRONLY | O_APPEND | O_CLOEXEC);

8. エッジケース分析

8.1 エッジケース1: fork()とマルチスレッドの組み合わせ

マルチスレッドプログラムからfork()を呼び出すことは、最も危険なエッジケースの1つである。fork()は呼び出したスレッドのみを子プロセスにコピーし、他のスレッドは存在しない。この時、他のスレッドがロックを保持していた場合、子プロセスではそのロックが永久に解放されない(デッドロック)。

マルチスレッド + fork の問題:

  親プロセス:
Thread A Thread B
mutex_lock(m)
fork() ←── この瞬間にfork
子プロセス:
Thread A のコピーのみ
mutex_lock(m) ← デッドロック!
Thread Bは存在しないので
mutexは永久に解放されない
(永遠にブロック)
POSIX の pthread_atfork() を使えば、fork前後でロックを
  管理できるが、全てのライブラリのロックを把握するのは
  現実的に困難である。

  推奨される対策:
  1. マルチスレッドプロセスからはfork()しない
     → 代わりにposix_spawn()を使う
  2. fork()後はexec()のみを行う(exec前に複雑な処理をしない)
  3. fork()が必要ならスレッド生成前に行う

8.2 エッジケース2: PID の再利用(PID Recycling)

PIDは有限のリソースであり、プロセスが終了するとそのPIDは再利用される。これにより、PIDをキーとしてプロセスを追跡しているプログラムで、意図しないプロセスにシグナルを送信してしまう問題(TOCTOU = Time-of-Check Time-of-Use)が発生しうる。

PID再利用による問題:

  時刻T1: Process X (PID=5000) が動作中
           監視プログラムが PID=5000 を記録

  時刻T2: Process X (PID=5000) が終了
           PID 5000 が解放される

  時刻T3: 新しい Process Y が PID=5000 で生成される

  時刻T4: 監視プログラムが kill(5000, SIGTERM) を実行
           → Process X ではなく Process Y が終了される!

  対策:
  1. pidfd_open() (Linux 5.3+) を使用する
     → PIDの代わりにファイルディスクリプタでプロセスを参照
     → fdはプロセスの終了後に無効化されるため、再利用問題がない
     int pidfd = pidfd_open(pid, 0);
     pidfd_send_signal(pidfd, SIGTERM, NULL, 0);

  2. 子プロセスの場合は waitid() で WNOWAIT を使い、
     プロセスの存在を確認してからシグナルを送る

  3. cgroupsを使ってプロセスグループ単位で管理する

  PID空間のサイズ:
  Linux: /proc/sys/kernel/pid_max (デフォルト 32768、最大 4194304)
  pid_maxが32768の場合、高負荷サーバーでは数分で
  PIDが一巡する可能性がある。

8.3 エッジケース3: fork() 中のシグナル配送

fork()システムコールの実行中にシグナルが配送されると、予期しない動作を引き起こすことがある。特に、SIGCHLDハンドラ内でfork()を呼ぶと再帰的なfork()が発生し、リソースを急速に消費する可能性がある。

安全なシグナルハンドリングの原則:

  1. シグナルハンドラ内では async-signal-safe 関数のみ使用する
     ・安全: write(), _exit(), signal(), kill(), open(), close()
     ・危険: printf(), malloc(), free(), fork(), pthread_mutex_lock()

  2. シグナルハンドラは最小限の処理にとどめる
     ・フラグを立てるだけにして、実際の処理はメインループで行う

  volatile sig_atomic_t got_sigchld = 0;

  void handler(int sig) {
      (void)sig;
      got_sigchld = 1;  /* フラグを立てるだけ */
  }

  /* メインループ */
  while (running) {
      if (got_sigchld) {
          got_sigchld = 0;
          while (waitpid(-1, NULL, WNOHANG) > 0)
              ;  /* ここで安全に回収処理を行う */
      }
      /* ... 通常の処理 ... */
  }

9. 実践演習

演習1: [基礎] プロセスの観察と情報取得

目的: 実行中のプロセスの情報をOSのツールを用いて収集する。

# === 演習1-A: プロセスツリーの確認 ===
# 現在のシステムのプロセスツリーを表示する。
# なぜツリー表示が有用か? → プロセスの親子関係と、
# どのサービスからどのプロセスが派生したかが一目でわかる。
 
# Linux:
ps auxf                      # ツリー形式で全プロセスを表示
pstree -p                    # PID付きのプロセスツリー
pstree -p $(pgrep sshd | head -1)  # sshd以下のツリーのみ
 
# macOS:
ps aux                       # 全プロセスを表示
pstree                       # Homebrew: brew install pstree
 
# === 演習1-B: /proc からプロセス情報を読む(Linux専用) ===
# /proc は仮想ファイルシステムであり、カーネルがプロセス情報を
# ファイルのように公開している。これにより、特別なAPIを使わず
# 通常のファイル操作でプロセス情報にアクセスできる。
 
cat /proc/self/status       # 自分自身(catプロセス)の情報
cat /proc/self/maps         # メモリマッピング情報
ls -la /proc/self/fd        # 開いているファイルディスクリプタ
cat /proc/self/cmdline | tr '\0' ' '  # コマンドライン引数
cat /proc/self/environ | tr '\0' '\n' | head -20  # 環境変数
 
# 演習課題:
# 1. 自分のシェルプロセスのPIDを確認せよ(echo $$)
# 2. そのPIDの /proc/[PID]/status からプロセス状態を確認せよ
# 3. /proc/[PID]/fd で開いているファイルディスクリプタの数を数えよ
# 4. 新しいファイルを open してから再度 fd の数を確認し、増えたことを確かめよ
 
# === 演習1-C: プロセスの統計情報 ===
# top/htopでリアルタイムのプロセス情報を確認する
top -bn1 | head -20          # バッチモードで1回だけ表示
# htop                       # 対話的なプロセスモニター(要インストール)
 
# プロセスのリソース使用量
/usr/bin/time -v ls /tmp 2>&1  # Linux: 詳細なリソース使用量

演習2: [応用] fork爆弾の理解とリソース制限

目的: fork爆弾の動作原理を理解し、OSがプロセスの暴走をどのように防ぐかを学ぶ。

# ⚠️ fork爆弾を絶対に実行しないこと。理論的な理解のみ行う。
 
# Bash の fork爆弾:
# :(){ :|:& };:
#
# 展開すると:
# bomb() {
#   bomb | bomb &   # 自分自身を2回呼び出し、バックグラウンドで実行
# }
# bomb              # 最初の呼び出し
#
# なぜ危険か:
# 1回目: 1プロセス → 2プロセス
# 2回目: 2プロセス → 4プロセス
# 3回目: 4プロセス → 8プロセス
# ...
# n回目: 2^n プロセス
# 15回の呼び出しで 32,768プロセス → pid_max に到達
# メモリとCPUが枯渇し、システムが応答不能になる
 
# === 対策1: ulimit でプロセス数を制限 ===
ulimit -u          # 現在のプロセス数上限を確認
ulimit -u 100      # プロセス数上限を100に制限
# この設定は現在のシェルとその子プロセスにのみ適用される
 
# === 対策2: /etc/security/limits.conf で恒久的に制限 ===
# student  hard  nproc  200    # studentユーザーは最大200プロセス
# @users   hard  nproc  500    # usersグループは最大500プロセス
 
# === 対策3: cgroups で制限(systemd環境) ===
# systemd は自動的に各ユーザーセッションにcgroupを割り当てる
# systemctl status user-1000.slice  # UID=1000のユーザーのcgroup
 
# === 対策4: systemd の TasksMax ===
# サービスファイルで制限:
# [Service]
# TasksMax=512    # このサービスのプロセス/スレッド数上限
 
# 演習課題:
# 1. ulimit -u で現在のプロセス数上限を確認せよ
# 2. 上限を50に設定した状態で、多数のバックグラウンドプロセスを
#    生成するスクリプトを書き、上限に達した時の挙動を確認せよ
# 3. cgroups v2 の pids.max ファイルの場所を特定し、値を確認せよ

演習3: [発展] マルチプロセスパイプラインの実装

目的: シェルが行っているパイプラインの処理を自分で実装し、fork/exec/pipe/dup2の連携を深く理解する。

/* pipeline_exercise.c
 * 演習課題: 以下の骨格コードを完成させ、
 * 3段パイプライン「cat /etc/passwd | grep root | wc -l」
 * を実装せよ。
 *
 * ヒント:
 * - N段パイプラインには N-1 本のパイプが必要
 * - 各子プロセスで適切なfdをdup2で付け替え、
 *   不要なfdを全て閉じること
 * - 親プロセスは全てのパイプfdを閉じ、
 *   全ての子プロセスをwait()すること
 *
 * コンパイル: gcc -Wall -o pipeline_exercise pipeline_exercise.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main(void)
{
    /* 2本のパイプを作成 */
    int pipe1[2];  /* cat → grep */
    int pipe2[2];  /* grep → wc */
 
    if (pipe(pipe1) < 0 || pipe(pipe2) < 0) {
        perror("pipe");
        return 1;
    }
 
    /* === プロセス1: cat /etc/passwd === */
    pid_t pid1 = fork();
    if (pid1 == 0) {
        /* TODO: 以下を実装せよ
         * 1. stdout を pipe1[1] に付け替える
         * 2. 不要なfd(pipe1[0], pipe2[0], pipe2[1])を閉じる
         * 3. execlp("cat", "cat", "/etc/passwd", NULL) を実行
         */
 
        /* --- ここにコードを書く --- */
        dup2(pipe1[1], STDOUT_FILENO);
        close(pipe1[0]);
        close(pipe1[1]);
        close(pipe2[0]);
        close(pipe2[1]);
        execlp("cat", "cat", "/etc/passwd", NULL);
        perror("execlp cat");
        _exit(1);
    }
 
    /* === プロセス2: grep root === */
    pid_t pid2 = fork();
    if (pid2 == 0) {
        /* TODO: 以下を実装せよ
         * 1. stdin を pipe1[0] に付け替える
         * 2. stdout を pipe2[1] に付け替える
         * 3. 不要なfd(pipe1[1], pipe2[0])を閉じる
         * 4. execlp("grep", "grep", "root", NULL) を実行
         */
 
        /* --- ここにコードを書く --- */
        dup2(pipe1[0], STDIN_FILENO);
        dup2(pipe2[1], STDOUT_FILENO);
        close(pipe1[0]);
        close(pipe1[1]);
        close(pipe2[0]);
        close(pipe2[1]);
        execlp("grep", "grep", "root", NULL);
        perror("execlp grep");
        _exit(1);
    }
 
    /* === プロセス3: wc -l === */
    pid_t pid3 = fork();
    if (pid3 == 0) {
        /* TODO: 以下を実装せよ
         * 1. stdin を pipe2[0] に付け替える
         * 2. 不要なfd(pipe1[0], pipe1[1], pipe2[1])を閉じる
         * 3. execlp("wc", "wc", "-l", NULL) を実行
         */
 
        /* --- ここにコードを書く --- */
        dup2(pipe2[0], STDIN_FILENO);
        close(pipe1[0]);
        close(pipe1[1]);
        close(pipe2[0]);
        close(pipe2[1]);
        execlp("wc", "wc", "-l", NULL);
        perror("execlp wc");
        _exit(1);
    }
 
    /* === 親プロセス: 全fdを閉じて子を待つ === */
    close(pipe1[0]);
    close(pipe1[1]);
    close(pipe2[0]);
    close(pipe2[1]);
 
    waitpid(pid1, NULL, 0);
    waitpid(pid2, NULL, 0);
    waitpid(pid3, NULL, 0);
 
    printf("パイプライン完了\n");
    return 0;
}

発展課題: 上記を一般化し、任意の数のコマンドをパイプで接続するプログラムを実装せよ。コマンドを文字列配列で受け取り、動的にパイプを作成する関数 execute_pipeline(char *commands[], int n) を設計すること。


10. プロセス管理の現代的な発展

10.1 コンテナとプロセス名前空間

Dockerなどのコンテナ技術は、Linuxのプロセス名前空間(PID namespace)を活用している。プロセス名前空間を分離することで、コンテナ内のプロセスはコンテナ外のプロセスを認識できなくなり、あたかも独立したシステムで動作しているかのように見える。

PID 名前空間の階層:

  ホストの PID 名前空間:
systemd (PID=1)
├── dockerd (PID=500)
└── containerd-shim (PID=600)
┌───┴──────────────────────────────┐
コンテナの PID 名前空間
ホストから見た PID コンテナ内PID
PID=601 → PID=1 (init)
PID=602 → PID=2 (app)
PID=603 → PID=3 (worker)
コンテナ内のPID 1は
ホストのPID 1(systemd)とは無関係
└──────────────────────────────────┘
└── containerd-shim (PID=700)
┌───┴──────────────────────────────┐
別のコンテナの PID 名前空間
PID=701 → PID=1
PID=702 → PID=2
└──────────────────────────────────┘
コンテナはプロセスの隔離技術であり、VMとは異なり
  カーネルを共有している。そのため、ホストの /proc を
  適切にマウントすればホストのプロセスも見えてしまう。
  セキュリティの境界としてはVMの方が強固である。

10.2 cgroups(Control Groups)によるリソース制限

cgroupsはプロセスグループ単位でリソース(CPU、メモリ、I/O帯域等)を制限する仕組みである。systemdはサービスごとにcgroupを自動作成し、リソース管理を行っている。

cgroups v2 の階層構造:

  /sys/fs/cgroup/
  ├── cgroup.controllers    # 利用可能なコントローラ一覧
  ├── system.slice/         # systemdが管理するサービス
  │   ├── nginx.service/
  │   │   ├── cgroup.procs  # このcgroupに属するPIDのリスト
  │   │   ├── cpu.max       # CPU使用率の上限
  │   │   ├── memory.max    # メモリ使用量の上限
  │   │   └── pids.max      # プロセス数の上限
  │   └── sshd.service/
  │       ├── cgroup.procs
  │       └── ...
  └── user.slice/           # ユーザーセッション
      └── user-1000.slice/
          ├── cgroup.procs
          └── ...

  設定例:
  # nginx のCPU使用率を2コア分に制限
  echo "200000 100000" > /sys/fs/cgroup/system.slice/nginx.service/cpu.max
  # → 100ms周期中200ms使用可能 = 2コア分

  # メモリ上限を512MBに設定
  echo 536870912 > /sys/fs/cgroup/system.slice/nginx.service/memory.max

FAQ

Q1: プロセスとスレッドの違いは?

Q2: なぜfork()してからexec()するのか?直接新プロセスを起動ではダメか?

fork+execの分離により、子プロセスの環境を親が事前に設定できるという柔軟性が生まれる。具体的には以下の操作をfork後exec前に行える。

  • ファイルディスクリプタの付け替え: dup2(fd, STDOUT_FILENO) でstdoutをファイルにリダイレクト。シェルの > リダイレクションはこの仕組みで実装されている。
  • パイプの接続: 2つの子プロセスのstdin/stdoutをパイプで接続。シェルの | はこの仕組み。
  • 環境変数の設定: setenv() で子プロセスの環境を変更。
  • 権限の変更: setuid() / setgid() で権限を降格。
  • リソース制限: setrlimit() で子プロセスのリソース上限を設定。
  • シグナルマスクの設定: 特定のシグナルをブロック/解除。

WindowsのCreateProcess()は一体化されており、上記のような柔軟な設定が難しい(CreateProcessの引数で一部は可能だが、拡張性に限界がある)。Linuxのposix_spawn()はfork+execを内部的に呼ぶラッパーであり、上記の設定をアトリビュートとして渡すことができる。

Q3: コンテキストスイッチはなぜ遅いのか?

レジスタの保存・復元だけなら数十ナノ秒で完了する。しかし、コンテキストスイッチが「遅い」と言われる主な原因は以下の間接的なコストにある。

  1. TLBフラッシュ: プロセスが切り替わるとページテーブルが変わるため、TLB(Translation Lookaside Buffer)の内容が無効化される。TLBミスが多発すると、アドレス変換のためにページテーブルをメモリから読み直す必要があり、数十〜数百サイクルのペナルティが発生する。ASIDやPCID(Process-Context Identifiers)を持つ最新のCPUでは、TLBのフラッシュを最小限に抑えられる。

  2. キャッシュの汚染: L1/L2/L3キャッシュに新しいプロセスのデータが読み込まれるまで、キャッシュミスが多発する(コールドキャッシュ問題)。ワーキングセットが大きいプロセスほど影響が大きく、キャッシュのウォームアップには想定で数十マイクロ秒かかる場合がある。

  3. パイプラインのフラッシュ: CPUの命令パイプラインがクリアされ、パイプラインが再充填されるまで数サイクルのバブルが発生する。

Q4: ゾンビプロセスはなぜ存在するのか?害はあるのか?

ゾンビプロセスが存在する理由は、子プロセスの終了ステータスを親プロセスに確実に伝達するためである。Unixの設計では、子の終了コード(正常終了か、どのシグナルで死んだか等)は親がwait()で回収するまで保持される必要がある。もしOS子プロセスの終了と同時にすべての情報を破棄すると、親が子の終了状態を確認する手段がなくなる。

ゾンビプロセス自体はCPU時間もメモリもほとんど消費しない(task_structの最小限の情報とPIDエントリのみ)。しかし、PIDは有限リソースであり、大量のゾンビが蓄積すると新しいプロセスを生成できなくなる。また、/proc エントリも残り続けるため、管理上の混乱を招く。

Q5: nice値とは何か?プロセスの優先度をどう制御するか?

nice値はプロセスのCPU優先度を調整するための値で、-20(最高優先度)から+19(最低優先度)の範囲を取る。デフォルトは0である。「nice」という名前は、「他のプロセスに対して nice(親切)にする=CPUを譲る」という意味に由来する。

# nice値の確認と変更
nice                          # 現在のnice値を表示
nice -n 10 ./heavy_task       # nice値10(低優先度)でプログラムを起動
renice -n -5 -p 1234          # PID 1234のnice値を-5に変更(root権限が必要)
 
# Linux CFS における nice値の効果:
# nice 0 のプロセスの重み: 1024
# nice 1 のプロセスの重み: 820  (約1.25倍遅くなる)
# nice -1 のプロセスの重み: 1277 (約1.25倍速くなる)
# nice値が1つ変わると、相対的なCPU時間が約10%変化する

Q6: プロセスが使用しているメモリ量をどう調べるか?

# 方法1: /proc/[PID]/status(Linux)
grep -E "^(VmSize|VmRSS|VmSwap)" /proc/self/status
# VmSize: 仮想メモリサイズ(マップされた全領域の合計)
# VmRSS:  実際に物理メモリ上にあるサイズ(Resident Set Size)
# VmSwap: スワップアウトされたサイズ
 
# 方法2: ps コマンド
ps -o pid,vsz,rss,comm -p 1234
# VSZ: 仮想メモリサイズ(KB)
# RSS: 物理メモリ使用量(KB)
 
# 方法3: /proc/[PID]/smaps_rollup(Linux 4.14+)
# プロセスの詳細なメモリ統計を確認できる
cat /proc/self/smaps_rollup
 
# 注意: RSSは共有ライブラリのページも含むため、
# プロセス固有のメモリ使用量を正確に知るには PSS
# (Proportional Set Size) を確認する。
# PSSは共有ページをそれを共有するプロセス数で割った値。

まとめ

概念 ポイント
プロセス 実行中のプログラム。PID、独立した仮想アドレス空間、PCBを持つ
メモリレイアウト テキスト→データ→BSS→ヒープ(↑)→...→スタック(↓)
5状態モデル New→Ready→Running→Blocked→Terminated。Blocked→Runningの直接遷移はない
PCB OSがプロセスを管理するための中核データ構造。コンテキストスイッチで保存/復元
fork/exec Unix流のプロセス生成。分離設計により柔軟な環境設定が可能
Copy-on-Write fork()のコストを劇的に削減。書き込み時にのみページをコピー
コンテキストスイッチ CPU状態の保存/復元。TLBフラッシュとキャッシュ汚染が主なコスト
スケジューリング FCFS、SJF、RR、MLFQ。Linuxは赤黒木ベースのCFSを採用
IPC パイプ、共有メモリ、シグナル、ソケット。用途に応じて選択
ゾンビ/孤児 親のwait()不足でゾンビ発生。孤児はinitが養子にする
名前空間/cgroups コンテナ技術の基盤。プロセスの隔離とリソース制限

次に読むべきガイド


参考文献

  1. Silberschatz, A., Galvin, P.B., Gagne, G. "Operating System Concepts." 10th Edition, Chapter 3-5, Wiley, 2018. -- プロセスの概念、スケジューリング、プロセス間通信の教科書的解説。通称「恐竜本」。

  2. Kerrisk, M. "The Linux Programming Interface: A Linux and UNIX System Programming Handbook." No Starch Press, 2010. -- Linuxシステムプログラミングの決定版。fork/exec、パイプ、シグナル、共有メモリの詳細な解説と豊富なコード例。特に Chapter 24-28(プロセス生成)、Chapter 44(パイプ)、Chapter 48-54(共有メモリ、セマフォ)が本章の内容に直接関連する。

  3. Love, R. "Linux Kernel Development." 3rd Edition, Addison-Wesley, 2010. -- Linuxカーネルのプロセス管理とCFSスケジューラの内部実装を解説。task_structの構造、プロセス生成のカーネル内部処理、CFSの赤黒木実装に関する詳細な記述がある。

  4. Tanenbaum, A.S., Bos, H. "Modern Operating Systems." 4th Edition, Pearson, 2014. -- OS理論の名著。プロセスの状態遷移モデル、スケジューリングアルゴリズムの数学的分析、IPCメカニズムの比較が充実している。

  5. Stevens, W.R., Rago, S.A. "Advanced Programming in the UNIX Environment." 3rd Edition, Addison-Wesley, 2013. -- 通称「APUE」。Unix/Linuxプログラミングの実践的なリファレンス。fork/exec、パイプ、シグナル処理の実装パターンが詳細にカバーされている。

  6. Linux man pages: fork(2), exec(3), wait(2), pipe(2), signal(7), proc(5), cgroups(7). -- 公式のシステムコールドキュメント。各システムコールの正確な仕様、エラーコード、注意事項が記載されている。man 2 fork 等で参照可能。