プロセスの中にもう一段、実行の単位がある

第3章では、forkでプロセスを複製し、execで別のプログラムに置き換え、waitで回収するというプロセスの一生を追いました。 forkした子は親とは別のアドレス空間を持つので、子が変数を書き換えても親には見えません。安全ですが、同じデータを2つのプロセスで扱いたいときは、パイプやソケットでコピーして渡すしかありません。

1つのプログラムの中で処理を同時に進めたいけれど、メモリはそのまま共有したい。この要求に応えるのがスレッドです。 この章では、Linuxがプロセスとスレッドをどう区別しているか(実はほとんど区別していません)を確認し、スレッドを切り替えるコスト、並行と並列の違い、そして誰をいつCPUに載せるかを決めるスケジューラを見ていきます。

プロセスとスレッドの違いは「何を共有するか」

教科書では「プロセスは資源の単位、スレッドは実行の単位」と説明されます。 同じプロセス内のスレッドは、アドレス空間(コード・ヒープ・グローバル変数)、ファイルディスクリプタのテーブル、シグナルハンドラを共有します。 一方でレジスタの値、プログラムカウンタ、スタックはスレッドごとに別々に持ちます。どこまで実行したか、どの関数の中にいるかはスレッドごとに違うからです。

clone()のフラグで共有範囲を選ぶ

Linuxでこの「何を共有するか」を決めているのがclone()システムコールです。 fork()も、glibcのpthread_create()も、内部ではclone()(新しいカーネルではclone3())を呼んでいます。違いは渡すフラグだけです。

フラグ意味fork相当pthread_create相当コンテナの初期プロセス
CLONE_VMアドレス空間を共有するなし(CoWで複製)ありなし
CLONE_FILESFDテーブルを共有するなし(複製)ありなし
CLONE_SIGHANDシグナルハンドラを共有するなしありなし
CLONE_THREAD同じスレッドグループに入る(同じPIDに見える)なしありなし
CLONE_NEW*PID・ネットワーク・マウントなどのnamespaceを新しく作るなしなしあり

フラグを全部立てればスレッドになり、何も共有しなければfork相当の新しいプロセスになります。 さらにCLONE_NEWPIDやCLONE_NEWNETを付けると、PID番号やネットワークが見える範囲まで切り離された「コンテナの中のプロセス」になります。 スレッドとコンテナは別物に見えますが、カーネルから見ると同じclone()の引数違いにすぎません。namespaceの詳細は第11章で扱います。

task_structとPID・TID

カーネル内部では、プロセスもスレッドも同じtask_structという構造体で表されます(include/linux/sched.h)。 スケジューラがCPUに載せる単位はこのtaskで、Linuxのカーネルにはプロセス専用の構造体がありません。

task_structにはpidとtgid(スレッドグループID)の2つの番号があります。 ユーザー空間で「プロセスID」と呼んでいるのはtgidのほうで、getpid()は同じプロセスのどのスレッドから呼んでも同じtgidを返します。 スレッドを1本ずつ区別したいときはgettid()を呼びます。メインスレッドではpidとtgidが一致します。

実物を見てみます。Linuxコンテナで何もしないNode.jsを起動し、/proc/<pid>/taskを数えると、手元では7本のスレッドが見えました。

