プロセスとスレッド — 作業部屋と、その中で働く作業員

概要 — まず全体をつかむ

中級では「分離=安全だが重い/共有=速いが危うい」という対を見ました。上級では、その 重さの正体(切り替えコスト)と 危うさの正体(可視性と同期)を、CPUとメモリの動きまで下りて追います。

詳細 — 1段階ずつ追う

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

プロセスが持つ「自分専用のメモリ」は、内部で役割ごとに区画が分かれています。

  • テキスト領域 — 機械語の命令そのもの。書き換え禁止で読むだけ
  • データ領域 — 初期値を持つグローバル変数など(プログラム開始時に確定)
  • ヒープ ※1 — 実行中に必要な分だけ確保する領域。低い番地から上へ伸びる
  • スタック ※2 — 関数呼び出しの記録(戻り先・局所変数)。高い番地から下へ伸びる

ヒープとスタックは互いに向き合って伸び、メモリ の空間を挟み込むように使います。スレッドはこのうち テキスト・データ・ヒープを共有 し、スタックだけを各自持つ ——ここが「速いが危うい」の出発点です。共有部分は受け渡しなしで触れて速い反面、同じ番地を複数のスレッドが同時にいじれてしまうのです。

登場人物メモ:

  • ※1 ヒープ — 実行中に動的に確保・解放するメモリ領域
  • ※2 スタック — 関数の呼び出しと戻りを積み下ろしで管理する領域
  • ※3 TLB — 仮想アドレスから物理アドレスへの変換表のキャッシュ
  • ※4 ミューテックス/セマフォ/条件変数 — 共有資源への出入りを制御する同期の道具
