📖

OSの仕組みとシステムコール入門 - 第2回 プロセスの生成と管理

に公開

はじめに

本記事では、OSの仕組みとシステムコール入門として、プロセスの生成・管理・終了について詳しく学びます。
10回に渡ってOSについて学ぶ講座の第2回目です。

⚠️本稿は参考資料としてお読みください。

本講座の構成(全 10 回)

  1. OSとカーネルの基礎知識
  2. プロセスの生成と管理 ←今回
  3. ファイルシステムの基本
  4. システムコールの仕組み
  5. open, read, write の実例と挙動
  6. ネットワークソケット基礎
  7. strace/ltrace を使ったシステムコール解析
  8. マルチプロセス・マルチスレッドの考え方
  9. 実践演習
  10. まとめ

プロセスとは

プロセスは「実行中プログラム+実行文脈(コンテキスト)」です。
プログラムが静的なファイルであるのに対し、プロセスは動的な実行状態を含む概念です。

プロセスの主要構成要素

  • 仮想アドレス空間(テキスト/データ/ヒープ/スタック/共有メモリ 等)
  • レジスタ状態(PC, SP, 汎用レジスタ…)
  • オープンファイル記述子(FD)テーブル
  • シグナルハンドラ/マスク
  • 各種資源へのハンドル(ソケット、パイプ、cgroups の割当 など)
  • プロセスID(PID)と親プロセスID(PPID)
  • 実効ユーザーID/グループID

Linuxカーネル内では task_struct 構造体で表現されます(実際にはスレッドも含むタスクです。)。

プロセス生成API

fork()

  • 特徴: 親プロセスをほぼ丸ごと複製。実体コピーはせずCopy-on-Write(COW)により遅延コピーをする。
  • 戻り値: 親には「子のPID」を、子には「0」を返す。
  • 用途: 古典的Unix流の基本的なプロセス生成。
  • 注意点: マルチスレッドプログラムでは子プロセスで安全に呼べる関数が制限される。

vfork()

  • 特徴: 親のアドレス空間を共有し、子がexec()_exit()するまで親をブロック。
  • 用途: 歴史的な高速化策。
  • 注意: 誤用すると未定義動作を引き起こすため、近年は推奨されないケースが多い。
  • 推奨: 現在ではposix_spawn()の使用を検討すべきである。

clone()

  • 特徴: Linux固有。共有する資源をフラグで細かく制御できる低レベルAPI。
  • 用途: pthread_create()の内部実装や、コンテナ技術(PID/UTS/Mount namespaceなど)
  • 柔軟性: CLONE_VM, CLONE_FILES, CLONE_SIGHAND等のフラグで共有範囲を制御、

posix_spawn()

  • 特徴: POSIX標準API。fork()+exec()の安全・高速ラッパー。
  • 利点: 巨大プロセスで fork()のコストが高い場合に有効。
  • 用途: 組込みシステムや、メモリ使用量を抑えたいアプリケーション。

比較表

API 規格 特徴 典型用途 推奨度
fork POSIX COWで複製、シンプル 古典的Unix流。パイプライン等 一般的
vfork POSIX 親VM共有・親ブロック(レガシー) 速度最優先の歴史的最適化 非推奨
clone Linux 共有資源を細かく制御(スレッド/名前空間) スレッド生成・コンテナ基盤 特殊用途
posix_spawn POSIX fork+execの高級ラッパー 組込み・巨大プロセスからの子起動 推奨

execve():アドレス空間の置き換え

execve(path, argv, envp) は PIDを変えずに実行イメージを差し替えるシステムコールです。

動作の詳細

  • 既存のメモリ構造: コード/データ/スタックは破棄され、新しいプログラムのセグメントがマップされる。
  • プロセスID: 変更されない(同じプロセスが異なるプログラムを実行)。
  • ファイル記述子: close-on-execフラグが設定されていないものは継承される。
  • シグナル: ハンドラはデフォルトに戻る。
  • 戻り値: 成功時は戻り値はなし。失敗時は-1を返し、元のプロセスイメージに戻る。

exec*() ファミリ

  • execl, execv, execvp, execvpe等は引数・環境変数の渡し方違いのラッパ
  • 内部的には最終的にexecve()システムコールが呼ばれる

プロセス終了と待機

終了システムコール

exit() vs _exit()

  • exit(): ユーザ空間の後始末(atexit()ハンドラ、stdioバッファflush等)を行った後_exit()を呼ぶ
  • _exit(): 即座にカーネルへ戻り終了。fork()直後の子で安全に即終了したいときはこちら

終了ステータス

  • 0: 正常終了
  • 1-255: エラー終了(慣習的に1は汎用エラー、127は「コマンドが見つからない」)

子プロセスの回収

