記事

CSロードマップ 第8回 — プロセスとスレッド:OSは実行単位をどう抽象化するか

CSロードマップ 第8回 — プロセスとスレッド:OSは実行単位をどう抽象化するか
前提知識 — 先にこちらをご確認ください
TL;DR — 要点まとめ
  • プロセスは「独立したアドレス空間 + 資源の束」で、スレッドは「プロセス内の実行フロー」である。スレッドはコード/ヒープ/グローバル変数を共有するが、スタックとレジスタは専用で持つ
  • Unixのfork()はプロセスを複製してexec()で上書きする2段階、WindowsのCreateProcess()は一度に新規作成する。複製は高価に見えるがCopy-on-Writeで実際には速い
  • スレッドモデルは1:1 (Linux NPTL、Windows)、N:1(グリーンスレッド)、M:N(Go goroutine、Erlang)に分かれ、性能と実装複雑度のトレードオフが異なる
  • コンテキストスイッチはレジスタ保存/復元だけでなく、TLBフラッシュとキャッシュ汚染まで引き起こすため、現代のゲームエンジンは「スレッド数を増やす」より「Job/TaskGraph/Fiberで作業を細かく分割してコアに分配する」方向に進んでいる
Visitors

Hits

はじめに:地図から本論へ

前回では3つのオペレーティングシステムの血統と骨格をざっと見ました。Linuxはモノリシック、Windows NTはハイブリッド、macOS XNUはMach + BSDの二重構造。これが地図だったとすれば、今回からは本論です。

ステージ2の核心的な問いをもう一度取り出します。

「2つのスレッドが同じ変数を使うと、なぜプログラムは時々だけ死ぬのか?」

この問いに答えるには、まず「スレッドとは何か」を正確に知る必要があります。そしてスレッドを理解するには、その上位概念であるプロセスを先に知る必要があります。プロセスとスレッドの違い、両者がメモリをどう共有しどう分離するか、そしてOSがそれをどう抽象化するか — これが並行性の全ての問題の出発点です。

今回扱う内容:

  • プロセス:PCBとアドレス空間レイアウト。Linuxのtask_struct、WindowsのEPROCESS、macOSのproc/task
  • プロセス生成:Unixのfork()+exec()2段階モデル、WindowsのCreateProcess()単一呼び出し、そしてCopy-on-Write
  • スレッド:なぜプロセスだけでは足りないのか、TCB、共有領域と専用領域、TLS
  • スレッドマッピングモデル:1:1、N:1、M:N — Goのgoroutineがなぜあれほど軽いのか
  • コンテキストスイッチ:レジスタ・TLB・キャッシュコストの実態
  • ゲームエンジンの実行モデル:Unityのメインスレッド、Unreal TaskGraph、Naughty DogのFiber

ゲーム開発の視点は保ちつつ、今回は理論的基礎が多めです。次回(スケジューリング)、その次(同期)がこの上に積み上がるためです。


Part 1:プロセス — OSが見る実行単位

プロセスとは何か

教科書的な定義から。プロセス (Process)実行中のプログラムです。ハードディスク上の.exeファイルやMach-Oバイナリはプログラムで、それがメモリにロードされてCPUで実行されているインスタンスがプロセスです。

プロセスが持つもの:

  1. 固有のアドレス空間 (Address Space) — 他のプロセスと隔離されたメモリ
  2. 実行状態 — CPUレジスタ値、プログラムカウンタ
  3. 開いているファイルのテーブル — 現在使用中のファイルディスクリプタ一覧
  4. 所有者情報 — UID、GIDなどの権限
  5. 子プロセス関係 — 誰が誰を作ったか(プロセスツリー)

OSはこれらの情報をすべて1つの構造体で管理します。これがPCB (Process Control Block)、またはプロセスディスクリプタです。

PCBの実体 — OS別の構造体

Linux — task_struct

Linuxカーネルでプロセス(およびスレッド)を表す構造体はstruct task_structです。include/linux/sched.hで定義されており、数百個のフィールドを持つ巨大な構造体です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Linuxカーネルのtask_structの一部 (kernel 6.x基準、極端に単純化) */
struct task_struct {
    /* 状態 */
    unsigned int           __state;          /* TASK_RUNNING など */

    /* 識別子 */
    pid_t                  pid;              /* プロセスID */
    pid_t                  tgid;             /* スレッドグループID */
    struct task_struct    *parent;           /* 親プロセス */
    struct list_head       children;         /* 子のリスト */

    /* メモリ */
    struct mm_struct      *mm;               /* アドレス空間 */

    /* ファイル */
    struct files_struct   *files;            /* 開いているファイルテーブル */

    /* スケジューリング */
    int                    prio;
    struct sched_entity    se;               /* CFSスケジューリングエンティティ */

    /* シグナル、リソース制限など数百フィールド... */
};

実際の構造体は700行を超えます。Linuxではプロセスとスレッドが同じ構造体で表現されます — これはLinuxの独特な設計で、後で再度扱います。

Windows — EPROCESSKPROCESS

Windows NTは2層に分かれます:

  • KPROCESS (Kernel Process Block) — スケジューリング関連の最小情報
  • EPROCESS (Executive Process Block) — KPROCESSをラップして追加情報を含む
1
2
3
4
5
6
7
8
9
/* 概念的な擬似コード — 実際のWindows内部はWinDbgやNTソース流出版を参照 */
typedef struct _EPROCESS {
    KPROCESS Pcb;                    /* カーネルプロセスブロック (継承) */
    HANDLE UniqueProcessId;          /* PID */
    LIST_ENTRY ActiveProcessLinks;   /* グローバルプロセスリスト */
    PVOID SectionBaseAddress;        /* イメージロードアドレス */
    PVOID Token;                     /* セキュリティトークン */
    /* ... */
} EPROCESS;

macOS — proc + task