やさしく言うと(中級

同じ「同時に処理を進める」でも、プロセスとスレッドではメモリの扱いが正反対です。

  • プロセスは独立 — 互いにメモリが分離されています。他プロセスの領域は見えないので安全ですが、その分、切り替えや情報の受け渡しが重い
  • スレッドは共有 — 同一プロセス内では同じメモリを共有します。データの受け渡しが要らず速いが、同じ場所を同時にいじると競合が起きます。
アニメーション『プロセスとスレッド』を開く
プロセスA独立したメモリ空間スレッド1スレッド2スレッド3共有メモリ(ヒープ・グローバル変数など)↑ 同一プロセス内のスレッドが共有プロセスB独立したメモリ空間スレッド1スレッド2スレッド3共有メモリ(ヒープ・グローバル変数など)↑ 同一プロセス内のスレッドが共有隔離互いに見えないプロセスは独立(メモリを隔離)/スレッドは同一プロセス内でメモリを共有

つまり「分離=安全だが重い/共有=速いが危うい」という対の性質です。

登場人物メモ:

  • ※1 コンテキストスイッチ — CPUが処理対象を素早く切り替える動作。切り替えには手間(コスト)がかかる
  • ※2 排他制御 — 共有データを一度に1つだけが触れるようにする仕組み(ロックなど)

コンテキストスイッチの本当のコスト

アニメーション『プロセスとスレッド』を開く
プロセスA独立したメモリ空間スレッド1スレッド2スレッド3共有メモリ(ヒープ・グローバル変数など)↑ 同一プロセス内のスレッドが共有プロセスB独立したメモリ空間スレッド1スレッド2スレッド3共有メモリ(ヒープ・グローバル変数など)↑ 同一プロセス内のスレッドが共有隔離互いに見えないプロセスは独立(メモリを隔離)/スレッドは同一プロセス内でメモリを共有

CPUが処理対象を切り替えるとき、水面下では次が起きます。

  1. レジスタの退避 — 実行中のスレッドのCPUレジスタ(途中経過)をメモリへ保存する
  2. 次の状態の復元 — 切り替え先のレジスタを積み直す
  3. (プロセス切り替えなら)アドレス空間の交換 — ページテーブルを差し替える
  4. これに伴い TLB ※3 が無効化され、変換表を引き直すことになる
  5. さらに キャッシュが冷える — 前のプロセスのデータが追い出され、しばらく遅い

スレッド切り替えは1〜2で済みますが、プロセス切り替えは3〜5の 間接コスト が重い。だから「スレッドは軽い」と言われます。ただし、この直接的な保存時間よりも、キャッシュとTLBが温まり直すまでの見えない遅さの方が、しばしば大きいのです。

やさしく言うと(中級
  1. CPUは、一度に扱える処理の本数に限りがある
  2. そこでコンテキストスイッチ※1 で、処理対象を高速に切り替える
  3. 人間から見ると、多数の処理が同時に進んでいるように見える
  4. 切り替えの判断(誰に、いつCPUを渡すか)はOSのスケジューラが行う

この「素早く切り替えて同時に見せる」土台は、CPU の動き方そのものです。

スレッドモデルと同期プリミティブ

スレッドを誰が管理するか で、モデルが分かれます。

  • 1対1(カーネルスレッド) — 1つのユーザースレッドに、OSが管理する1つの実行単位を対応させる。OSが直接スケジューリングし、複数コアへ素直に散らせるが、生成と切り替えにカーネルを通すコストが乗る
  • M対N(ユーザースレッド) — 多数のユーザースレッドを、少数のカーネルスレッドに載せ替えて動かす。切り替えがユーザー空間内で完結して軽いが、実装が複雑で、1つがカーネル内でブロックすると巻き添えが出やすい

共有を安全に使うための道具が 同期プリミティブ ※4 です。

  • ミューテックス — 「一度に1人だけ」。鍵を持つ者だけが共有領域に入れる(排他)
  • セマフォ — 「同時に入れるのはN人まで」。数を数えて入場制限する
  • 条件変数 — 「ある条件が整うまで眠り、整ったら起こしてもらう」。無駄な繰り返し確認を避ける

デッドロックの4条件とメモリ可視性

デッドロック は、次の4つが すべて揃った ときだけ起きます。

  1. 相互排他 — その資源は同時に1つしか使えない
  2. 保持と待機 — 何かを持ったまま、別の何かを待つ
  3. 横取り不可 — 持っている資源を無理やり奪えない
  4. 循環待ち — AはBを、BはAを……と待ちの輪ができる

裏を返せば、どれか1つを崩せば防げる。「ロックを取る順番を全員で固定する(循環を作らせない)」がよく効くのは、この4番目を壊しているからです。

もう1つの危うさが メモリ可視性 です。あるスレッドが書いた値が、別のスレッドやコアから すぐ見えるとは限りません。各コアはキャッシュを持ち、CPUは命令を並べ替えて先へ進むためです(詳しくはCPUのメモリオーダリング)。だから「共有変数を書いた/読んだ」だけでは足りず、同期プリミティブやメモリバリアで順序と見え方を約束させる 必要があります。競合状態がやっかいなのは、たいてい正しく動いてしまい、ごく稀な巡り合わせでだけ壊れるからです。

やさしく言うと(中級

並行(concurrent)と並列(parallel)は違います。

  • 並行 — 1つのCPUが素早く切り替えて、複数の処理を代わる代わる進める(同時に見えるが、瞬間瞬間は1つ)
  • 並列 — 複数のCPUコアが、本当に同じ瞬間に別々の処理を実行する

なぜ共有メモリは便利で危険なのか。 スレッドが同じメモリを共有できると、データの受け渡しが速く便利です。しかし、2つのスレッドが同じデータを同時に読み書きすると、結果が実行の巡り合わせで変わってしまう——これが競合状態(レースコンディション)です。

防ぐには排他制御(ロック)※2 を使い、共有データを一度に1つのスレッドだけが触れるようにします。ただしロックの掛け方を誤ると、互いに相手のロックを待って止まるデッドロックを招くこともあります。速さと安全は、ここでも綱引きになります。

非同期・イベントループとの対比

「危うい共有」を避けるもう1つの道が、スレッドを増やさない 発想です。

アニメーション『イベントループ(単一メインスレッド)』を開く
単一メインスレッドとイベントループタスクキュー(順番待ちの行列)① クリック② タイマー③ 再描画(paint)④ 入力(キー)先に入ったものから 1つずつ③④ は処理されず待ち先頭を1つ渡すイベントループ空いたら次を取り出すメインスレッド(1本だけ)重いJSタスク実行中・長く居座る終わるまで離さない1本しかないのでこれが終わるまで次へ進めない再描画が止まる入力に反応しない画面が固まる描画も入力も反応しない(フリーズ)JSと描画は同じ1本のスレッドを奪い合う

イベントループは、単一のメインスレッド が「やることの列」を順に片づけ、時間のかかる入出力は裏へ投げて、終わった通知だけを受け取ります。共有メモリを複数スレッドで奪い合わないので、ロックが要らず、競合の悩みが根本から消えます。

  • マルチスレッド — 本当に同時に計算できる(CPUをたくさん使う仕事に強い)。ただし共有と同期の難しさを背負う
  • イベントループ — 待ちの多い仕事(通信・ファイル)を1スレッドで捌く。単純で安全だが、重い計算でメインを塞ぐと全部止まる

どちらが上ではなく、仕事が「計算で詰まる」か「待ちで詰まる」か で選びます。多くの実システムは、両者を組み合わせて使います。

⚠️ 深部の落とし穴

  • 偽共有(false sharing) — 別々の変数が同じキャッシュ行に同居し、片方を書くと他方も無効化されて無用に遅くなる
  • ロック順序の不一致 — 取る順番がスレッドごとに違うと、循環待ち=デッドロックへ
  • 可視性の見落とし — 同期なしの共有変数は、別コアから古い値に見えることがある
  • スレッド過多 — 増やすほどコンテキストスイッチとメモリ(各自のスタック)がかさみ、かえって遅くなる
  • メインスレッド専有 — イベントループで重い計算を回すと、待ちの列全体が固まる
やさしく言うと(中級
  • 競合状態 — 共有データの同時書き換えで、結果が不定になる
  • デッドロック — 複数スレッドが互いのロックを待ち続けて止まる
  • 切り替えコスト過多 — スレッドを増やしすぎ、コンテキストスイッチばかりで遅くなる
  • 1プロセスの巻き添え — 共有メモリゆえ、あるスレッドの暴走が同プロセス全体を壊す

なお、プロセスごとにメモリを分離するこの隔離の考え方は、Linux のコンテナが「プロセスを箱に閉じ込めて独立させる」土台にもつながっています。独立と共有の使い分けは、OSの根っこにある設計思想です。

関連する知識

理解度チェック

そのまま解けます(成績は保存されません)。無料アカウントを作ると、学習の記録と進捗の山登りが始まります。

1. プロセスの切り替えが、同一プロセス内のスレッド切り替えより重くなりやすいのはなぜ?

2. デッドロックの成立に必要な4条件に含まれるのはどれ?

3. 同一プロセス内のスレッドどうしが「各自別々に持つ」のはどれ?

4. 「同時に入れるのはN人まで」と数を数えて入場制限する同期の道具はどれ?

5. あるスレッドが共有変数に書いた値が、別コアのスレッドからすぐ見えるとは限らない。主な理由は?

6. 待ちの多い入出力中心の仕事に、イベントループ(単一メインスレッド)が向く主な理由は?

7. 別々の変数がたまたま同じキャッシュ行に同居し、片方を書くと他方も無効化されて無用に遅くなる現象を何という?

8. 多数のユーザースレッドを、少数のカーネルスレッドに載せ替えて動かすスレッドモデルを「◯対◯」の形で何という?

プロセスとスレッド — 作業部屋と、その中で働く作業員 | Kotowary