wait()/waitpid()/waitid()

  • 目的: 親が子プロセスの終了ステータスを回収(reap)する。
  • wait(): 任意の子プロセスの終了を待つ。
  • waitpid(pid, &status, options): 特定のプロセスを指定可能。
    • WNOHANG でノンブロッキング。
    • waitpid(-1, ...) で任意の子を回収可。
  • waitid(): より柔軟な待機オプション。

ステータス解析マクロ

  • WIFEXITED(status): 正常終了かチェック。
  • WEXITSTATUS(status): 終了コード取得。
  • WIFSIGNALED(status): シグナルによる終了かチェック。
  • WTERMSIG(status): 終了シグナル取得。

ゾンビと孤児プロセス

ゾンビプロセス

  • 状態: 子が終了済みだが親がwaitしておらず、PIDテーブルに最小情報だけ残っている状態。
  • 識別: psのSTATにZと表示。
  • 問題: 大量発生するとPIDテーブルを枯渇させる可能性。
  • 対策: 親プロセスで適切にwait()を呼ぶ。

孤児プロセス

  • 状態: 親が先に終了したプロセス。
  • 処理: PID 1(init/systemd)が引き取り、自動的にwait()してくれる。
  • 影響: 通常は問題なし。

プロセス状態とps表示

Linuxが扱う代表的な状態とps表示例を下記に示します。

ps STAT 意味 詳細
R 実行可能(Running/Runnable) CPU実行中またはrun queue待ち
S 割り込み可能スリープ I/O完了やイベント待ち
D 不可中断スリープ(I/O待ち等) 通常は短時間、長時間は異常
T 停止(ジョブ制御 SIGSTOP等) Ctrl+Z やデバッガなど
Z ゾンビ 終了済みだが親が回収していない

追加修飾子

  • +: フォアグラウンドプロセスグループ
  • <: 高優先度プロセス
  • N: 低優先度プロセス
  • s: セッションリーダー

スケジューラ(CFS等)や状態遷移の詳細は別回で扱います。

実践演習

環境構築

Dockerの準備・起動

mkdir -p proc_lesson/{src,logs}
cd proc_lesson

cat > Dockerfile <<'EOF'
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
    build-essential strace psmisc procps \
    git vim less gdb
WORKDIR /workspace
EOF

docker build -t proc_lesson .
docker run --rm -it --name proc_lab -v "$PWD":/workspace proc_lesson /bin/bash

実験1: fork→exec→waitの基本動作確認

// src/fork_exec.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void){
    printf("[main] Starting process demonstration (PID=%d)\n", getpid());
    
    pid_t pid = fork();
    if(pid == -1){
        perror("fork failed");
        exit(1);
    }
    
    if(pid == 0){
        // 子プロセス
        printf("[child] PID=%d, PPID=%d\n", getpid(), getppid());
        printf("[child] About to exec...\n");
        execlp("echo", "echo", "Hello-from-child", NULL);
        
        // execが失敗した場合のみここに到達
        perror("exec failed");
        _exit(127);
    } else {
        // 親プロセス
        printf("[parent] Created child PID=%d\n", pid);
        
        int status;
        pid_t waited_pid = waitpid(pid, &status, 0);
        
        printf("[parent] Child PID=%d terminated\n", waited_pid);
        if(WIFEXITED(status)){
            printf("[parent] Child exit status=%d\n", WEXITSTATUS(status));
        } else if(WIFSIGNALED(status)){
            printf("[parent] Child killed by signal %d\n", WTERMSIG(status));
        }
    }
    
    printf("[main] Process demonstration complete\n");
    return 0;
}

コンパイル・実行・解析

gcc -Wall -Wextra src/fork_exec.c -o src/fork_exec

# 基本実行
./src/fork_exec

# システムコール追跡
strace -f -o logs/fork_exec.strace ./src/fork_exec

# ログ解析(主要システムコールのみ抽出)
grep -E "(fork|execve|wait)" logs/fork_exec.strace

簡易まとめ

  • fork() 後、親子でPIDが異なること
  • 子が execve("/bin/echo", ...) を呼ぶタイミング
  • 親側で wait4()waitpid() の内側)が呼ばれていること
  • FDの継承状況

実験2: ゾンビプロセスの観察と対策

// src/zombie_demo.c
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

// シグナルハンドラを使った自動回収
volatile sig_atomic_t child_exited = 0;

void sigchld_handler(int sig) {
    (void)sig; // 未使用警告抑制
    child_exited = 1;
}