macOSの二重構造がここにも現れます。BSDレイヤにはUnix伝統のstruct procがあり、Machレイヤにはstruct taskがあります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* BSD側 — bsd/sys/proc_internal.h */
struct proc {
    pid_t                  p_pid;           /* POSIXプロセスID */
    struct proc           *p_pptr;          /* 親 */
    struct task           *task;            /* Mach taskへのリンク */
    /* ... */
};

/* Mach側 — osfmk/kern/task.h */
struct task {
    queue_head_t           threads;         /* このtaskに属するスレッドたち */
    vm_map_t               map;             /* アドレス空間 */
    ipc_space_t            itk_space;       /* Mach port空間 */
    /* ... */
};

つまりmacOSでfork()でプロセスを作ると、BSDのprocとMachのtaskがペアで生成されます。Unixプログラム(pstop)はprocを見て、Machベースのツール(lldbInstruments)はtaskを見ます。

プロセスのアドレス空間レイアウト

プロセスが持つメモリはどう配置されているでしょうか?伝統的なUnix/Linuxプロセスの32ビットアドレス空間レイアウトを見ます。

プロセスのアドレス空間レイアウト(概念図) Kernel Space (ユーザープロセスから直接アクセス不可) 高位アドレス 0xFFFFFFFF Stack 関数呼び出しフレーム、ローカル変数 ↓ 下に伸びる 未使用領域 Stackが伸びるための空間 mmapされた共有ライブラリがここに配置 (libc、libdl、ヒープ拡張など) Heap malloc / newで割り当てられるメモリ ↑ 上に伸びる(brk / sbrk) BSS (Uninitialized Data) int x; (0で初期化) Data (Initialized) int x = 42; Read-only Data (.rodata) Text (Code) 実行可能な機械語 低位アドレス 0x00400000 保護 RW RW RW RW R RX

各領域を説明します(低位アドレスから):

  • Text (.text):実行可能な機械語。読み取り + 実行のみ許可。書き込みを試みるとセグメンテーションフォールト
  • Read-only Data (.rodata):文字列リテラル("Hello")、定数配列など。読み取り専用
  • Data (.data):初期化済みグローバル/静的変数(int x = 42;)。ファイルに初期値が含まれる
  • BSS (Block Started by Symbol):0で初期化されたグローバル変数(int x;static char buf[1024];)。ファイルにはサイズのみが記録され、実行時にOSが0で埋める — 実行ファイルのサイズを縮めるトリック
  • Heap:動的割り当て(mallocnew)。brk()システムコールで上に拡張
  • 共有ライブラリ領域 (mmap)libc.solibstdc++.soなどがmmap()でこの領域にマップされる
  • Stack:関数呼び出しフレーム、ローカル変数、戻りアドレス。下向きに伸びる
  • Kernel Space:カーネルコードとデータ。ユーザープロセスは直接アクセス不可。32ビットLinuxでは上位1GB、x86-64では上位半分

WindowsもPEで異なるセクション名を使いますが、構造はほぼ同じです(.text.data.rdata.bss)。

プロセスの状態遷移

プロセスは複数の状態を行き来します。Silberschatz教科書の標準モデル:

プロセス状態遷移 (Silberschatzモデル) New Ready Running Terminated Waiting admitted scheduler dispatch interrupt I/O or event wait I/O or event completion exit
  • New:プロセスが作られたばかり
  • Ready:実行可能だがCPUを待っている
  • Running:CPUで実際に実行中
  • Waiting(またはBlocked):I/O完了やイベントを待っている
  • Terminated:終了

実際のOSはさらに複雑な状態を持ちます。Linuxのtask_structにはTASK_RUNNINGTASK_INTERRUPTIBLETASK_UNINTERRUPTIBLETASK_STOPPEDTASK_TRACEDTASK_DEADTASK_WAKEKILLTASK_WAKINGTASK_PARKEDなどがあります。psで見えるSRDZといった文字がこれらです。

1
2
3
4
5
$ ps aux
USER  PID  %CPU %MEM  COMMAND
root   1   0.0  0.1   /sbin/init           <- S (sleeping)
www    1234 2.1  1.5   nginx: worker        <- R (running)
root   5678 0.0  0.0   [kworker/u8:2]       <- D (uninterruptible sleep)

D状態(uninterruptible sleep)はゲーム開発者にも重要です — ディスクI/Oやドライバ要求を待っている状態で、この状態ではkill -9すら効きません。「応答しないプロセス」のかなりの部分がD状態です。


Part 2:プロセス生成 — fork、exec、CreateProcess

次にプロセスをどう作るかを見ます。3つのOSの哲学の違いが最も鮮明に表れる点です。

Unix:fork() + exec() — 2段階モデル

Unixのアイデアは「親を複製してから上書きする」です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();   /* 1段階:自分を複製 */

    if (pid == 0) {
        /* 子プロセス */
        execl("/bin/ls", "ls", "-l", NULL);   /* 2段階:新プログラムで上書き */
        /* execlが成功すればここは実行されない */
    } else if (pid > 0) {
        /* 親プロセス */
        int status;
        waitpid(pid, &status, 0);             /* 子の終了を待機 */
    } else {
        perror("fork failed");
    }
    return 0;
}

fork()ひとつの呼び出しが2回リターンします。親には子のPIDを、子には0を返します。独特なAPIです。

fork()がやること(naiveな実装):

  1. 新しいPCB(task_struct)を作成
  2. 親のアドレス空間をすべてコピー(text、data、heap、stackすべて)
  3. 開いているファイルディスクリプタもコピー
  4. 子に新しいPIDを割り当て
  5. 子をreadyキューに入れる

問題は2番目です。プロセスのアドレス空間が数百MBのとき毎回コピーするのは莫大に高価です。しかもfork()直後にexec()を呼べばどうせアドレス空間を上書きするわけで、コピーしてすぐ捨てることになります。

Copy-on-Write — 「本当に書き込むときにコピー」

