POSIXスレッドとカーネルスレッドのマッピングモデル
なぜLinuxのスレッドは1:1なのか。N:1やM:Nが理論上は美しいのに実装が廃れた理由を、スケジューラ・シグナル・プリエンプションの実装制約から腑に落とせます。
- 1.スレッドモデルとはユーザスレッドとカーネルスレッドの対応付けで、1:1・N:1・M:Nの3種があり、それぞれスケジューリングと並列性のトレードオフが異なる。
- 2.LinuxのNPTLは1:1を採用。clone(CLONE_THREAD)でカーネルが各スレッドを直接スケジュールし、SMP並列・正しいシグナル・ブロッキングシステムコールの単純さを優先した結果である。
- 3.M:Nはユーザスケジューラとカーネルスケジューラの二重管理がシグナル・プリエンプション・ブロッキングと衝突し複雑化したため、本流の汎用OSでは退いた。
マッピングモデルとは──2層のスケジューリング対象
スレッドには2つの層があります。アプリケーションが pthread_create で作る ユーザスレッド(実行の論理単位)と、CPU に実際に載せる対象としてカーネルが認識する カーネルスケジューリングエンティティ(Linux では task)です。マッピングモデルとは、この2層を 何対何で対応付けるか の設計です。
なぜ層が分かれるかというと、CPU に実行単位を割り当てる権限はカーネルしか持たないからです(コンテキストスイッチ はカーネルが行う)。ユーザ空間が「100個のスレッド」を持っていても、カーネルがそれを1個としか見ていなければ、同時に走れる CPU は1個です。逆にカーネルが100個と見ていれば、コア数まで本当に並列に走れます。この対応関係が並列性・コスト・制御性をすべて決めます。
3つのモデルと設計トレードオフ
| 観点 | 1:1(カーネルレベル) | N:1(ユーザレベル) | M:N(ハイブリッド) |
|---|---|---|---|
| 対応 | ユーザ1 ↔ カーネル1 | ユーザN ↔ カーネル1 | ユーザM ↔ カーネルN(M大なりN) |
| スケジューラ | カーネルのみ | ユーザ空間ランタイムのみ | 二段(ユーザ+カーネル) |
| SMP並列 | 可(コア数まで真に並列) | 不可(常に1コア) | 可(N本まで並列) |
| 生成/切替コスト | 中(システムコールを伴う) | 極小(関数呼び出し並み) | 切替は小、管理は複雑 |
| ブロッキングsyscall | そのスレッドだけ眠る | プロセス全体が眠る | 対策が要る(後述) |
| 代表例 | Linux NPTL, Windows | 初期green thread, 一部VM | Solaris旧来, FreeBSD旧KSE |
N:1──軽いが並列化できず、1本のブロックで全滅する
N:1 は複数のユーザスレッドを1個のカーネルスレッドに載せます。切替はユーザ空間ランタイム内のレジスタ退避だけで済むため 極端に軽い(モード遷移すら不要)。しかし致命的な弱点が2つあります。第一に、カーネルからは1スレッドにしか見えないので マルチコアでも並列に走れません。第二に、あるスレッドがブロッキングシステムコール(例:ディスク read)を呼ぶと、カーネルは「このカーネルスレッドが眠る」としか判断できず、同居する全ユーザスレッドが道連れに止まります。これを避けるには全 I/O を非ブロッキング化してランタイムが肩代わりする必要があり、純粋な N:1 は汎用スレッドライブラリとしては破綻します。
M:N──理論的には最良、しかし二重管理が破綻する
M:N は「軽い切替(N:1 の利点)」と「真の並列(1:1 の利点)」を両取りしようとする設計です。M 個のユーザスレッドを N 個のカーネルスレッド上で、ユーザ空間スケジューラが多重化します。理屈は美しいのですが、スケジューラが2つ存在すること が実装上のあらゆる困難の源になります(次節)。
なぜLinux(NPTL)は1:1を選んだのか
Linux の現行スレッド実装 NPTL(Native POSIX Thread Library) は 1:1 です。pthread_create は内部で clone を CLONE_VM | CLONE_FILES | CLONE_THREAD | CLONE_SIGHAND 等のフラグ付きで呼び、アドレス空間・ファイルディスクリプタ・シグナル配送を共有する task を1つ作ります。生成された task は通常のプロセスと同じく CFSスケジューラ が直接スケジュールします。つまりカーネルにとってスレッドとプロセスは「どのリソースを共有するか」が違うだけの同じ実体です。1:1 を採った理由は、M:N の欠点の裏返しとして整理できます。
- SMP並列が自明に得られる:各スレッドが独立した task なので、カーネルがそのままコアへ分散する。ユーザ空間で並列スケジューリングを再発明しなくてよい。
- ブロッキングシステムコールが単純:あるスレッドが眠っても、それは1個の task が眠るだけ。他スレッドは別 task として走り続ける。I/O を非ブロッキング化する義務がない。
- シグナルとプリエンプションがカーネルの責任で完結:タイマ割り込みによる強制切替(プリエンプション)も、特定スレッドへのシグナル配送も、カーネルが task 単位で正しく扱える。
- 同期プリミティブが効率的:futex は競合しない限りユーザ空間で完結し、本当に眠る/起こすときだけカーネルへ落ちる。1:1 の task と futex の組み合わせで、軽い生成・切替コストの不利を実用上ほぼ埋められた。
M:Nが破綻した本質──スケジューラの二重化
M:N が汎用 OS の本流から退いた核心は、ユーザ空間スケジューラとカーネルスケジューラが同じ事実について別々の判断をしてしまう ことです。具体的な衝突点を挙げます。
- ブロッキングの可視化問題:ユーザスレッドがブロッキングシステムコールに入ると、それを載せていたカーネルスレッドごと眠ります。すると本来そのカーネルスレッド上で走れたはずの他のユーザスレッドまで止まります。これを避けるため、Solaris の旧実装や FreeBSD の KSE では、カーネルがブロック発生をユーザ空間へ通知し、ランタイムが新しいカーネルスレッドを補充する scheduler activations という仕組みが必要でした。この上向き通知(アップコール)の機構自体が複雑で壊れやすい。
- プリエンプションの二段:カーネルがカーネルスレッドをプリエンプトしても、その上で多重化されているどのユーザスレッドが止まったのかをユーザ空間が把握しないと、ユーザ空間スケジューラの状態が実態とずれます。
- シグナル配送の曖昧さ:「特定の pthread にシグナルを送る」という POSIX 要件を満たすには、対象ユーザスレッドが今どのカーネルスレッド上にいるかをカーネルが知る必要がある。M:N ではその対応が動的に変わり、配送ロジックが複雑化します。
- 同期との相性:ミューテックスやfutex は「眠っている待ち手を起こす」前提だが、待ち手がユーザ空間スケジューラに退避されていると、カーネルから直接起こせない。
M:N の動機は切替コストの削減でしたが、実測では二段スケジューリングの調整オーバーヘッド・キャッシュ局所性の悪化・複雑なロックがそれを相殺しがちでした。一方で 1:1 側は futex とスレッド生成の最適化(NPTL)でコスト差を縮め、結果として「単純で速い 1:1」が「複雑で理論上軽い M:N」に競争で勝った、というのが歴史的な決着です。
なお M:N が常に劣るわけではありません。協調的スケジューリング と相性が良いユーザレベル並行(Go の goroutine、Java の仮想スレッド、各種コルーチン)は、実質 M:N 的な多重化を 言語ランタイムが 担います。ランタイムが I/O を全面的に非ブロッキング化しブロック点を自分で管理できるため、汎用 OS スレッドライブラリでは破綻した前提を回避できるのです。
スレッドローカルストレージ(TLS)の管理
各スレッドが「自分専用のグローバル変数」を持つ仕組みが スレッドローカルストレージ(TLS) です。1:1 では各 task が固有のレジスタを持つため、TLS の基点を 専用レジスタ に置けます。x86-64 では fs セグメントベース(%fs:0 が現スレッドの TLS ブロック先頭、Thread Control Block を指す)、AArch64 では TPIDR_EL0 レジスタです。
C の __thread(C11 の _Thread_local)変数は、コンパイラとリンカが TLS セグメント にレイアウトし、アクセスは「fs ベース+固定オフセット」に展開されます。スレッド生成時、ランタイムは初期イメージ(.tdata/.tbss)から各スレッドの TLS ブロックを複製し、新 task の fs ベースを clone のフラグ(CLONE_SETTLS)でカーネルに設定させます。コンテキストスイッチで task が切り替わると fs ベースも切り替わるので、同じ命令・同じオフセットが自動的に別スレッドの変数を指す ──これが TLS の本質です。
__thread/_Thread_local は 静的TLS。レジスタ相対の1命令でアクセスでき高速ですが、レイアウトがロード時に確定するため dlopen した共有ライブラリの追加 TLS は 動的TLS(dynamic TLS) 扱いになり、__tls_get_addr 経由のやや重い解決になります。一方 pthread_key_create/pthread_getspecific は API ベースで、ポインタ1個をキーごとに格納するだけ。デストラクタを登録できる利点があり、移植性が高い反面アクセスは関数呼び出しぶん重くなります。
スタックの管理──固有資源としてのスタック
スレッドが共有しないものの代表が スタック です。同じプロセス内でも各スレッドは独立した呼び出し履歴・ローカル変数を持つため、スレッドごとに別領域のスタックが必要です。1:1 ではスレッド生成時にライブラリが mmap でスタック領域を確保し、新 task のスタックポインタをそこへ向けます。
- 既定サイズ:Linux/glibc では多くの環境で1スレッドあたり既定 8MiB。ただしこれは 仮想アドレス予約 であり、実際の物理ページは触れたぶんだけ デマンドページング で割り当てられます。だから 8MiB × 数千スレッドでも即座に物理メモリを食い尽くすわけではありません。
- ガードページ:スタック末尾に1ページ以上の アクセス不可(PROT_NONE)領域 を置き、スタックオーバーフローが隣接領域を黙って破壊する前にフォールトで検出します。
- スレッド数の現実的上限:仮想アドレス空間(64bit でも実装上の上限)と既定スタックサイズが、生成できるスレッド数の上限を実質決めます。大量スレッドが要るなら
pthread_attr_setstacksizeでスタックを小さく取るか、そもそもスレッドを増やさず スレッドプール や非同期 I/O で固定数を使い回す設計が定石です。
- マッピングモデルは ユーザスレッド対カーネルスケジューリングエンティティ の比。1:1/N:1/M:N を SMP並列・ブロッキング挙動・切替コストで対比できること。
- Linux は NPTLで1:1。
pthread_createはclone(CLONE_THREAD ...)、スケジュールは CFS が task 単位で行う。 - M:N が廃れた理由は 二重スケジューリング(ブロッキングの可視化・シグナル・プリエンプションの衝突)。scheduler activations の複雑さがコストに見合わなかった。
- TLS は x86-64 で
%fsベース+オフセット、スタックは各スレッド固有・既定8MiB・ガードページ付き。
スレッドモデルの選択は「理論上の美しさ」ではなく「OS の他の構成要素(スケジューラ・シグナル・システムコール・同期)とどれだけ素直に噛み合うか」で決まりました。プロセスとスレッドの基礎は プロセスとスレッド を、生成の内部実装は fork/clone/exec を参照してください。
OS Article
POSIXスレッドとカーネルスレッドのマッピングモデルを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
pthread
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
LinuxのNPTLは1:1を採用。clone(CLONE_THREAD)でカーネルが各スレッドを直接スケジュールし、SMP並列・正しいシグナル・ブロッキングシステムコールの単純さを優先した結果である。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「pthread / スレッド」に近いか確認する。
- 強みである「スレッドモデルとはユーザスレッドとカーネルスレッドの対応付けで、1:1・N:1・M:Nの3種があり、それぞれスケジューリングと並列性のトレードオフが異なる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。