スレッドプールと作業窃取スケジューラ
スレッドを使い回し、暇なワーカーが他人のタスクを横取りする仕組みが分かれば、コア数に張り付く高並行コードが書ける。タスク粒度・プールサイジング・両端キューの原理を解説します。
- 1.スレッドプールはスレッド生成コストを償却するため、固定数のワーカーでタスクキューを回す。CPUバウンドなら『コア数前後』、I/Oバウンドなら待ち時間に応じて多めが基本。
- 2.作業窃取(ワークスティーリング)は各ワーカーが両端キュー(deque)を持ち、自分はLIFOで底から、暇な他人はFIFOで先頭から奪う。これで局所性と負荷分散を両立する。
- 3.Fork/JoinやTokioが採用。LIFOで生成直後の熱いタスク(キャッシュに乗っている)を自分が処理し、古い大粒タスクを盗ませるのが速さの鍵。タスク粒度が細かすぎても粗すぎても損する。
なぜプールで使い回すのか
OSスレッドの生成・破棄は安くありません。カーネルがスタック領域(既定で数MB規模を予約。多くは遅延コミット)を確保し、スケジューラの管理構造を作り、破棄時に回収します。タスクごとに一つスレッドを立てれば、仕事そのものより準備と後始末が重くなる逆転が起きます。さらにスレッド数がコア数を大きく超えると、CPUがレジスタとキャッシュ状態を入れ替えるコンテキストスイッチが頻発し、実効スループットが落ちます。
スレッドプールは、少数の長命なワーカースレッドを最初に作って使い回すことでこの生成コストを償却します。仕事は「スレッド」ではなく**タスク(処理の単位)**として投入し、空いたワーカーが順に拾って実行します。これにより並行度をハードウェアに合わせて頭打ちさせつつ、タスク投入のコストをキューへの追加だけに抑えられます。並行設計全体の地図は並行性モデル(CSP・アクター・STM)に整理してあります。
[タスク投入] → [タスクキュー] → ワーカー1 (実行中)
→ ワーカー2 (実行中)
→ ワーカーN (アイドル→次を取得)
# スレッドは N 個のまま固定。タスクだけが流れていく
タスク粒度:細かすぎても粗すぎても損
タスク粒度(granularity)とは、1タスクが担う仕事量のことです。これは性能を左右する第一の設計変数です。
- 粒度が細かすぎる:1タスクの仕事に対してスケジューリングのオーバーヘッド(キュー操作、同期、ワーカーの起床)が相対的に大きくなり、管理コストが本体を食う。1要素ごとにタスク化するような分割がこれにあたります。
- 粒度が粗すぎる:タスク数がワーカー数を下回ると、遊ぶワーカーが出て並列度を使い切れない。最後の1個の巨大タスクを待つ間、他コアが空転します(テールレイテンシの悪化)。
経験則として、タスク数はワーカー数より十分多く保ち、かつ1タスクがスケジューリングコストを十分上回る仕事量を持つように刻むのが要諦です。Fork/Join型の分割では、ある閾値(cutoff)まで再帰分割し、それ未満は逐次処理に切り替える「分割をやめる境界」を設けるのが定石です。
配列を再帰的に半分ずつ分けて並列処理するとき、要素数がしきい値(例えば数千要素)を下回ったら分割をやめて逐次ループに落とすのが鉄則です。葉のタスクが小さすぎるとフォークの同期コストが支配的になります。「これ以上分けても並列化の利得より管理費が勝つ」境界を計測で探すのが実務の勘所です。
プールのサイジング:CPUバウンドかI/Oバウンドか
最適なワーカー数は、タスクが何で時間を使うかで決まります。
| 種類 | 時間の使い道 | 目安のワーカー数 | 理由 |
|---|---|---|---|
| CPUバウンド | 計算で常にコアを使う | 物理コア数前後 | それ以上はスイッチ損で逆効果 |
| I/Oバウンド | 待ち(ディスク・通信)が大半 | コア数より多め | 待つ間に別タスクを走らせる |
| 混在 | 計算と待ちが交互 | 計測して調整 | 下の式で待機比から概算 |
CPUバウンドな処理は、コアが常に計算で埋まるため、ワーカー数を物理コア数前後にするのが上限です。これを超えると、追加スレッドは実行権を奪い合うだけでスループットは増えず、コンテキストスイッチとキャッシュ汚染でむしろ落ちます。
I/Oバウンドな処理は逆で、ワーカーが応答待ちでブロックしている間、そのコアは遊んでいます。待ち時間の分だけスレッドを増やせば、空いたコアで別タスクを進められます。古典的な見積もりが次の関係です。
ワーカー数 ≒ コア数 × (1 + 待機時間 / 計算時間)
# 計算時間に対して待機が長いほど、多くのスレッドが報われる
# 例: 計算1・待機3 → コア数 × 4 が目安
作業窃取プールのワーカーは少数(多くはコア数)に固定されています。ここでブロッキングI/Oやスリープを行うと、そのワーカーが丸ごと止まり、キューに積まれた他タスクが進みません。Tokioが「ブロッキング処理は専用プール(spawn_blocking)へ隔離せよ」と強く求めるのはこのためです。計算用ランタイムのワーカーをブロックさせると、わずか数本のスレッドが詰まって全体がスタベーションに陥ります。
作業窃取の核心:ワーカーごとの両端キュー
単一の共有キューをN個のワーカーで奪い合うと、そのキューのロックが競合の一点になります。コア数が増えるほど、全員が同じロックを叩いてキャッシュ行を行き来させ、スケールしません。
作業窃取(ワークスティーリング)は、この一点集中を分散で解きます。各ワーカーが**自分専用の両端キュー(double-ended queue, deque)**を持ち、原則として自分のdequeだけを操作します。鍵は、自分と泥棒がdequeの反対側の端から触ることです。
| 操作主体 | 触る端 | 順序 | ねらい |
|---|---|---|---|
| 自分(オーナー) | 底(bottom) | LIFO(後入れ先出し) | 直近のタスク=キャッシュが熱い |
| 他人(泥棒) | 先頭(top) | FIFO(古い順) | 大粒で独立した古いタスクを奪う |
オーナーはタスクを底にpushし、底からpopします(LIFO)。泥棒は他人のdequeの先頭からstealします(FIFO)。両者が逆端を触るため、普段の自分の push/pop は他ワーカーと競合せず、ほぼロックなしで進みます。窃取が実際に起きる稀なときだけ、底と先頭が出会う境界で不可分操作(CASなど)による調停が必要になります。
ワーカーAのdeque: top → [t1][t2][t3][t4] ← bottom
↑泥棒はここから奪う ↑自分はここで push/pop
# 自分: t4 を pop(一番新しい=キャッシュに乗っている)
# 泥棒B: t1 を steal(一番古い=Aがしばらく触っていない)
なぜこの非対称が効くのか。理由は、新しいタスクほどキャッシュ局所性が高いからです。直前に生成したタスクが触るデータは、生成元ワーカーのL1/L2キャッシュにまだ残っている可能性が高い。だからオーナーはLIFOで熱いタスクを自分で処理します。一方、dequeの先頭にある古いタスクは「しばらく放置された=もうキャッシュから追い出されている」ので、どのコアで動かしても局所性の損は小さい。さらにFork/Join型では先頭側ほど分割の上流=大粒のタスクなので、一度盗めば泥棒は当面困らず、窃取の頻度自体が下がります。キャッシュ局所性の一般論はメモリレイアウトとデータ局所性に詳しくあります。
オーナーがLIFOを使うのは局所性(直近データの再利用)と、Fork/Joinで子タスクを即座に処理して再帰を深さ優先で進める(メモリ使用量を抑える)ためです。泥棒がFIFOで先頭を奪うのは、(1) オーナーが当面触らない古い端なので衝突しにくい、(2) 分割木の根に近い大きな仕事を一括で得られて窃取回数が減る、の二点です。局所性と低競合を同時に取る、巧妙な役割分担です。
Fork/JoinとTokio:二つの実装
この原理を実装した代表が、JavaのFork/Join FrameworkとRustのTokioランタイムです。狙う負荷は違いますが、骨格は同じ作業窃取です。
| 観点 | Fork/Join (Java) | Tokio (Rust) |
|---|---|---|
| 主な対象 | CPUバウンドな分割統治 | 非同期I/O(多数の軽量タスク) |
| タスクの正体 | 再帰的に fork/join するサブ計算 | await で中断/再開する Future |
| ローカル取得 | LIFO(deque底) | LIFO(deque底)+直近1個のLIFOスロット |
| 窃取 | 他ワーカーの先頭から FIFO | 他ワーカーの先頭から FIFO |
| ブロック対策 | ManagedBlocker で補償スレッド | spawn_blocking で別プールに隔離 |
Fork/Joinは、大きな計算を fork で部分問題に分け、join で結果を待ち合わせる分割統治に最適化されています。join で子の完了を待つ間、ワーカーは遊ばず自分のdequeから別タスクを引いて進める(あるいは他から盗む)ため、待ち合わせがコアを止めません。ブロッキングが避けられない場合は ManagedBlocker で補償スレッドを一時的に足し、並列度の枯渇を防ぎます。
Tokioは非同期ランタイムで、扱う「タスク」はasync/awaitとコルーチンの内部実装で解説する Future です。await で中断したタスクは、I/O完了などの通知で再びキューに戻され、空いたワーカーが拾います。Tokioのワーカーは通常のdequeに加えて直近に起こされたタスクを置くLIFOスロットを持ち、関連タスク間のキャッシュ局所性を強めています。ただし1タスクが他へ譲らず長時間計算し続けると、そのワーカーのキューが滞り公平性が崩れるため、計算の重い処理は明示的に譲るか別プールへ逃がす設計が要ります。
窃取は「誰のキューに仕事があるか」を探すスキャンと、境界での不可分操作を伴います。タスクが極端に細かいと、ワーカーが互いのキューを盗み合うだけで実仕事が進まない空転に陥り得ます。また窃取はタスクを別コアへ移すため、移ったタスクは元コアのキャッシュ局所性を失います。負荷分散の利得(遊ぶコアを減らす)と局所性の損失(キャッシュミス増)はトレードオフで、適切なタスク粒度がこの均衡点を決めます。
全体像と設計判断
作業窃取スケジューラの強みは、負荷が偏っても自律的に均すことです。中央のディスパッチャが割り当てを決めるのではなく、暇になったワーカーが自発的に仕事を探しに行く——プッシュではなくプルの分散制御だからこそ、特定ワーカーへの偏りが自然に解消されます。
頻出は三点。(1) スレッドプールの動機——スレッド生成コストの償却とコンテキストスイッチ抑制。(2) サイジング——CPUバウンドはコア数前後、I/Oバウンドは「コア数 × (1 + 待機/計算)」で多めに。(3) 作業窃取の非対称——オーナーは底からLIFO(局所性が高い熱いタスク)、泥棒は先頭からFIFO(古く大粒で衝突しにくいタスク)。「LIFOで局所性、逆端アクセスで低競合、プルで負荷分散」を一息で言えると盤石です。
まとめ
スレッドプールは(1) 少数の長命ワーカーでタスクを回し生成コストを償却する仕組みで、その効きはタスク粒度とプールサイズ(CPUバウンドならコア数前後、I/Oバウンドなら待機比に応じて多め)で決まります。(2) 作業窃取はワーカーごとの両端キューで共有ロックの一点集中を分散し、(3) **オーナーは底からLIFO(局所性)、泥棒は先頭からFIFO(低競合・大粒)**という逆端アクセスで、キャッシュ局所性と負荷分散を同時に取ります。Fork/JoinもTokioもこの骨格を共有し、違いは対象(CPU計算か非同期I/Oか)とブロック隔離の作法にあります。待ち合わせの基礎は同期処理と非同期処理で補強できます。
プログラミング Article
スレッドプールと作業窃取スケジューラを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
並行処理
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
作業窃取(ワークスティーリング)は各ワーカーが両端キュー(deque)を持ち、自分はLIFOで底から、暇な他人はFIFOで先頭から奪う。これで局所性と負荷分散を両立する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「並行処理 / スレッドプール」に近いか確認する。
- 強みである「スレッドプールはスレッド生成コストを償却するため、固定数のワーカーでタスクキューを回す。CPUバウンドなら『コア数前後』、I/Oバウンドなら待ち時間に応じて多めが基本。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。