$ node -e "setTimeout(() => {}, 2000)" &
$ ls /proc/$!/task | wc -l
7
$ cat /proc/$!/task/*/comm | sort | uniq -c
      1 DelayedTaskSche
      1 MainThread
      1 SignalInspector
      4 V8Worker
$ grep -E "^(Tgid|Pid|Threads)" /proc/$!/status
Tgid:	8
Pid:	8
Threads:	7

JavaScriptを実行しているのはMainThreadの1本だけで、V8WorkerはGCやコンパイルを裏で手伝うV8のスレッドです。 ファイル読み込みなどに使うlibuvのスレッドプールは、初めて使われたときに遅れて作られるため、この時点ではまだいません。 「Node.jsはシングルスレッド」という言葉は、JavaScriptを実行するスレッドが1本という意味です。プロセスとしては最初から複数のスレッドを持っています。

コンテキストスイッチ — 切り替えのたびに払うコスト

CPUのコア数よりスレッドの数のほうがずっと多いのが普通です。そこでOSは、実行中のスレッドを止めて別のスレッドにCPUを渡す作業を繰り返します。これがコンテキストスイッチです。

切り替えの中で起きていること

graph LR
  A["スレッドAが実行中"] --> B["タイマー割り込み<br/>またはI/O待ちで停止"]
  B --> C["カーネルがAの<br/>レジスタを保存"]
  C --> D["スケジューラが<br/>次のタスクBを選ぶ"]
  D --> E{"Bは別プロセス?"}
  E -->|はい| F["ページテーブルを切り替え"]
  E -->|いいえ| G["アドレス空間はそのまま"]
  F --> H["Bのレジスタを復元して再開"]
  G --> H

  style A fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style H fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style F fill:#f97316,stroke:#ea580c,color:#fff
コンテキストスイッチの流れ。同じプロセス内のスレッド同士なら、ページテーブルの切り替えが要らない

切り替えのきっかけは2種類あります。1つは、スレッドがI/Oやロックを待つために自分からCPUを手放す自発的なスイッチです。 もう1つは、割り当てられた時間を使い切ったスレッドからカーネルがCPUを取り上げる非自発的なスイッチ(プリエンプション)です。 第2章で見たタイマー割り込みがあるので、無限ループするスレッドがいてもOSは制御を取り戻せます。

別プロセスに切り替えるときはページテーブルも切り替わります。次章で扱うTLB(アドレス変換のキャッシュ)の中身が使えなくなるため、同じプロセス内のスレッド同士の切り替えより重くなります。

1回あたり1.2〜2.2µs

Eli Benderskyの2018年の測定では、Linuxのスレッド間コンテキストスイッチは1回あたり約1.2〜2.2µsでした。 同じ記事で、Goのgoroutineの切り替えは約170nsと1桁小さい値が出ています。goroutineはカーネルに入らず、Goのランタイムがユーザー空間で切り替えるからです(第8章で詳しく比較します)。

数µsなら小さく見えますが、これは直接コストだけです。切り替わった先のスレッドは別のデータを触るので、CPUシリーズ第4章で見たキャッシュの中身が入れ替わり、しばらくはキャッシュミスが続きます。 1万本のスレッドが頻繁に切り替わるサーバーでは、この間接コストが積み重なってスループットが下がります。第7章のC10K問題につながる話です。

スタック8MiBは「予約」にすぎない

glibcのpthreadは、スレッドごとに既定で8MiBのスタックを用意します(ulimit -sの8192KiB)。1万スレッドなら80GiB必要に思えますが、実際に1万スレッドを立てるとVSZ(仮想メモリの大きさ)は約80GiB、RSS(実メモリの使用量)は約80MiB程度に収まります。 8MiBは仮想アドレスの範囲を予約しただけで、物理メモリが割り当てられるのはスレッドが実際に触ったページだけだからです。 この「予約と実体のずれ」は次章の仮想メモリの中心的なテーマです。

並行と並列は別の言葉

並行(concurrency)は、複数の処理が「進行中」である状態を指します。1コアでも、Aを少し進めてBに切り替え、またAに戻れば、2つの処理は並行に進んでいます。 並列(parallelism)は、複数の処理が「同じ瞬間に」実行されている状態で、物理的に複数のコアが必要です。

graph TB
  subgraph CON["並行: 1コアで交互に進める"]
    direction LR
    c1["A"] --> c2["B"] --> c3["A"] --> c4["B"] --> c5["A"]
  end
  subgraph PAR["並列: 2コアで同時に進める"]
    direction LR
    p0["コア0: A A A A A"]
    p1["コア1: B B B B B"]
  end
  CON ~~~ PAR

  style c1 fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style c3 fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style c5 fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style c2 fill:#f97316,stroke:#ea580c,color:#fff
  style c4 fill:#f97316,stroke:#ea580c,color:#fff
  style p0 fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style p1 fill:#f97316,stroke:#ea580c,color:#fff
並行は「切り替えながら同時に進行中」、並列は「物理的に同時に実行」。並列なら必ず並行だが、逆は成り立たない

Node.jsのイベントループは、JavaScriptを1本のスレッドで動かしながら大量の接続を並行にさばきます。待ち時間の多いWebサーバーでは、並列でなくても並行であれば十分に速く動きます。 一方、画像の変換や暗号処理のようにCPUを使い続ける処理は、並行にしても速くなりません。複数のコアで並列に動かす必要があり、ここでworker_threadsや複数プロセスが登場します。 どちらが必要かは「待ちが多いか、計算が多いか」で決まります。

スケジューラ — 誰をいつCPUに載せるか

実行可能なタスク(psの状態がRのもの)がコア数より多いとき、どれを次に動かすかを決めるのがスケジューラです。 考え方の基本はタイムシェアリングです。1961年のCTSS以来、OSはCPU時間を短い時間片(タイムスライス)に区切って順番に配り、全員が少しずつ進んでいるように見せてきました。

CTSS

MITのタイムシェアリングシステム。CPU時間を細かく区切って複数ユーザーに配る発想の出発点

O(1)スケジューラ

Linux 2.5系に導入。タスク数によらず一定時間で次のタスクを選べる

NPTL

スレッドを1対1でカーネルのtaskに対応させるLinuxの新しいpthread実装

CFS(Linux 2.6.23)

vruntimeを赤黒木で管理し、最も実行時間の少ないタスクを選ぶ「完全に公平な」スケジューラ

EEVDF(Linux 6.6)

lagとvirtual deadlineで、公平さに加えてレイテンシの要求も扱う

sched_ext

BPFでスケジューラを書けるようにする仕組み。Linux 6.12で本体に入り、7.3でサブスケジューラが完成

CFS — 一番遅れているタスクを選ぶ

2007年のLinux 2.6.23から長く使われたCFS(Completely Fair Scheduler)は、タスクごとにvruntime(仮想実行時間)という値を持たせます。 タスクがCPUを使うと、その時間がvruntimeに加算されます。スケジューラはvruntimeが最も小さいタスク、つまり今までいちばんCPUをもらえていないタスクを次に選びます。

最小値をすばやく取り出すために、実行可能なタスクはvruntimeをキーにした赤黒木(平衡二分探索木の一種)に並べられます。木のいちばん左のノードが次に走るタスクです。

graph TD
  R["vruntime 50"] --> L["vruntime 30"]
  R --> RR["vruntime 70"]
  L --> LL["vruntime 10<br/>最左ノード = 次に実行"]
  L --> LR["vruntime 40"]
  RR --> RL["vruntime 60"]
  RR --> R2["vruntime 90"]

  style LL fill:#8b5cf6,stroke:#6d28d9,color:#fff
CFSの実行キューのイメージ。vruntimeが小さいタスクほど左に並び、最左のタスクがCPUをもらう

I/O待ちで眠っていたタスクは、その間vruntimeが増えないので、起きたときに木の左側に入り、すぐにCPUをもらえます。 CPUを使い続けるバッチ処理より、キー入力やネットワークの応答を待つ対話的なタスクが優先されやすいのはこのためです。

EEVDF — 公平さに締め切りを加える

CFSは公平さには強い一方で、「このタスクはCPUを少ししか使わないが、待たせずにすぐ動かしてほしい」というレイテンシの要求を表す手段がありませんでした。 そこで2023年のLinux 6.6から、スケジューラはEEVDF(Earliest Eligible Virtual Deadline First)に置き換えられました。

EEVDFはタスクごとに2つの値を見ます。1つ目のlagは「公平に配っていれば受け取れたはずのCPU時間」と「実際に受け取った時間」の差です。lagが0以上、つまりもらい足りていないタスクだけが選ばれる資格(eligible)を持ちます。 2つ目のvirtual deadlineは、そのタスクが要求するタイムスライスを使い終えるべき仮想的な締め切りです。eligibleなタスクの中から、締め切りが最も早いものを選びます。 短いスライスを要求するタスクほど締め切りが早く来るので、早く選ばれます。公平さを保ったまま、レイテンシの必要なタスクを先に動かせるようになりました。

nice値で取り分を変える

ユーザーがスケジューラに影響を与える一番身近な手段はnice値です。-20(最優先)から19(最も譲る)まであり、既定は0です。 nice値は重みに変換され、1段階違うとCPUの取り分がおよそ10%変わるように設計されています。 シェルならnice -n 10 node batch.js、Node.jsならos.setPriority()で設定できます。夜間バッチのnice値を上げておくと、同じマシンで動くWebサーバーの応答を邪魔しにくくなります。

TypeScriptで確かめる — コア数ぶんのWorkerで並列計算

並列でどれだけ速くなるかを測ってみます。2000万未満の素数を数える処理をN個に分け、N本のWorkerで同時に計算します。 Node.js 24以降は型注釈を取り除いて.tsをそのまま実行できるので、node threads.tsで動きます。

import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
import os from "node:os";

// 範囲 [from, to) の素数を数える(わざと重いCPU処理)
function countPrimes(from: number, to: number): number {
  let count = 0;
  for (let n = Math.max(2, from); n < to; n++) {
    let isPrime = true;
    for (let d = 2; d * d <= n; d++) {
      if (n % d === 0) { isPrime = false; break; }
    }
    if (isPrime) count++;
  }
  return count;
}

if (!isMainThread) {
  // Worker 側: 受け取った範囲を計算して返す
  const { from, to } = workerData as { from: number; to: number };
  parentPort!.postMessage(countPrimes(from, to));
} else {
  const LIMIT = 20_000_000;

  async function run(threads: number): Promise<void> {
    const start = performance.now();
    const chunk = Math.ceil(LIMIT / threads);
    const jobs = Array.from({ length: threads }, (_, i) =>
      new Promise<number>((resolve, reject) => {
        // 同じファイルを Worker として起動する
        const w = new Worker(new URL(import.meta.url), {
          workerData: { from: i * chunk, to: Math.min(LIMIT, (i + 1) * chunk) },
        });
        w.once("message", resolve);
        w.once("error", reject);
      }),
    );
    const total = (await Promise.all(jobs)).reduce((a, b) => a + b, 0);
    const ms = (performance.now() - start).toFixed(0);
    console.log("threads=" + threads + " primes=" + total + " " + ms + "ms");
  }

  console.log("availableParallelism =", os.availableParallelism());
  for (const n of [1, 2, 4, os.availableParallelism(), os.availableParallelism() * 2]) {
    await run(n);
  }
  // 自発的/非自発的コンテキストスイッチの回数
  const ru = process.resourceUsage();
  console.log("voluntary:", ru.voluntaryContextSwitches, "involuntary:", ru.involuntaryContextSwitches);
}

os.availableParallelism()は、このプロセスが使えるおおよその並列度(多くの環境では論理CPU数)を返します。手元のM3 Pro(11コア)での結果は次のとおりです。

Worker数所要時間1本のときとの比
15,646ms1.0倍
23,523ms約1.6倍
41,917ms約2.9倍
11(availableParallelism)1,063ms約5.3倍
22(コア数の2倍)1,036ms約5.4倍

11本までは速くなりますが、コア数を超えて22本にしてもほとんど変わりません。同時に実行できるのはコアの数までで、それ以上のスレッドはスケジューラが交互に載せるだけだからです。 11本で11倍にならないのは、大きい数ほど素数判定が重く、後ろの範囲を担当したWorkerが最後まで残るためです。M3 Proには高性能コアと高効率コアが混在していることも影響しています。 最後に表示されるinvoluntaryの回数は、タイムスライスを使い切ってCPUを取り上げられた回数の目安です(macOSとLinuxで数え方が異なります)。

まとめ — スレッドは共有の度合いで決まる

Linuxでは、プロセスもスレッドもtask_structで表され、clone()のフラグでどこまで共有するかを選びます。アドレス空間とFDテーブルを共有すればスレッド、何も共有しなければプロセス、namespaceまで分ければコンテナです。 スレッドの切り替えには1回あたり数µsと、キャッシュが入れ替わる間接コストがかかります。スケジューラはCFSのvruntimeからEEVDFのlagとvirtual deadlineへ移り、公平さとレイテンシの両方を扱うようになりました。 CPUを使い続ける処理を速くしたいなら並列が必要で、その上限はコア数です。

この章では、8MiBのスタックを予約したスレッドが実メモリを数KiB程度しか使わない理由を後回しにしました。次章(第5章)では、この「予約と実体のずれ」を生む仮想メモリを扱い、RSSとVSZ、ページフォールト、OOM Killer、そしてNode.jsのheapUsedとRSSの違いを見ていきます。

理解度チェック

問題 0 / 50%
Q1

Linuxのマルチスレッドプログラムで、2本目のスレッドから getpid() を呼んだときに返る値はどれですか?

キーボード: 1〜4 で選択、Enter で回答