解決策はCopy-on-Write (COW)です。fork()時点ではページテーブルだけをコピーし、実際のメモリページは親と子が共有します。ただしページは読み取り専用にマークされます。

どちらかがページに書き込もうとするとハードウェアがpage faultを発生させ、OSはそこで初めてそのページだけコピーします。

fork() + Copy-on-Writeの実際の動作 1) fork()呼び出し直後 ページテーブル ページテーブル(コピー) 物理ページ(読み取り専用) ページテーブルだけコピー — 速い 実メモリは共有 2) 子がページへの書き込みを試行 ページテーブル 書き込み試行 ✍️ 物理ページ(読み取り専用) ⚡ Page Fault発生 CPU → OSに処理を要請 3) OSがページをコピー 原本(RW復元) コピー(子専用) 実際に書いたページだけコピー 残りは引き続き共有 結果:fork()は「コピー」ではなく「共有 + 遅延コピー」 • fork()自体はページテーブルのサイズ分だけの作業 — 数マイクロ秒 • 子がほとんどのページを書かずにすぐexec()を呼べばコピーコストはほぼ0 • ページ単位の粒度(通常4KBか16KB) — 1バイト書くとページ全体がコピーされる • Linuxはこれをタスク生成の基本とし、プロセス生成が非常に速い

COWはハードウェアサポートが必要です — CPUのMMU (Memory Management Unit) がページ単位の保護とpage faultを発生させてくれてはじめてOSが介入できます。そのためページ単位のMMUは現代OSのほぼすべてのトリック(COW、スワップ、mmap、共有メモリ)の基盤です。

Windows:CreateProcess() — 単一呼び出し

Windowsは別の道を選びました。親複製の概念がなく、新プロセスを最初から作ります

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>

int main() {
    STARTUPINFO si = { sizeof(si) };
    PROCESS_INFORMATION pi;

    BOOL ok = CreateProcess(
        "C:\\Windows\\System32\\notepad.exe",  /* 実行ファイル */
        NULL,                                   /* コマンドライン */
        NULL, NULL,                             /* プロセス/スレッドセキュリティ属性 */
        FALSE,                                  /* ハンドル継承有無 */
        0,                                      /* 生成フラグ */
        NULL, NULL,                             /* 環境変数、作業ディレクトリ */
        &si, &pi);

    if (ok) {
        WaitForSingleObject(pi.hProcess, INFINITE);
        CloseHandle(pi.hProcess);
        CloseHandle(pi.hThread);
    }
    return 0;
}

Unixのfork()は引数がないのに、CreateProcess()10個の引数を取ります。これは「プロセス生成時に設定可能なすべてのオプションを1つの関数に詰め込む」Windowsの哲学です。

トレードオフ

側面Unix fork()+exec()Windows CreateProcess()
API複雑度2段階だが各々シンプル1段階だが引数が多い
プロセス生成コストCOWで非常に安い相対的に高い
シェル実装自然(fork → リダイレクション設定 → exec)ShellExecuteのような別APIが必要
セキュリティ親のハンドルが自動継承(ミスの余地あり)明示的に継承指定
柔軟性fork後exec前に任意コードを実行できる生成時点だけで設定

macOS — Unix継承 + いくつかのひねり

macOSはBSD継承なので当然fork()exec()をサポートします。しかしXNUの内部実装は少し独特です。

