プロセスとスレッド 基礎
我々の curl コマンドは プロセス として走る —— OS 抽象がアドレス空間、オープンファイル表、わずかなカーネル状態をまとめたもの。 その中で 1 つ、または複数の スレッド が走り得る。 5 セクションで全体像を組み立てる:プロセスを構成するもの(PCB、アドレス空間、fd 表); アドレス空間を共有する実行可能コンテキストとしての スレッド;ユーザ / カーネルモード境界と各側が見えるもの;コンテキストスイッチ + 対話的コスト分解; 最後にクイックリファレンス。
プロセスとは実際何か
走っているプログラムはディスク上のファイルではない。OS の簿記 —— アドレス空間、オープンファイル、credentials、スケジューラブル状態 —— をカーネルが名付け、サスペンドし、他のすべてから保護できる 1 つの単位にまとめたもの。
curl ... と打つと、シェルが fork() + execve()を呼ぶ:カーネルは新しい task_struct(Linux のプロセス制御ブロック PCB の名前)、新しい mm_struct(アドレス空間)、新しいファイルディスクリプタ表を確保し、新しい PID を割り当てる。 ELF バイナリのコードがマップされ、エントリポイントがプログラムカウンタにロードされ、実行が始まる。 走っているプログラムを構成するすべて —— あらゆるレジスタ、スタックとヒープのあらゆるバイト、 あらゆるオープンソケット —— はそれらのカーネル構造の中に住み、バイナリファイルの中ではない。
アドレス空間
各プロセスは私的な 64 ビット仮想アドレス空間を持つ (x86-64 で 256 TB、うちユーザ半分が 128 TB)。低位から高位への配置:
- text —— プログラムの機械語、読み取り専用、ELF ファイルからマップ。
- data / bss —— グローバル変数、初期化済みと未初期化。
- heap ——
brk()またはmmapで上方向に伸びる。mallocがここに住む。 - mmap 領域 —— 共有ライブラリ、大型割り当て、ファイルマッピング。
- stack —— 高位アドレスから下方向に伸びる;スレッドごとに 1 つ。
- カーネル空間 —— 上半分(x86-64 Linux で 0xffff_8000_0000_0000 以上)、 全プロセスにマップされるがカーネルモードでのみアクセス可能。
アドレス空間は仮想:あらゆる load と store はページテーブルを通り、その仮想アドレスを どの物理ページ(あれば)が支えているか見つける。2 プロセスが両方とも仮想アドレス0x7fff1234 に整数 42 を保持できる、ただし別の物理ページで。 virtual-memory primer がその仕組みを扱う。
PCB の残り
アドレス空間以外に、プロセスは以下を所有する:
- オープンファイル表 —— fd 0(stdin)、1(stdout)、2(stderr)、 加えて
open()されたソケットやファイル。fd 番号はプロセスごとのstruct fileポインタ配列のインデックス。 - Credentials —— uid、gid、capabilities。すべてのカーネル権限チェックで使われる。
- シグナル処理状態 —— どのシグナルがブロックされ、どれにカスタムハンドラがあるか。
- 作業ディレクトリ、root、umask、rlimits —— 他のすべてが依存する小さなプロセスごとの状態。
- 親 / 子ポインタ —— プロセスツリー。
wait()とゾンビがどう刈り取られるかに使われる。 - スケジューリング状態 —— 実行可能、スリープ、ゾンビ;優先度;蓄積 CPU 時間。 cpu-scheduling primer がスケジューラがこれをどう使うかを説明する。
このバンドル —— アドレス空間 + オープンファイル + credentials + スケジューリング状態 —— が隔離の単位。カーネルはどのプロセスも別プロセスのメモリを読み、ファイルディスクリプタを盗聴し、 credentials をなりすますことができないと保証する —— 明示的に仲介する IPC メカニズム(共有メモリ、パイプ、ソケット、シグナル、ptrace)を除いて。
要点。「プロセスはカーネルが隔離のためにまとめた束: アドレス空間 + オープンファイル表 + credentials + スケジューリング状態。 ディスク上のバイナリは種;プログラムを走らせるすべては、fork() + execve() 時にカーネルが確保した task_struct と mm_struct の中に住む。」
スレッド —— アドレス空間を共有する実行可能コンテキスト
スレッドはスケジューラが CPU を渡す単位。プロセスは 1 つのスレッド (最初に書いた「シングルスレッドプログラム」)、または数千を含み得る。 すべてが同じメモリを共有する —— これがスレッディングの魅力の源泉であり、 バグの大半の源泉でもある。
Linux では pthread_create は実際 clone() syscall を、 親プロセスの mm_struct、fd テーブル、シグナルハンドラ、その他の状態を共有する 新しい task_struct を求めるフラグ付きで呼ぶ。 新しいタスクは独自のスタック、独自のスレッドローカルストレージ、独自のレジスタ状態、 独自のカーネルスケジューリングスロットを得る —— しかしグローバルメモリへの読み書きは、プロセス内の他のすべてのスレッドが見るのと同じ物理ページに当たる。
その共有が魅力でもあり危険でもある。魅力:スレッド間のデータ受け渡しが無料 —— 全員が同じポインタと同じヒープを見る。危険:共有状態への同期なしのアクセスが メモリを静かに破壊する;concurrency primer がどうするかを扱う。
カーネルスレッド vs ユーザスレッド
現代の Linux では各 pthread はカーネルスレッド —— カーネルがそれを知り、スケジュールし、サスペンド・再開でき、CPU 間で移動できる。 これは時に「1:1 モデル」と呼ばれる:アプリケーションレベルのスレッド 1 つにつき カーネルスレッド 1 つ。シンプルでプロセスが実際に複数 CPU を並列で使えるが、 カーネルスレッドは重い —— それぞれが task_struct、カーネルスタック(Linux で 16 KB)、 ~1 KB の追加簿記をコストとする。サーバでの実用上限:数千。
ユーザ空間スレッド(グリーンスレッド、ファイバ、コルーチン)は、 多くのアプリケーションレベルの並行タスクをより少ないカーネルスレッドに多重化する。 Go の goroutine、Java 仮想スレッド(Project Loom、JDK 21+)、Rust async task、 Python asyncio task —— すべてユーザレベル。 ランタイムは独自のスケジューラを維持し、カーネルスレッド上の M:N または 1:N モデルで タスクをパーク / ウェイクする。利点は数百万の並行タスクへのスケーリング (goroutine は ~2 KB のスタック); コストはどの底層カーネルスレッドでのブロッキング syscall も、その上でスケジュールされた 全ユーザタスクをブロックする(ランタイムが他のカーネルスレッドへ移動するか、 非ブロッキング I/O を使うことで緩和される —— I/O primer が epoll と io_uring を扱う)。
スレッドローカルストレージ(TLS)
各スレッドはアドレス空間を共有するので、C のグローバル変数はデフォルトで共有される。 各スレッドに独自の値を持たせるには、thread_local(C++11)または__thread(GCC)を宣言、または pthread_key_create で ランタイム key 付きストレージを使う。コンパイラは FS セグメントレジスタ(x86-64) または TPIDR_EL0(ARM64)経由のアクセスを吐き、各スレッドが異なる物理位置から読むようにする。
TLS は共有状態への競合を回避する標準的な方法:cache-coherence のスレッドごとのカウンタパターン、 tcmalloc/jemalloc のスレッドごとアロケータキャッシュ、トレース内のスレッドごと現在リクエストコンテキスト —— すべて TLS に住む。
スレッドのコスト
- メモリ。各スレッドはデフォルトで 8 MB のスタック予約 (仮想;物理ページは使用時に割り当て)。1 000 スレッド = ~8 GB の仮想アドレス空間予約。
pthread_attr_setstacksizeで縮小できるが、多くのサーバは手間をかけない。 - スケジューラ負荷。CFS スケジューラは実行可能スレッドの O(log N) で 次に走らせる者を決める。数千は問題なし;数百万は不可 (そういうシナリオがユーザ空間スレッディングランタイムを必要とする理由)。
- キャッシュ圧迫。各スレッドは独自のワーキングセットを持つ; それらの間のコンテキストスイッチが互いのデータを L1/L2 から追い出す。 高並行低スループットのサーバは、実作業よりキャッシュスラッシングに多くの時間を費やし得る。
要点。「スレッドは親プロセスのアドレス空間、fd、credentials を共有するが、 独自のスタックとレジスタを持つ。カーネルスレッド(Linux の各 pthread)はカーネル スケジューリング単位と 1:1 —— 数千が実用上限。ユーザ空間スレッド(goroutine、 仮想スレッド、async task)は多タスクをより少ないカーネルスレッドに多重化し数百万にスケールする、 代価として底に非ブロッキング I/O が必要。」
ユーザモード vs カーネルモード —— 特権境界
CPU はハードウェアレベルで 2 つの特権レベルを強制する。プログラムが実行する あらゆるバイトのコードは、どちらか一方で走り、何ができるかのルールが両者間で大きく異なる。
現代の CPU は複数の特権リングを実装する(x86:4 リング、実用は 0 と 3 のみ; ARM:4 例外レベル EL0–EL3)。我々の目的には 2 つある:ユーザモード(ring 3 / EL0)、あらゆるアプリケーションが走る場所、 と カーネルモード(ring 0 / EL1)、OS が走る場所。 ハードウェアが境界を強制する;ソフトウェアは逃れられない。
ユーザモードができないこと
- マップされていないメモリに触る。各プロセスのアドレス空間のカーネル半分はマップされているが ring 3 からアクセス不能 —— 読み書きを試みると fault が発生し、カーネルがそれを SIGSEGV に変換する。
- 特権命令を使う。割り込み無効化(
cli)、 新ページテーブルロード(mov cr3, ...)、model-specific レジスタの読み出し、 I/O ポート命令の実行(in/out)、IDT 設定 —— すべて ring 0 専用。 - ハードウェアと直接話す。ネットワークカードのバッファを読まない、タイマーをプログラムしない、DMA を起動しない。 ユーザコードは syscall でカーネルに頼む;カーネルが実際のハードウェア作業を行う。
カーネルモードができること(ほぼすべて)
ring 0 で走っているとき、カーネルはハードウェアができることなら何でもできる。 カーネルは自身のポリシーには従う —— capability チェック、namespace 制限、cgroup 制限 —— がそれらはソフトウェアの慣習であり、ハードウェアが強制するものではない。 カーネルのバグはあらゆるメモリを破壊し、マシンをダウンさせ、悪意のあるユーザに root を与え得る。 だからカーネルコードのレビューは、ユーザ空間の何ものよりもパラノイア度が高い。
制御はどう境界を超えるか
3 つのメカニズムが CPU をリング間で動かす、それぞれが独自の primer で扱われる:
- Syscall(x86-64 では
syscall、ARM64 ではsvc #0): ユーザコードがカーネルに何かを頼む。自発的、制御された、引数はレジスタ。 assembly-isa primer を参照。 - 割り込み(interrupt):ハードウェアがカーネルの注意を要求する —— ネットワークカードにパケットがある、タイマーが発火した、キーボードに入力がある。 ユーザコードの視点からは不随意。syscalls-interrupts primer を参照。
- 例外(exception):ユーザコードが違法なことをした —— 0 除算、 マップされていないアドレスのデリファレンス、未定義命令の実行。 CPU は固定のカーネルハンドラへジャンプする;カーネルは通常シグナル (SIGSEGV、SIGFPE、SIGILL)をプロセスに送り返す。
3 ケースとも、ハードウェアがアトミックにユーザの命令ポインタとスタックポインタを保存し、 CPU ごとのカーネルスタックへ切り替え、特権リングを上げ、 model-specific レジスタに記録されたカーネルエントリポイントへジャンプする。 カーネルはやるべきことをやり、その後 ring 3 への戻りを保存された(または変更された) ユーザ命令ポインタで手配する。
なぜ 2 モードが存在するか
特権分離がなければ、あらゆるプログラムがカーネルを破壊でき、あらゆるプログラムが 他のプログラムのメモリを読め、あらゆるプログラムがあらゆるディスクファイルを読める。 実用的なマルチユーザコンピューティングは隔離を必要とする。 カーネルはそれを強制する信頼された仲介者;特権境界はハードウェアがカーネルを支える方法。
コストは実在する —— あらゆる syscall、あらゆるページフォールト、あらゆる割り込みが 境界の交差。post-Meltdown の KPTI で各交差が TLB をフラッシュするようになり、 各交差のコストが ~50 ns から ~150 ns に上昇した。 高性能サーバは交差を避けるために大いに努力する(I/O primer が io_uring を、 syscalls primer が vDSO を扱う)。
要点。「ユーザモード(ring 3)はマップされていないメモリに触れず、 特権命令を実行できず、ハードウェアと話せない。カーネルモード(ring 0)はハードウェアが できることを何でもできる。制御は syscall(自発的)、割り込み(ハードウェア発起)、 または例外(ユーザコードフォールト)経由で境界を越える。各交差は ~150 ns の純粋オーバーヘッド、 高スループットサーバがそれを最小化する理由。」
コンテキストスイッチ —— 実際のコスト
カーネルは秒間数千回、走っているスレッドを切り替える。直接コストは数百ナノ秒; 間接コスト(冷たいキャッシュ、TLB フラッシュ、スケジューラ簿記)はしばしばそれを矮小化する。
コンテキストスイッチは、カーネルが 1 つのスレッドを停止して別のスレッドを開始する必要があるたびに発生する。 トリガは:タイマー割り込みが現在スレッドのタイムスライスを使い切る、現在スレッドが I/O で自発的にブロック、 より高優先度のスレッドが実行可能になる、または現在スレッドが sched_yield を呼ぶ。
何が保存・復元されるか
カーネルは離脱スレッドの CPU 状態をその task_struct に保存する: 汎用レジスタ、命令ポインタ、スタックポインタ、フラグ、セグメントレジスタ、 FPU とベクトル状態(現代 CPU の大半は遅延 —— 実際に使われた時のみ復元)。 x86-64 では ~200 バイトの状態;保存と復元に数十サイクル。
その後カーネルは次に何を走らせるか決め(スケジューリング決定 —— 次の primer 参照)、 CPU ごとのポインタを更新し(Linux の current)、到着スレッドの状態のロードを開始する。 到着スレッドが別プロセスにあるなら、カーネルは CR3(ページテーブルベースレジスタ)もスワップする —— これが TLB フラッシュをトリガする —— キャッシュされた仮想→物理翻訳すべてが捨てられ、次のアクセスでページテーブルから再ウォークする必要がある。
コストの非対称性
プロセス → プロセスのスイッチは高い方。直接のレジスタ保存 / 復元(~100 ns)に加え、 TLB フラッシュは次の ~50–500 メモリアクセスがキャッシュされた翻訳ではなくページテーブルウォーク になることを意味する。L1 命令とデータキャッシュも離脱スレッドからの内容を失い、 到着スレッドのために L2/L3/DRAM から再温める必要がある —— スイッチ後の最初の ~10 μs に測定可能な IPC 低下として現れる。
同一プロセス内のスレッド → スレッドのスイッチは CR3 スワップと TLB フラッシュを完全に飛ばす。 直接コストは ~100 ns;キャッシュ効果は最小、到着スレッドのワーキングセットは依然暖かい可能性が高いから (最近走っていた)。これが「単一プロセスを共有する数百スレッド」が「リクエストごとの 1 プロセス」を I/O 律速サーバで同じワークロード下で 5–10 倍上回る理由。
現代の緩和策
- ASID / PCID タグ付き TLB。最近の x86(Westmere 以降、2010 年)と すべての現代 ARM は TLB エントリにプロセス ID をタグ付けする、 異なるプロセスへのスイッチが離脱プロセスの TLB エントリを保持できる、 フラッシュする代わりに。再ウォークコストを下げるがキャッシュ効果は消せない。
- CPU アフィニティ。スレッドを特定のコアに固定すると (
sched_setaffinity、taskset)、 そのキャッシュ状態がスイッチを跨いで暖かいまま保たれる。 低レイテンシシステム(高頻度取引、ScyllaDB のようなデータベース)が使う。 - ユーザ空間スケジューリング。Go の goroutine スケジューラは ほぼすべてをユーザ空間で行い、goroutine 間をカーネルを介さずスイッチする。 各スイッチが ~50 ns、カーネルの ~1 μs と比較。 Java 仮想スレッド(Loom)と Rust の tokio も同じ仕組み。
どう測るか
Linux 上:perf stat -e context-switches,cs,migrations がレートを表示;perf sched がウェイクアップから実行までの壁時計レイテンシを表示。vmstat 1 の cs 列が最も簡単な 1 行チェック。 50K req/s を捌く典型的な web サーバは秒間 10 万〜50 万コンテキストスイッチを示す; 忙しいデータベースは秒間 1 万〜5 万。秒間百万級はスレッドプールが大きすぎるか、 過剰なロック競合を示唆する。
要点。「コンテキストスイッチは離脱スレッドのレジスタを保存し、 次のスレッドを選び、そのレジスタをロードする。同一プロセス内スレッド→スレッド: 直接 ~100 ns、キャッシュは暖かいまま。プロセス→プロセス:直接 ~100 ns + TLB フラッシュ + キャッシュの後悔 —— I/O 律速コードでミリ秒級の壁時計影響。 ユーザ空間スケジューリング(goroutine、仮想スレッド)はプロセス内ケースで カーネルを完全に回避し ~10 倍速く走る。」
クイックリファレンス
冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。
プロセスとスレッドの違いは?
プロセスは OS が隔離するバンドル(アドレス空間 + オープンファイル + credentials + スケジューリング状態)。 スレッドはプロセス内の 1 つの実行可能コンテキスト。同一プロセス内のスレッドは アドレス空間、fd テーブル、credentials を共有 —— スタックとレジスタだけがスレッドごと。 Linux では両者とも同じ clone() syscall で作られる;フラグが異なる。
なぜスレッドスイッチがプロセススイッチより安いか?
両者ともレジスタを保存・復元する(~100 ns)。プロセススイッチは追加で CR3 をスワップし、 ASID タグなし TLB を持つ CPU では TLB をフラッシュする —— キャッシュされた仮想→物理翻訳を無効化し、 次のメモリアクセス多数にページテーブルウォークを強制する。冷たいキャッシュの後悔も加わる: メモリ律速コードでミリ秒級の壁時計影響。
カーネルスレッドとユーザ空間スレッドの違いは?
カーネルスレッド(Linux のあらゆる pthread)はカーネルスケジューリング単位と 1:1 —— カーネルが各々を知っている。重い:マシンあたり実用上限 ~数千。 ユーザ空間スレッド(goroutine、仮想スレッド、async task)はランタイムが管理し、 より少ないカーネルスレッドに多重化する。軽い:プロセスあたり数百万、 ただしカーネルスレッドをブロックしその上の全ユーザタスクをスタールさせないために、 下に非ブロッキング I/O が必要。
fork() は何をする、COW がどう安くするか?
fork() は親の同一コピーである子プロセスを作る —— 同じコード、同じデータ、同じ fd、同じレジスタ。Copy-on-write が安くする: カーネルがページテーブルを複製するが各ページを読み取り専用にマークし、参照カウントを上げる。 両プロセスは誰かが書くまで同じ物理ページを見る —— 書いた時点で fault が発生し、そのページだけがコピーされる。 典型的な fork+exec は大半のページを書かないので、大半のページは共有のまま。fork なしの exec(並列性に使う)は実際に変更されたページにのみコストを払う。
CPU がユーザモード vs カーネルモードにあることは何を意味するか?
ユーザモード(x86 ring 3、ARM EL0):カーネルメモリにアクセスできず、特権命令を使えず、 ハードウェアに直接話せない。アプリケーションコードはここに住む。 カーネルモード(ring 0 / EL1):ハードウェアができることを何でもできる。 交差は syscall(自発的)、割り込み(ハードウェア発起)、または例外(ユーザコードフォールト)経由。 post-KPTI 各交差は ~150 ns の純粋オーバーヘッド、高スループットサーバが最小化する理由。
コンテキストスイッチが設定する最大スループット上限をどう見積もるか?
典型的なカーネルスレッドスイッチ:~1 μs CPU 時間。シングルコアでは秒間 100 万スイッチの天井、 しかしそのレートでは CPU はスイッチしかしていない —— 有用な仕事ゼロ。現実的な上限: コアあたり秒間 ~10 万〜50 万コンテキストスイッチ、それを超えるとオーバーヘッドが支配的になる。 サーバが 50K req/s でリクエストあたり 2 スイッチをしているなら ~100K/s で問題なし; 500K req/s でリクエストあたり 10 スイッチなら、サイクルを浪費している。perf stat -e context-switches で測る。
コードレビューの赤旗
- リクエストごとにプロセスを生成。Apache prefork スタイル。 低並行性で動くが、fork ごとのオーバーヘッドとプロセスごとのメモリフットプリントが 数百並行を超えるとそれを崩壊させる。スレッドまたは async を使う。
- 無制限のスレッドプール。「到着リクエストごとに新スレッドを作成」を上限なしでやると、 ~数千スレッドの天井に当たり、OOM(スタック予約)するかスケジューラをスラッシュする。 境界付きプール + キューを使う。
- async / goroutine コンテキスト内のブロッキング呼び出し。ブロッキング syscall は底層のカーネルスレッドを固定し、その上にスケジュールされた 他のすべてのタスクを餓死させる。async バリアントを使うか、ワーカースレッドへ手渡す。
- 同期なしの共有可変状態。スレッドはメモリを共有する; 言語の中にそれを思い出させるものはない。
std::atomic、Arc<Mutex<...>>、チャネル、またはメッセージパッシング —— 何かが同期を所有しなければならない。 - yield しないタイトな CPU 律速ループ。スケジューリングポイント(関数呼び出し、チャネル操作、syscall)に ミリ秒間ヒットしない goroutine やスレッドは、CPU を握り続けて 同じランキュー上の他のすべてを遅延させる。