int main(void){
    printf("=== Zombie Process Demonstration ===\n");
    
    // SIGCHLD ハンドラを設定(オプション)
    signal(SIGCHLD, sigchld_handler);
    
    pid_t pid = fork();
    if(pid == 0){
        printf("[child] PID=%d, exiting immediately\n", getpid());
        sleep(1); // 少し待ってから終了
        _exit(42); // 終了コード42で終了
    }
    
    printf("[parent] PID=%d, child PID=%d created\n", getpid(), pid);
    printf("[parent] Check for zombie: ps -o pid,ppid,stat,cmd -p %d\n", pid);
    
    // ゾンビ状態を観察するため、まず待機せずに放置
    printf("[parent] Sleeping for 5 seconds (child becomes zombie)...\n");
    sleep(5);
    
    // 子プロセスの状態を回収
    int status;
    pid_t waited_pid = waitpid(pid, &status, WNOHANG);
    
    if(waited_pid > 0){
        printf("[parent] Reaped child PID=%d\n", waited_pid);
        if(WIFEXITED(status)){
            printf("[parent] Child exit status=%d\n", WEXITSTATUS(status));
        }
    } else if(waited_pid == 0){
        printf("[parent] Child still running (unexpected)\n");
    } else {
        perror("[parent] waitpid failed");
    }
    
    printf("[parent] Demonstration complete\n");
    return 0;
}

実行と解析

gcc -Wall -Wextra src/zombie_demo.c -o src/zombie_demo

# 実行(別ターミナルで状態観察)
./src/zombie_demo &

# プロセス状態の確認
ps -o pid,ppid,stat,cmd | grep -E "(zombie_demo|<defunct>)"

# より詳細な状態確認
ps aux | grep zombie_demo

実験3: 異なるプロセス生成手法の比較

// src/spawn_comparison.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <spawn.h>
#include <string.h>
#include <time.h>

extern char environ;

// fork+exec による実装
int fork_exec_example() {
    clock_t start = clock();
    
    pid_t pid = fork();
    if(pid == 0) {
        execlp("echo", "echo", "fork+exec works", NULL);
        _exit(127);
    } else if(pid > 0) {
        int status;
        waitpid(pid, &status, 0);
        
        clock_t end = clock();
        printf("fork+exec time: %f ms\n", 
               ((double)(end - start) / CLOCKS_PER_SEC) * 1000);
        return WEXITSTATUS(status);
    }
    return -1;
}

// posix_spawn による実装
int posix_spawn_example() {
    clock_t start = clock();
    
    pid_t pid;
    char *argv[] = {"echo", "posix_spawn works", NULL};
    
    int ret = posix_spawn(&pid, "/bin/echo", NULL, NULL, argv, environ);
    if(ret == 0) {
        int status;
        waitpid(pid, &status, 0);
        
        clock_t end = clock();
        printf("posix_spawn time: %f ms\n", 
               ((double)(end - start) / CLOCKS_PER_SEC) * 1000);
        return WEXITSTATUS(status);
    }
    return -1;
}

int main() {
    printf("=== Process Creation Method Comparison ===\n");
    
    printf("\n1. fork+exec method:\n");
    fork_exec_example();
    
    printf("\n2. posix_spawn method:\n");
    posix_spawn_example();
    
    return 0;
}

セキュリティとベストプラクティス

プロセス権限の管理

  • setuid(), setgid()による権限制御
  • 最小権限の原則(principle of least privilege)
  • ファイル記述子の適切な管理(close-on-execフラグ)

シグナル処理

  • exec()後のシグナルハンドラはデフォルトに戻る
  • 子プロセスでのシグナルマスク継承に注意
  • SIGCHLDの適切な処理

リソース管理

  • ファイル記述子リークの防止
  • メモリリークの回避
  • ゾンビプロセスの適切な回収

よくあるトラップ・Tips

マルチスレッド環境でのfork()

  • 問題: fork()直後にロックを保持しているとデッドロックの危険
  • 対策: pthread_atfork()で回避、またはposix_spawn()を検討

exec()後の環境継承

  • 注意: シグナルハンドラはデフォルトに戻る。必要なら再設定を忘れずに
  • 重要: close-on-execフラグの適切な設定

パイプライン処理

  • 典型的バグ: パイプライン(pipe()fork()dup2()exec())でFDクローズ漏れ
  • 対策: 不要なFDは明示的にクローズ

SIGCHLD処理

  • 注意: SIGCHLDSIG_IGNすると自動でreapされるが、終了コードを取得できなくなる
  • 推奨: 明示的なwait()呼び出し

5. エラーハンドリング

  • システムコールの戻り値を必ずチェック
  • errnoの適切な処理
  • 子プロセスでは _exit() を使用(exit() ではない)

その他

システムコールの追跡

# Linux環境での詳細追跡
strace -f -e trace=process ./your_program

# macOS環境ではdtrace/dtrussを使用
sudo dtruss -f -n your_program

プロセス状態の監視

# プロセス状態の継続監視
watch 'ps -o pid,ppid,stat,cmd --forest'

# システム全体のプロセス情報
cat /proc/loadavg
cat /proc/stat

参考文献

Discussion