BSDのfork()がMachにマッピングされるとき実際には:

  1. 現在のproc構造体を複製
  2. 現在のtaskをMachレベルで複製(task_create()
  3. 初期スレッドを1つ作成(thread_create()
  4. アドレス空間も複製(Machのvm_mapをCOWで複製)

つまり、BSDのfork()呼び出し1つがMachレイヤの複数の操作に分解されます。これがXNU二重構造の実際の姿です。

もう1つ興味深いのはmacOSのposix_spawn()です。POSIX標準ですがmacOSが積極的に推奨するAPIで、fork+execを一度に実行します。

1
posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, environ);

なぜこれを使えと言うのか?iOSのためです。iOSではfork()がセキュリティ上禁止されており、posix_spawn()のみ許可されます。また内部実装がより効率的な場合もあります(COWのページテーブル複製すら省略できる)。

ちょっと、これは押さえておこう

「iOSでfork()をなぜ禁止したのか?」

3つの理由が重なります。

  1. サンドボックス侵害リスク:fork()された子プロセスは親の権限を継承しますが、iOSの厳格なアプリサンドボックスモデルではこの境界を壊しかねない潜在的脆弱性になります
  2. Objective-Cランタイムの状態複製問題:iOSアプリはほとんどがObjective-CやSwiftで書かれ、これらの言語のランタイムは初期化時に多くの状態(スレッド、GCDキュー、IOKit接続など)を生成します。fork()以降これらの状態が整合性を失いがちです
  3. メモリ効率:iOSはメモリが限られており、COWでもページテーブル複製は必要。posix_spawn()はこれすら省略可能

macOSではfork()は依然として許可されていますが、Appleは「可能な限りposix_spawn()を使う」ことを推奨します。


Part 3:スレッド — なぜプロセスだけでは足りないのか

プロセスベース並行性の限界

1970〜80年代のUnixはプロセス1つ = 実行フロー1つでした。複数のことを同時にしたければfork()でプロセスを複数作りました。Webサーバであれば接続ごとにプロセスを1つずつ作る方式(古典的なApacheのpreforkモード)。

このモデルの問題:

  1. プロセス生成コスト:COWで安くなったとはいえ、ページテーブル複製、PCB割り当てなど、依然数マイクロ秒〜ミリ秒単位
  2. コンテキストスイッチコスト:プロセス間切り替え時にアドレス空間も変わるためTLBフラッシュが必要(後で詳述)
  3. プロセス間通信 (IPC) コスト:プロセス同士はアドレス空間が分離されているため、データをやり取りするにはパイプ、ソケット、共有メモリなど重いメカニズムが必要
  4. 共有状態の表現の難しさ:複数の実行フローが同じデータ構造を扱いたいとき複雑

1990年代に入って解決策が必要になり、それがスレッド (Thread)です。

スレッドの定義

スレッドとはプロセス内部の独立した実行フローです。1つのプロセスの中に複数のスレッドがあれば、全員が同じアドレス空間を共有しながら、それぞれがCPUで同時に実行されえます

スレッドが共有するもの:

  • Text(コード):当然同じコードを実行
  • Heapmallocで割り当てたメモリ
  • Data / BSS:グローバル変数、静的変数
  • 開いているファイルディスクリプタ
  • シグナルハンドラ

スレッドが別々に持つもの:

  • Stack(スタック):各スレッドごとに別
  • CPUレジスタ状態:PC、SP、汎用レジスタなど
  • TLS (Thread-Local Storage):スレッド別のグローバル変数
  • エラー状態errno(POSIXではスレッド別)
プロセス間 vs スレッド間のメモリ共有 複数プロセス — 完全分離 プロセスA Text(コード) Data / BSS Heap Stack(実行フロー1) Registers, PC File descriptors プロセスB Text(別) Data / BSS Heap Stack(実行フロー1) Registers, PC File descriptors IPC(パイプ/ソケット/共有メモリ)なしでは会話不可 1プロセス内の複数スレッド — ほぼ共有 プロセスC(スレッド3つ) Text(共有) Data / BSS(共有) Heap(共有) Stack T1 専用 Stack T2 専用 Stack T3 専用 Regs T1 Regs T2 Regs T3 File descriptors(共有) TLS T1 TLS T2 TLS T3 同じheap/dataをそのまま読み書きする — 競合条件の根源

この図で重要な点:

  1. スレッド間ではheapとグローバル変数がそのまま共有されます — 「共有メモリ」が自然に存在する
  2. つまりスレッド2つが同じint counterを同時にcounter++するとrace conditionが生じる
  3. 逆にプロセス2つはアドレス空間が分離されていて自然に隔離されている

ステージ2の核心的な問い — 「スレッド2つが同じ変数を使うとなぜプログラムが時々だけ死ぬのか?」 — の答えがこの図の中にあります。スレッドは意図的にメモリを共有するため並行性の問題が生じ、それを管理する同期技法が必要です。(次回Part 10同期プリミティブで本格的に扱います。)

TCB — スレッド制御ブロック

プロセスにPCBがあるように、スレッドにはTCB (Thread Control Block)があります。TCBが持つもの:

  • スレッドID
  • CPUレジスタ状態(保存されたコンテキスト)
  • スレッド状態(Running、Ready、Waiting)
  • スタックポインタ、スタックベース
  • スケジューリング情報(優先度など)
  • 所属プロセスへのポインタ

OS別の実装:

  • Linuxtask_struct — プロセスとスレッドを同じ構造体で表現。どのフィールドを共有するかで区別する
  • WindowsKTHREAD + ETHREAD
  • macOS:Machのstruct thread

Linuxの独特な哲学 — 「プロセスとスレッドは同じ」

Linus Torvaldsは1990年代に大胆な決定をしました。「プロセスとスレッドを別の概念にせず、1つの『実行単位』として統合しよう。」

Linuxではfork()の代わりに、より一般的なclone()システムコールがあります。clone()「親と何を共有するか」をビットフラグで指定します。

1
2
3
4
5
6
7
8
9
10
/* Linux clone() — 概念 */
clone(fn, stack, flags, arg);

/* フラグの例: */
CLONE_VM       /* アドレス空間を共有(trueならスレッド、falseならプロセス) */
CLONE_FS       /* ファイルシステム状態を共有 */
CLONE_FILES    /* ファイルディスクリプタを共有 */
CLONE_SIGHAND  /* シグナルハンドラを共有 */
CLONE_THREAD   /* 同じスレッドグループに所属 */
/* ... */
  • fork() = clone() with すべての共有フラグOFF
  • pthread_create() = clone() with すべての共有フラグON
  • その間の任意の組み合わせが可能

これがLinuxの「プロセスとスレッドは連続的」な見方です。実際、Androidのような環境では「一部だけ共有する」プロセス複製を有効に使います(Zygoteプロセス)。

TLS — Thread-Local Storage

スレッドごとにグローバルに見えるが実際にはスレッドごとに独立した変数が必要な場合があります。これがTLSです。

典型例:errno。POSIXでerrnoは「最後のシステムコールのエラーコード」ですが、スレッドごとに別でなければいけません(スレッドAがread()に失敗した結果をスレッドBが上書きしてはいけない)。だからerrnoはTLSとして実装されます。

言語別のTLS宣言:

1
2
3
4
5
/* C11 */
_Thread_local int counter = 0;

/* GCC/Clang拡張 */
__thread int counter = 0;
1
2
// C++11
thread_local int counter = 0;
1
2
3
4
5
6
// C#
[ThreadStatic]
static int counter;

// またはより柔軟なThreadLocal<T>
static ThreadLocal<int> counter = new ThreadLocal<int>(() => 0);

ゲーム開発での実用例:

  • ロギングシステムで各スレッドの名前をTLSに保存してログ行に含める
  • レンダリングでスレッド別のコマンドバッファを割り当てて後でマージ
  • プロファイリングで現在実行中のスコープスタックをスレッド別に管理

Part 4:スレッドモデル — 1:1、N:1、M:N

さらに深い問いです。あなたがpthread_create()new Thread()を呼ぶとき、OSカーネルはそのスレッドをどう管理するのでしょうか?

なぜこの問いが重要か

CPUで実際に実行可能な単位はカーネルスレッド (Kernel-level Thread, KLT)です。カーネルだけがCPUをスケジューリングできるためです。

一方プログラムが作る「スレッド」は単にユーザー空間の抽象化かもしれません。これをユーザースレッド (User-level Thread, ULT)と呼びます。

ユーザースレッドとカーネルスレッドのマッピング方式は3つに分かれます。

ユーザースレッド ↔ カーネルスレッドのマッピングモデル 1:1(一対一) Linux NPTL, Windows ULT 1 ULT 2 ULT 3 KLT 1 KLT 2 KLT 3 長所 • 実装がシンプル • 真の並列性(マルチコア) • カーネルスケジューラ活用 短所 • スレッド生成コストが高い • 数千個でカーネル資源枯渇 • コンテキストスイッチが重い N:1(多対一) 昔のグリーンスレッド、GNU Pth ULT 1 ULT 2 ULT 3 ULT 4 KLT 1個 長所 • スレッド生成が極めて安い • 数十万個が可能 • ユーザースケジューラの自由 短所 • 並列性なし(コア1個のみ) • ブロッキングsyscall = 全停止 • 現在はほぼ使われない M:N(多対多) Go、Erlang、昔のSolaris U1 U2 U3 U4 U5 KLT 1 KLT 2 KLT 3 長所 • スレッド安い + 並列性 • 両者の長所を結合 • 数百万goroutineが可能 短所 • ランタイム実装が複雑 • スケジューリング公平性課題 • デバッグが難しい

1:1モデル — 現代のLinux/Windowsの選択

1:1モデルではユーザーが作ったスレッド1つがそのままカーネルスレッド1つです。pthread_create()が内部的にclone()システムコールを呼び出してカーネルが管理するタスクを直接作ります。

Linux NPTL (Native POSIX Thread Library): Linux 2.6からglibcのpthread実装はNPTLを使い、NPTLは1:1モデルです。以前はLinuxThreadsという非標準1:1実装がありましたが、NPTLがPOSIX準拠 + 性能で置き換えました。

WindowsCreateThread()はカーネルのKTHREADを直接作ります。やはり1:1。

長所:スレッドがブロックされても他のスレッドは動き続ける。マルチコアで自動分散。

短所:スレッド生成コストが比較的高く、数万〜数十万個になるとカーネルメモリが圧迫される。

N:1モデル — 過去の遺産

N:1モデルでは複数のユーザースレッドがカーネルスレッド1つにマップされます。カーネルは「このプロセスにスレッドが複数ある」ことを知りません — プロセスは1つにしか見えません。

このモデルはJavaの初期「グリーンスレッド」、GNU Pthなどのライブラリで使われました。1990年代初頭には標準でしたが、致命的な短所のためにほぼ消えました:

  • ブロッキングシステムコールが全体を止める:ユーザースレッド1つがread()でブロックすると、同じカーネルスレッドを共有するすべてのユーザースレッドが止まる
  • マルチコアが使えない:カーネルスレッド1つはCPUコア1つにしか割り当てられない

M:Nモデル — Goの選択

M:Nモデルは2つのモデルの長所を合わせます。M個のユーザースレッドがN個のカーネルスレッドプールに動的にマップされます(通常N = CPUコア数)。

代表的な実装

  • Go goroutine:Goランタイムが M:Nスケジューラ。数百万goroutineを数個のOSスレッドで動かす
  • Erlang/Elixir:BEAM VMが独自のスケジューラを実装
  • 昔のSolaris(Solaris 2〜8):標準POSIX pthreadsをM:Nで実装したが、複雑性のためSolaris 9で1:1に転換

理論的背景 — Andersonらの1991年SOSP論文 Scheduler Activations:「ユーザーレベルスレッドライブラリがカーネルと協力してM:Nを効率的に実装するには、どんなカーネルサポートが必要か」を扱いました。核心はブロッキングシステムコール時にカーネルがユーザースケジューラを起こし、別のユーザースレッドを別のカーネルスレッドに割り当てさせるべきということ。

Goランタイムはこれに似たアイデアを実装します。goroutineがblocking syscallを呼ぼうとするとランタイムがそれを検知してそのgoroutineを別のカーネルスレッドに移植するか、新しいカーネルスレッドを作ります。だからnet.Listenがブロックしても他のgoroutineは影響を受けません。

ゲーム開発の立場から

Unity、Unrealが使うスレッドはC++/C#レベルでは1:1モデルです。new Thread()std::threadがカーネルスレッドを直接作ります。

ただしエンジン内部のJobシステムやTaskグラフは事実上M:Nスケジューラです。プログラマが数千個の「Job」を発行しても実際にはエンジンが作った数個のワーカースレッドで動きます。これはPart 13 Lock-freeと構造的解決で詳しく扱うUnity Job System設計と直結します。


Part 5:3-OSスレッドAPI比較

Linux — pthreads

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <pthread.h>
#include <stdio.h>

void* worker(void* arg) {
    int id = *(int*)arg;
    printf("Thread %d running\n", id);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    int id1 = 1, id2 = 2;

    pthread_create(&t1, NULL, worker, &id1);
    pthread_create(&t2, NULL, worker, &id2);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

POSIX標準API。内部的にclone()システムコールを呼び出す。公式な名前は「pthread」ですが、Linuxのmanページを見ると実際にはNPTL(glibc実装)のドキュメントです。

Windows — CreateThread / _beginthreadex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>
#include <process.h>

unsigned __stdcall worker(void* arg) {
    int id = *(int*)arg;
    printf("Thread %d running\n", id);
    return 0;
}

int main() {
    HANDLE t1, t2;
    int id1 = 1, id2 = 2;

    t1 = (HANDLE)_beginthreadex(NULL, 0, worker, &id1, 0, NULL);
    t2 = (HANDLE)_beginthreadex(NULL, 0, worker, &id2, 0, NULL);

    WaitForSingleObject(t1, INFINITE);
    WaitForSingleObject(t2, INFINITE);
    CloseHandle(t1);
    CloseHandle(t2);
    return 0;
}

なぜCreateThreadではなく_beginthreadexなのか? CreateThreadはCRT (C Runtime Library) の初期化状態をスキップします — errnostrtokのようなスレッド別の状態が初期化されず問題が起きます。_beginthreadexはCRTと一緒に正しく初期化されるので、C/C++コードではこちらを使うべきです。

macOS — pthreads + libdispatch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* POSIX方式 — Linuxと同じ */
#include <pthread.h>
/* ... */

/* libdispatch (GCD) 方式 — macOS推奨 */
#include <dispatch/dispatch.h>

int main() {
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
        printf("Running in background\n");
        dispatch_async(dispatch_get_main_queue(), ^{
            printf("Back to main thread\n");
        });
    });

    dispatch_main();
    return 0;
}

