スケジューラの状態遷移とランキュー構造
プロセスがどの状態を行き来し、カーネルがどの構造で次の1つを選ぶのかが原理から分かります。状態遷移とper-CPUランキュー、ロードバランシングまで一気に押さえられます。
- 1.Linuxのタスク状態はTASK_RUNNING(実行可能/実行中)を起点に、INTERRUPTIBLE/UNINTERRUPTIBLEの待ち、STOPPED、そして終了後のZOMBIEへ遷移します。
- 2.スケジューラはグローバルな単一キューではなく、CPUごとに独立したランキュー(rq)を持ち、ロック競合とキャッシュ汚染を避けます。
- 3.per-CPUに分けた結果生じるCPU間の偏りは、スケジューリングドメイン階層をたどるロードバランサが定期的・イベント駆動で均します。
状態遷移とランキューを分けて捉える
スケジューラの仕事は2層に分かれます。1つは各タスクが「いま走れるのか/待っているのか/死んだのか」という状態を管理すること。もう1つは、走れるタスクの中から次の1つを選ぶデータ構造を運用することです。古典的な ready / running / blocked の3状態モデル(/os/scheduling/)は概念図としては正しいのですが、Linux の実装はもう少し細かく、待ちの種類や終了の途中段階まで状態として区別します。ここを正確に押さえると、ps で見える D や Z の正体、マルチコアでの偏りの均し方まで一本の筋で理解できます。
TASK_RUNNING を起点とした状態遷移
Linux のタスク状態は task_struct の state(現行カーネルでは __state)フィールドが持ちます。重要なのは、実行可能(ready)と実行中(running)が同じ TASK_RUNNING で表される点です。両者を分けるのは状態フィールドではなく、「いまランキューから選ばれて CPU を握っているか」という実行時の事実です。
fork / clone
│
▼
┌──────────────────────────────┐
│ TASK_RUNNING │ ← ready と running は同じ状態値
│ ・ランキュー上で順番待ち │
│ ・選ばれてCPUを実行中 │
└──────────────────────────────┘
▲ │ │ ▲
wakeup │ schedule() │ │ SIGCONT
(起床) ▼(待機へ) │ │
┌─────────────┐ │ ┌──────────────┐
│ INTERRUPTIBLE│ │ │ STOPPED │
│ UNINTERRUPT. │ │ │ (SIGSTOP等) │
└─────────────┘ │ └──────────────┘
│ exit()
▼
┌──────────┐ 親が wait() ┌──────────┐
│ ZOMBIE │ ───────────▶│ 解放(消滅) │
│(EXIT_*) │ └──────────┘
└──────────┘
遷移の本質は「ランキューに載っているか否か」です。TASK_RUNNING のタスクだけがランキュー上に存在し、スケジューラの選択対象になります。待ち状態へ移るときはランキューから外され(dequeue)、起床時に再び載せられます(enqueue)。状態フィールドの書き換えとランキュー操作は必ずセットで起きる、と捉えるのが正確です。
各状態の意味と分岐
待ち状態を1つにまとめず2種類に分けている点が、初学者がつまずきやすい所です。違いはシグナルで起こせるか否かです。
| 状態 | ランキュー | 意味 | シグナルで起床 | ps表示 |
|---|---|---|---|---|
| TASK_RUNNING | 載っている | 実行可能 または 実行中 | 該当せず | R |
| TASK_INTERRUPTIBLE | 外れている | イベント待ち(解除可能な待ち) | できる | S |
| TASK_UNINTERRUPTIBLE | 外れている | 完了必須の待ち(主にI/O) | できない | D |
| __TASK_STOPPED | 外れている | SIGSTOP等で一時停止 | SIGCONTで再開 | T |
| EXIT_ZOMBIE | 外れている | 終了済み・回収待ちの抜け殻 | 該当せず | Z |
- TASK_INTERRUPTIBLE:ソケットやパイプ、タイマーなど「待っても安全に途中で諦められる」イベントの待ち。シグナルが届くと待ちを中断して
TASK_RUNNINGへ戻り、システムコールはEINTRを返すか自動再開します。通常スリープしているプロセスの大半はこれで、Sと表示されます。 - TASK_UNINTERRUPTIBLE:ディスク I/O の完了など、途中で中断すると整合性が壊れる待ち。シグナルでは起こせず、待っているイベント(I/O 完了割り込みなど)でのみ起床します。ストレージが固まると
D状態のプロセスが滞留し、kill -9でも消えないのはこのためです(割り込みの仕組みは /os/interrupt-io/ を参照)。 - __TASK_STOPPED:
SIGSTOP/SIGTSTP(Ctrl+Z)などで一時停止した状態。SIGCONTでTASK_RUNNINGに戻ります。SIGSTOPとSIGKILLはハンドラで捕まえられない特別なシグナルです(/os/signals/)。 - EXIT_ZOMBIE:
exit()後、終了コードなどのメタ情報だけ残して本体を解放した抜け殻。親がwait()で回収すると完全に消えます。詳しくは /os/zombie-process/。
kill -9(SIGKILL)でも消せないプロセスを見たら、まず TASK_UNINTERRUPTIBLE(D)を疑います。SIGKILL すら配送が保留され、I/O が完了して TASK_RUNNING に戻った瞬間に初めて処理されるためです。NFS のハングなどで多発します。なお現在は途中状態の TASK_KILLABLE(致命シグナルだけは受ける UNINTERRUPTIBLE)も導入され、無条件に固まらない待ちが増えています。
「実行可能(ready)と実行中(running)を区別する状態フラグはどれか」と問われたら、Linux では区別しないが正答。両者とも TASK_RUNNING で、差はランキューに載っているという事実と、いまCPUを握っているかという実行時の状況だけです。3状態モデルの図と実装の対応を取り違えないこと。
per-CPUランキュー ── なぜ単一キューにしないのか
「走れるタスク(TASK_RUNNING)の集合」を保持するのがランキュー(runqueue, struct rq)です。素朴には全 CPU で1つの大きなキューを共有すれば良さそうですが、現代の Linux はCPU ごとに独立したランキューを1つずつ持ちます。
グローバル単一キュー(採用しない) per-CPUランキュー(現行)
[ 全CPUが奪い合う1本 ] CPU0 CPU1 CPU2
↑ ↑ ↑ ↑ [rq0] [rq1] [rq2]
CPU0 CPU1 CPU2 CPU3 │ │ │
└─ 1個のロックに集中 ─┘ ローカル ローカル ローカル
・ロック競合が深刻 ロックのみ ロックのみ ロックのみ
・キャッシュ行が全コア間で往復 ・競合が局所化
・コア数に対してスケールしない ・キャッシュ局所性を維持
単一キューを避ける理由は2つです。第一にロック競合。全 CPU が1本のキューのロックを取り合うと、コア数が増えるほど待ちが増え、スケールしません。per-CPU なら各 CPU は自分の rq->lock だけを取れば良く、競合が局所化します。第二にキャッシュ局所性。あるタスクを直前に走らせた CPU のキャッシュ・TLB にはそのタスクのデータが温まっています(/os/context-switch/)。同じ CPU で再び走らせればキャッシュが効くため、タスクは原則として元の CPU のランキューに留まろうとします。
各ランキューの内部は、スケジューリングクラスごとにサブ構造を持ちます。公平配分を担う CFS は cfs_rq の中で実行可能タスクを赤黒木に並べ、vruntime 最小のものを次に選びます(内部動作は /os/cfs-scheduler-internals/)。リアルタイムクラスは優先度ごとのキュー配列を持つ、という具合に構造が分かれています。
ランキューは単一のアルゴリズムで動くのではなく、stop → deadline → realtime → fair(CFS/EEVDF) → idle の順で並んだスケジューリングクラスのチェーンを上から問い合わせます。上位クラスに実行可能タスクがある限り下位は回りません。pick_next_task() はこの順でクラスを巡り、最初に「走らせるタスクあり」と答えたクラスから1つを取り出します。
ロードバランシング ── 偏りをどう均すか
per-CPU に分けた代償として、CPU 間の負荷が偏ります。あるコアのランキューに5タスク、隣は空、では公平でも効率的でもありません。これを均すのがロードバランサで、起動のきっかけは大きく2系統あります。
擬似コード:周期的バランシング(簡略)
on each scheduler tick (各CPUで定期的に):
domain = この CPU の最下位スケジューリングドメイン
while domain is not null:
if この CPU がバランス担当 かつ 間隔が経過:
busiest = ドメイン内で最も混んだグループを探す
if busiest の負荷 - 自分の負荷 が閾値を超える:
pull_tasks(busiest -> 自CPU) # 混んだ側から引き取る
domain = domain.parent # 上位ドメインへ
- 周期的バランシング(periodic):スケジューラティックごとに、混んだランキューから空いた側へタスクを**引き取り(pull)**ます。引っ張る側が動く pull 型が基本です。
- アイドル時バランシング(newidle / nohz):ある CPU が走らせるタスクを失って暇になった瞬間、即座に他から仕事を探して引き取ります。コアを遊ばせないための即応的な経路です。
- 起床時の配置(wake-up placement):眠っていたタスクが起きるとき、
select_task_rq()がどの CPU のランキューに載せるかを選びます。キャッシュが温かい元の CPU と、いますぐ空いている CPU を天秤にかけます。
均し方を決める鍵がスケジューリングドメインの階層です。CPU 同士の「近さ」(キャッシュやメモリの共有度)を木構造で表し、近い範囲から優先して均します。
| ドメイン階層(下→上) | 共有するもの | 移動コスト | 均しの積極度 |
|---|---|---|---|
| SMT(ハイパースレッド同士) | 実行ユニット・L1/L2 | ごく小さい | 最も積極的に均す |
| MC(同一物理コア群/L3共有) | L3キャッシュ | 小さい | 積極的 |
| NUMAノード内 | ローカルメモリ | 中 | 慎重 |
| NUMAノード間 | (共有が薄い) | 大きい(遠隔メモリ) | 最も消極的 |
階層の意図は明快です。移動コストが小さい近い CPU 間ほど頻繁・積極的に均し、コストの大きい遠い CPU 間(特に NUMA ノードをまたぐ移動)は閾値を高くして慎重にする。下位ドメインから順に上へたどることで、まず安いペアで均し、それでも収まらない大きな偏りだけ高コストな広域移動に踏み込みます。タスクを別 CPU へ移すと温めたキャッシュを捨てることになるため、「均す利得」が「移動の損」を上回るときだけ動かす、という損得勘定が全体を貫く設計思想です。
taskset や sched_setaffinity() でタスクが走れる CPU 集合(CPU マスク、例 {0,1})を固定すると、ロードバランサはその範囲の外へはタスクを動かせません。特定コアを専有させてキャッシュ局所性やレイテンシを稼ぐ常套手段ですが、固定しすぎると偏りをバランサが救えなくなるため、利得と硬直のトレードオフになります。
まとめ
- Linux のタスク状態は TASK_RUNNING を起点に、INTERRUPTIBLE / UNINTERRUPTIBLE の待ち、STOPPED、終了後の ZOMBIE へ遷移する。ready と running は同じ状態値で、差はランキューに載っているか・CPU を握っているか。
- 待ちの2種はシグナルで起こせるかで分かれ、UNINTERRUPTIBLE(
D)は SIGKILL でも消せない。 - ランキューはCPU ごとに独立。ロック競合の局所化とキャッシュ局所性のために単一キューを避け、内部はスケジューリングクラス別の構造(CFS は赤黒木)を持つ。
- per-CPU の偏りはスケジューリングドメイン階層をたどるロードバランサが均す。近い CPU ほど積極的に、NUMA をまたぐ移動は慎重に——「均す利得 > 移動の損」のときだけ動かす。
- 状態遷移を起こすシグナルは /os/signals/、回収待ちの抜け殻は /os/zombie-process/、スケジュールされる単位は /os/process-thread/ も参照。
OS Article
スケジューラの状態遷移とランキュー構造を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
スケジューラ
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 5
導入後に効く点
スケジューラはグローバルな単一キューではなく、CPUごとに独立したランキュー(rq)を持ち、ロック競合とキャッシュ汚染を避けます。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 5
判断チェックリスト
- 自社の用途が「スケジューラ / プロセス状態」に近いか確認する。
- 強みである「Linuxのタスク状態はTASK_RUNNING(実行可能/実行中)を起点に、INTERRUPTIBLE/UNINTERRUPTIBLEの待ち、STOPPED、そして終了後のZOMBIEへ遷移します。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。