CSロードマップ 第8回 — プロセスとスレッド:OSは実行単位をどう抽象化するか
- CS ロードマップ (0) — AI時代、CS知識はなぜより重要になったのか
- CSロードマップ (1) — 配列と連結リスト:メモリの地形を読む
- CS ロードマップ (2) — スタック、キュー、デック:制限が生み出す強力な抽象化
- CSロードマップ (3) — ハッシュテーブル:O(1)探索の条件と限界
- CSロードマップ (4) — ツリー:順序と均衡、O(log n)の保証
- CSロードマップ (5) — グラフ:関係のネットワーク、経路の科学
- CSロードマップ (6) — メモリ管理:スタックとヒープ、GC、そしてフレームを食い潰すものたち
- CSロードマップ (番外編) — ヒープと優先度キュー:部分順序の経済学
- CSロードマップ 第7回 — OSアーキテクチャ入門:Unix、NT、XNUの分かれ道
- CSロードマップ 第8回 — プロセスとスレッド:OSは実行単位をどう抽象化するか
- CSロードマップ第9回 — スケジューリング: OSは誰にCPUを渡すのか
- プロセスは「独立したアドレス空間 + 資源の束」で、スレッドは「プロセス内の実行フロー」である。スレッドはコード/ヒープ/グローバル変数を共有するが、スタックとレジスタは専用で持つ
- 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で作業を細かく分割してコアに分配する」方向に進んでいる
はじめに:地図から本論へ
前回では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で実行されているインスタンスがプロセスです。
プロセスが持つもの:
- 固有のアドレス空間 (Address Space) — 他のプロセスと隔離されたメモリ
- 実行状態 — CPUレジスタ値、プログラムカウンタ
- 開いているファイルのテーブル — 現在使用中のファイルディスクリプタ一覧
- 所有者情報 — UID、GIDなどの権限
- 子プロセス関係 — 誰が誰を作ったか(プロセスツリー)
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 — EPROCESS、KPROCESS
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プログラム(ps、top)はprocを見て、Machベースのツール(lldb、Instruments)はtaskを見ます。
プロセスのアドレス空間レイアウト
プロセスが持つメモリはどう配置されているでしょうか?伝統的なUnix/Linuxプロセスの32ビットアドレス空間レイアウトを見ます。
各領域を説明します(低位アドレスから):
- 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:動的割り当て(
malloc、new)。brk()システムコールで上に拡張 - 共有ライブラリ領域 (mmap):
libc.so、libstdc++.soなどがmmap()でこの領域にマップされる - Stack:関数呼び出しフレーム、ローカル変数、戻りアドレス。下向きに伸びる
- Kernel Space:カーネルコードとデータ。ユーザープロセスは直接アクセス不可。32ビットLinuxでは上位1GB、x86-64では上位半分
WindowsもPEで異なるセクション名を使いますが、構造はほぼ同じです(.text、.data、.rdata、.bss)。
プロセスの状態遷移
プロセスは複数の状態を行き来します。Silberschatz教科書の標準モデル:
- New:プロセスが作られたばかり
- Ready:実行可能だがCPUを待っている
- Running:CPUで実際に実行中
- Waiting(またはBlocked):I/O完了やイベントを待っている
- Terminated:終了
実際のOSはさらに複雑な状態を持ちます。Linuxのtask_structにはTASK_RUNNING、TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE、TASK_STOPPED、TASK_TRACED、TASK_DEAD、TASK_WAKEKILL、TASK_WAKING、TASK_PARKEDなどがあります。psで見えるS、R、D、Zといった文字がこれらです。
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な実装):
- 新しいPCB(
task_struct)を作成 - 親のアドレス空間をすべてコピー(text、data、heap、stackすべて)
- 開いているファイルディスクリプタもコピー
- 子に新しいPIDを割り当て
- 子をreadyキューに入れる
問題は2番目です。プロセスのアドレス空間が数百MBのとき毎回コピーするのは莫大に高価です。しかもfork()直後にexec()を呼べばどうせアドレス空間を上書きするわけで、コピーしてすぐ捨てることになります。
Copy-on-Write — 「本当に書き込むときにコピー」
解決策はCopy-on-Write (COW)です。fork()時点ではページテーブルだけをコピーし、実際のメモリページは親と子が共有します。ただしページは読み取り専用にマークされます。
どちらかがページに書き込もうとするとハードウェアがpage faultを発生させ、OSはそこで初めてそのページだけコピーします。
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にマッピングされるとき実際には:
- 現在の
proc構造体を複製 - 現在の
taskをMachレベルで複製(task_create()) - 初期スレッドを1つ作成(
thread_create()) - アドレス空間も複製(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つの理由が重なります。
- サンドボックス侵害リスク:fork()された子プロセスは親の権限を継承しますが、iOSの厳格なアプリサンドボックスモデルではこの境界を壊しかねない潜在的脆弱性になります
- Objective-Cランタイムの状態複製問題:iOSアプリはほとんどがObjective-CやSwiftで書かれ、これらの言語のランタイムは初期化時に多くの状態(スレッド、GCDキュー、IOKit接続など)を生成します。fork()以降これらの状態が整合性を失いがちです
- メモリ効率:iOSはメモリが限られており、COWでもページテーブル複製は必要。posix_spawn()はこれすら省略可能
macOSではfork()は依然として許可されていますが、Appleは「可能な限りposix_spawn()を使う」ことを推奨します。
Part 3:スレッド — なぜプロセスだけでは足りないのか
プロセスベース並行性の限界
1970〜80年代のUnixはプロセス1つ = 実行フロー1つでした。複数のことを同時にしたければfork()でプロセスを複数作りました。Webサーバであれば接続ごとにプロセスを1つずつ作る方式(古典的なApacheのpreforkモード)。
このモデルの問題:
- プロセス生成コスト:COWで安くなったとはいえ、ページテーブル複製、PCB割り当てなど、依然数マイクロ秒〜ミリ秒単位
- コンテキストスイッチコスト:プロセス間切り替え時にアドレス空間も変わるためTLBフラッシュが必要(後で詳述)
- プロセス間通信 (IPC) コスト:プロセス同士はアドレス空間が分離されているため、データをやり取りするにはパイプ、ソケット、共有メモリなど重いメカニズムが必要
- 共有状態の表現の難しさ:複数の実行フローが同じデータ構造を扱いたいとき複雑
1990年代に入って解決策が必要になり、それがスレッド (Thread)です。
スレッドの定義
スレッドとはプロセス内部の独立した実行フローです。1つのプロセスの中に複数のスレッドがあれば、全員が同じアドレス空間を共有しながら、それぞれがCPUで同時に実行されえます。
スレッドが共有するもの:
- Text(コード):当然同じコードを実行
- Heap:
mallocで割り当てたメモリ - Data / BSS:グローバル変数、静的変数
- 開いているファイルディスクリプタ
- シグナルハンドラ
スレッドが別々に持つもの:
- Stack(スタック):各スレッドごとに別
- CPUレジスタ状態:PC、SP、汎用レジスタなど
- TLS (Thread-Local Storage):スレッド別のグローバル変数
- エラー状態:
errno(POSIXではスレッド別)
この図で重要な点:
- スレッド間ではheapとグローバル変数がそのまま共有されます — 「共有メモリ」が自然に存在する
- つまりスレッド2つが同じ
int counterを同時にcounter++するとrace conditionが生じる - 逆にプロセス2つはアドレス空間が分離されていて自然に隔離されている
ステージ2の核心的な問い — 「スレッド2つが同じ変数を使うとなぜプログラムが時々だけ死ぬのか?」 — の答えがこの図の中にあります。スレッドは意図的にメモリを共有するため並行性の問題が生じ、それを管理する同期技法が必要です。(次回Part 10同期プリミティブで本格的に扱います。)
TCB — スレッド制御ブロック
プロセスにPCBがあるように、スレッドにはTCB (Thread Control Block)があります。TCBが持つもの:
- スレッドID
- CPUレジスタ状態(保存されたコンテキスト)
- スレッド状態(Running、Ready、Waiting)
- スタックポインタ、スタックベース
- スケジューリング情報(優先度など)
- 所属プロセスへのポインタ
OS別の実装:
- Linux:
task_struct— プロセスとスレッドを同じ構造体で表現。どのフィールドを共有するかで区別する - Windows:
KTHREAD+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 すべての共有フラグOFFpthread_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/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準拠 + 性能で置き換えました。
Windows: CreateThread()はカーネルの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) の初期化状態をスキップします — errno、strtokのようなスレッド別の状態が初期化されず問題が起きます。_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レジスタ)の入れ替えが必要
コンテキストスイッチの「隠れたコスト」
レジスタの保存/復元は実は氷山の一角です。本当に高価なのは間接効果です。
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ではInstrumentsのSystem 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.position、GameObject.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::GameThread、ENamedThreads::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 API:CreateFiber、SwitchToFiber。macOS/Linuxではucontext.hのmakecontext/swapcontext(レガシー、推奨されない)、またはBoost.Context、libcoのようなライブラリを使わなければなりません。
エンジン実行モデル比較
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
InstrumentsのSystem 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_struct、EPROCESS、proc+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のスレッドモデル
公式ドキュメント
- Linux man pages —
clone(2)、fork(2)、pthread_create(3)、proc(5)— man7.org - Apple Developer — Threading Programming Guide — developer.apple.com
- Apple Developer — Dispatch — developer.apple.com/documentation/dispatch
- Microsoft Docs — Processes and Threads — learn.microsoft.com
- Microsoft Docs — Fibers — learn.microsoft.com
- Go Runtime — The Go Scheduler (Dmitry Vyukov) — morsmachine.dk/go-scheduler
ゲーム開発 / GDC資料
- Gyrling, C. — Parallelizing the Naughty Dog Engine Using Fibers, GDC 2015 — gdcvault.com
- Unity Technologies — C# Job System manual — docs.unity3d.com
- Unreal Engine Documentation — Task Graph System — dev.epicgames.com
- Fabian Giesen — Reading List on Multithreading and Synchronization — fgiesen.wordpress.com
ブログ / 記事
- 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:
ps、top、htop、strace、perf、ftrace - macOS:Activity Monitor、
ps、sample、Instruments(System Trace、Time Profiler) - Windows:Task Manager、Process Explorer、WPA、PerfView
- クロスプラットフォーム:Tracy Profiler — ゲームへの組み込みに向く