macOSでもpthreadsはサポートされますが、AppleはGCD (Grand Central Dispatch)を推奨します。理由はPart 7で扱いました — スレッド寿命を手動管理しなくてよい、QoSクラスでP/Eコアを自動活用、予測可能なキュー抽象化など。

C# — 言語レベルの抽象化

C#は上記3つのOSすべてで動きます。.NETランタイム(CLRまたはCoreCLR)がOSの違いを隠してくれます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.Threading;
using System.Threading.Tasks;

// 1) 最も原始的な方法 — ほぼ使われない
Thread t = new Thread(() => Console.WriteLine("Hello"));
t.Start();
t.Join();

// 2) ThreadPool — スレッド再利用
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("Hello"));

// 3) Task / async-await — 現代の推奨
await Task.Run(() => HeavyComputation());

// 4) Parallel — データ並列性
Parallel.For(0, 100, i => ProcessItem(i));

内部的には:

  • Linux:libcoreclrがpthread_create()使用
  • Windows:CreateThread()使用
  • macOS:pthread_create()使用(GCDは直接使わない)

Unityの特殊性:UnityはThread使用を制限的に推奨します。代わりにJob SystemとUniTask、Coroutineを使えと言います。理由はUnity Engine APIのほとんどがメインスレッド以外で呼び出すとクラッシュするからです。(Part 13で詳述)


Part 6:コンテキストスイッチ — なぜ高価か

コンテキストスイッチとは

CPUコア1つでスレッド複数個を順繰りに実行するには、現在のスレッドの状態を保存して次のスレッドの状態を復元する必要があります。これがコンテキストスイッチです。

保存すべきもの:

  • CPUレジスタ:RAX、RBX、…、RIP(プログラムカウンタ)、RSP(スタックポインタ)、フラグレジスタ
  • 浮動小数点レジスタ:XMM、YMM、ZMM(AVX時代には数十KB)
  • MMU状態:プロセスが変わるとページテーブルポインタ(x86のCR3レジスタ)の入れ替えが必要

コンテキストスイッチの「隠れたコスト」

レジスタの保存/復元は実は氷山の一角です。本当に高価なのは間接効果です。

コンテキストスイッチ — 直接コスト vs 隠れたコスト Thread A実行 スイッチ ~1-10μs Thread B実行(キャッシュ再構築中) スイッチ ~1-10μs Thread A実行(キャッシュ再構築) 直接コスト(視覚的に見える部分) • レジスタ保存(~30個、~数百バイト) • SIMDレジスタ保存(AVX-512時は数KB) • カーネルに入る → スケジューラ実行 → 復帰 • MMUポインタ入れ替え(プロセス間スイッチ時) 合計おおよそ 1〜10マイクロ秒(ハードウェア依存) 隠れたコスト(見えない部分) TLB flush:プロセス転換時にアドレス変換キャッシュが空に → 数百〜数千サイクルで再構築 CPUキャッシュ汚染:Thread Aが使っていたL1/L2データが Thread Bの実行で押し出される 分岐予測器汚染:ブランチ履歴がごちゃ混ぜに プリフェッチャ状態リセット 合計 数十マイクロ秒〜数ミリ秒 「その後の性能低下」として現れる 結論:スレッドを作りすぎるとCPUはずっとスイッチするだけで有用な仕事をしない 防止策:(1) スレッド数をコア数近辺に保つ、(2) Job/Taskで作業単位を細かく刻んでキューイング

TLBとプロセス間スイッチ

TLB (Translation Lookaside Buffer)はCPU内部の小さなキャッシュで、「仮想アドレス → 物理アドレス」変換結果を保存します。L1 TLBは通常64〜128エントリ程度です。

プロセスが変わるとCR3レジスタ(ページテーブルベース)が変わり、TLBは完全にflushされます(PCID/ASID最適化がなければ)。するとその後のメモリアクセスのたびにページテーブルを再度たどる必要があります。

スレッド間スイッチはより安いです — 同じアドレス空間を共有するのでCR3が変わらずTLB flushもありません。これが「プロセスよりスレッドが軽い」という言葉の具体的な根拠の1つです。

測定する

Linuxではperf statで測定できます:

1
2
3
4
5
6
7
$ perf stat -e context-switches,cpu-migrations,cache-misses -p <PID> sleep 10

Performance counter stats for process id '1234':

     12,345      context-switches
        567      cpu-migrations
 10,234,567      cache-misses

macOSではInstrumentsSystem Traceテンプレートでスレッドスケジューリングとコンテキストスイッチをマイクロ秒単位で観察できます。

WindowsではXperfまたはWindows Performance Analyzerが同じ役割。

LaMarca & Ladnerの観察

キャッシュ親和性の観点で、LaMarca & Ladner 1996 — “The Influence of Caches on the Performance of Heaps”のような研究が扱ったように、アルゴリズムの理論的複雑度だけでは実際の性能を予測できません。同じ理由で、スレッドを多く作るほど速くなるという素朴な期待はキャッシュ/TLBコストのために崩れやすいです。

「最適スレッド数 = コア数」というルールはこの観察から来ます。それ以上はコンテキストスイッチが利得を食いつぶします。


Part 7:ゲームエンジンの実行モデル

いよいよ理論をゲームエンジンにつなげます。

Unity — メインスレッドの強い制約

Unity開発者なら「このAPIはメインスレッドでのみ呼び出せる」という警告を一度は見たことがあるでしょう。Transform.positionGameObject.Instantiate()Renderer.sharedMaterialなど、Unity Engine APIのほとんどがメインスレッド専用です。

なぜか?

Unity EngineはC++で書かれており、内部データ構造にロックがありません。Unityチームが「すべてのエンジン呼び出しはメインスレッドから来る」という前提で設計したため、ロック取得オーバーヘッドをなくしました。

これは意図的なトレードオフです:

  • ✅ エンジン呼び出しが非常に速い(ロックなし)
  • ❌ マルチスレッド活用が難しい

Unityの解決策:Job System + Burst + Native Containers。メインスレッドはそのままにして、データ処理だけを並列化する別のレイヤを提供します。(Part 13で詳述)

Unreal Engine — Task Graph

Unreal EngineはTask Graphシステムを使います。ゲームコードが発行した「タスク」たちが依存性DAGをなし、エンジンがワーカースレッドプールに分配します。

Unrealのワーカースレッドプール:

  • Game Thread:ゲームロジック(Unityのメインスレッドに相当)
  • Render Thread:レンダリングコマンドのビルド
  • RHI Thread:GPUドライバ呼び出し
  • Worker Threads:その他汎用作業

タスクはENamedThreadsで実行するスレッドを指定します。例:ENamedThreads::GameThreadENamedThreads::AnyBackgroundHiPriTask

Fiber — Naughty Dogのアプローチ

Christian GyrlingのGDC 2015講演 “Parallelizing the Naughty Dog Engine Using Fibers”Fiberベースのエンジン設計で有名です。

Fiberは協調的ユーザーレベルスレッドです。OSが関与せずアプリケーションが自らスイッチします。カーネルスレッドが1人の働き手だとすれば、その働き手が持っている複数の仕事がFiberです。

  • Fiber生成コスト:極めて安い(数ナノ秒)
  • Fiberスイッチ:レジスタだけ保存/復元、カーネル介入なし
  • 数千個発行可能

Naughty DogのThe Last of Us 2はこのシステムでPS4の7コアを安定的に活用しました。FiberはM:Nモデルの1形態と見なせます(Fiber = ユーザースレッド、カーネルスレッド = ワーカー)。

WindowsのFiber APICreateFiberSwitchToFiber。macOS/Linuxではucontext.hmakecontext/swapcontext(レガシー、推奨されない)、またはBoost.Context、libcoのようなライブラリを使わなければなりません。

エンジン実行モデル比較

主要エンジンのスレッド実行モデル Unity Main Thread(固定) 大部分のEngine API Transform、GameObjectなど Job System(別レイヤ) W0 W1 W2 W..N IJob / IJobParallelFor Burst + Native Containers 哲学:Engine維持 + データ並列 Unreal Engine Game Thread Render Thread RHI Thread Audio Thread Worker Thread Pool W0 W1 W2 W..N Task Graph:依存性DAG ENamedThreadsでターゲット指定 哲学:複数Named Thread + 汎用プール Fiber (Naughty Dog) ワーカースレッド(コア数分) W0 W1 W2 W..7 Fiberプール(数千個) F F F F F ... Job = Fiberで実行 協調的スイッチ、カーネル介入なし 待機時はFiber入れ替えだけ 哲学:ユーザーレベル協調スケジューリング

Part 8:実戦観察 — 私のスレッドはどう動いているか

理論を知った後は実際に見ます。3つのOSすべてがプロセスとスレッドを観察する豊富なツールを提供します。

Linux — /proc、ps、top

Linuxではすべてが/proc仮想ファイルシステムに露出されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 特定プロセスのスレッド一覧
$ ls /proc/<PID>/task/
1234  1235  1236  ...

# 各スレッドの状態
$ cat /proc/1234/task/1234/status
Name:   myapp
State:  R (running)
Tgid:   1234
Pid:    1234
Threads: 8

# アドレス空間マッピング
$ cat /proc/1234/maps
00400000-00452000 r-xp 00000000 08:01 12345 /usr/bin/myapp
00651000-00652000 r--p 00051000 08:01 12345 /usr/bin/myapp
7f1234000000-7f1234021000 r-xp 00000000 08:01 54321 /lib/x86_64-linux-gnu/libc.so.6
...

top -Hでスレッド単位のCPU使用率を見られます。

macOS — Activity Monitor、ps、Instruments

Activity MonitorはGUIツールですが、より精密なデータはCLIツールにあります。

1
2
3
4
5
# プロセスのスレッド数確認
$ ps -M <PID>

# 詳細情報
$ sample <PID> 5 -mayDie

InstrumentsSystem Traceテンプレートが最も強力です。P/Eコア別の実行タイムライン、コンテキストスイッチイベント、ブロック原因まですべて見せてくれます。Apple Silicon環境で特に有用 — どのスレッドがP-coreで動いていてどのスレッドがE-coreに押しやられたかが可視化されます。

Windows — Process Explorer、WPA

Process Explorer (Sysinternals) はタスクマネージャーの強化版:

  • プロセスツリーの可視化
  • 各プロセスのスレッド一覧 + スタックトレース
  • ハンドル、DLL、メモリ詳細

Windows Performance Analyzer (WPA)はInstrumentsに相当。Xperfで収集したETWイベントを分析します。

C#でスレッドを扱う — コード例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

class ThreadInspector {
    static void Main() {
        Console.WriteLine($"現在のプロセスID: {Process.GetCurrentProcess().Id}");
        Console.WriteLine($"管理スレッドID: {Thread.CurrentThread.ManagedThreadId}");
        Console.WriteLine($"CPUコア数: {Environment.ProcessorCount}");

        // スレッド生成コスト測定
        var sw = Stopwatch.StartNew();
        var threads = new Thread[100];
        for (int i = 0; i < 100; i++) {
            threads[i] = new Thread(() => Thread.Sleep(1));
            threads[i].Start();
        }
        foreach (var t in threads) t.Join();
        sw.Stop();
        Console.WriteLine($"100個スレッド生成+終了: {sw.ElapsedMilliseconds}ms");

        // ThreadPool.Queueはずっと速い
        sw.Restart();
        var countdown = new CountdownEvent(100);
        for (int i = 0; i < 100; i++) {
            ThreadPool.QueueUserWorkItem(_ => {
                Thread.Sleep(1);
                countdown.Signal();
            });
        }
        countdown.Wait();
        sw.Stop();
        Console.WriteLine($"100個ThreadPool作業: {sw.ElapsedMilliseconds}ms");
    }
}

実行結果(筆者マシンでおおよそ):

1
2
3
4
5
現在のプロセスID: 12345
管理スレッドID: 1
CPUコア数: 8
100個スレッド生成+終了: 85ms
100個ThreadPool作業: 8ms

10倍の差。これがスレッドプールを使う実用的な理由です。.NETのThreadPool、JavaのExecutorService、C++のstd::asyncすべて同じアイデアです — スレッドを再利用して生成コストを分割償還。


まとめ

この回で扱ったこと:

プロセス

  • PCB(task_structEPROCESSproc+task) — OSがプロセスを追跡する構造
  • アドレス空間レイアウト:Text、Data、BSS、Heap、Stack、Kernel
  • プロセス状態遷移:New、Ready、Running、Waiting、Terminated

プロセス生成

  • Unixのfork() + exec() — 2段階、Copy-on-Writeで実際には速い
  • WindowsのCreateProcess() — 1段階、引数が多い
  • macOSのposix_spawn() — iOS互換 + より効率的
  • fork()時のCOWはハードウェアMMUサポートに基づく

スレッド

  • プロセス vs スレッド:アドレス空間を共有するかどうかが核心
  • 共有:Text、Data、Heap、ファイルディスクリプタ
  • 専用:Stack、レジスタ、TLS
  • Linuxの独特な哲学:プロセスとスレッドを同じ構造体で表現(clone()

スレッドマッピングモデル

  • 1:1(Linux NPTL、Windows):標準、真の並列
  • N:1(昔のグリーンスレッド):ほぼ衰退
  • M:N(Go goroutine、Erlang):数百万の同時スレッド、ランタイム実装が複雑

コンテキストスイッチ

  • 直接コスト:レジスタ保存/復元 ~1-10μs
  • 隠れたコスト:TLB flush、キャッシュ汚染、分岐予測器汚染
  • プロセス間スイッチがスレッド間スイッチより高価(CR3入れ替え)
  • 「スレッド数 = コア数」原則

ゲームエンジン実行モデル

  • Unity:Main Thread制約 + Job System(データ並列化)
  • Unreal:複数Named Thread + Task Graph
  • Naughty Dogエンジン:Fiberベース協調的スケジューリング

次回はPart 9 スケジューリング — 複数のスレッドが準備状態のとき、OSは誰にCPUを渡すでしょうか?LinuxのCFS → EEVDF、Windowsのpriority boost、macOSのQoSベーススケジューリングを見ます。ゲームのフレーム予算16.67msとpriority inversion問題も扱います。


References

教科書

  • Silberschatz, Galvin, Gagne — Operating System Concepts, 10th ed., Wiley, 2018 — Ch.3 (Processes)、Ch.4 (Threads)
  • Bovet, Cesati — Understanding the Linux Kernel, 3rd ed., O’Reilly, 2005 — task_structとプロセス管理 Ch.3
  • Mauerer — Professional Linux Kernel Architecture, Wrox, 2008 — 現代のLinuxカーネル内部
  • Russinovich, Solomon, Ionescu — Windows Internals, 7th ed., Microsoft Press, 2017 — EPROCESS/ETHREAD詳細
  • Singh — Mac OS X Internals: A Systems Approach, Addison-Wesley, 2006 — XNUのtask/proc二重構造
  • Butenhof — Programming with POSIX Threads, Addison-Wesley, 1997 — pthreadsの古典
  • Stevens, Rago — Advanced Programming in the UNIX Environment, 3rd ed., Addison-Wesley, 2013 — fork/execの実践
  • Gregory — Game Engine Architecture, 3rd ed., CRC Press, 2018 — Ch.8 マルチプロセッサエンジン設計

論文

  • Anderson, Bershad, Lazowska, Levy — “Scheduler Activations: Effective Kernel Support for the User-Level Management of Parallelism”, SOSP 1991 — DOI — M:Nモデルの理論的基礎
  • Mogul, Borg — “The Effect of Context Switches on Cache Performance”, ASPLOS 1991 — コンテキストスイッチの隠れたコストの測定
  • Engelschall — “Portable Multithreading: The Signal Stack Trick for User-Space Thread Creation”, USENIX 2000 — ユーザーレベルスレッド実装
  • Kleiman, Smaalders — “The LWP Framework: Building and Debugging Mach Tasks and Threads”, Mach Workshop 1990 — Machのスレッドモデル

公式ドキュメント

ゲーム開発 / GDC資料

ブログ / 記事

  • Raymond Chen — The Old New Thing — Win32 CreateProcessの内部
  • Linus Torvalds — comp.os.minixのスレッド関連の初期議論(1992)
  • Dmitry Vyukov — 1024cores.net — ロックフリー並行性の資料(Goスケジューラ内部を含む)
  • Howard Oakley — The Eclectic Light Company — macOSスレッド観察テクニック

ツール

  • Linux:pstophtopstraceperfftrace
  • macOS:Activity Monitor、pssample、Instruments(System Trace、Time Profiler)
  • Windows:Task Manager、Process Explorer、WPA、PerfView
  • クロスプラットフォーム:Tracy Profiler — ゲームへの組み込みに向く
この記事は著者の CC BY 4.0 ライセンスの下で提供されています。