Linux コンテキストスイッチ

このページの目次

パイプラインでの read() 呼び出し1回で、コンテキストスイッチが連鎖します。タスクAがデータ待ちをしている間、CPUはタスクBの実行に移ります。Bがデータを書き込んでAをウェイクアップしますが、Aはすぐに読み込みを再開するわけではなく、スケジューラが再びAを選択するまで待機します。

この例を追いながら、⁠カーネルへの入り込み、イベント待機、実行キューへの登録、実行再開を区別する必要があります。これらはすべて呼び出しレイテンシに影響しますが、対応するメカニズムと最適化の方向性は異なります。

追跡したい部分該当セクション
read() がなぜスリープし、ウェイクアップ後もなぜ待機するのか?第1節:レイテンシのタイムライン
タスク切り替え時に、スタックとアドレス空間はどのように引き継がれるか?第2~3節:mm と switch_to
同じ切り替えでも、なぜコストが異なるのか?第4節:TLS、FPU、TLB
通知は発生したが、なぜスレッドがスリープから覚めないのか?第5~6節:待機プロトコルと計測

本稿では、一般的なスケジューリングパスと x86-64 アーキテクチャを例に、Linux 7.2 のソースコードを参照します。アーキテクチャ固有の詳細や関数の順序は、安定した ABI ではありません。

1. ブロックする read() から始める

タスクAが read() を呼び出し、パイプラインにデータがなく、まだ書き込み側が存在するとします。CPUはシステムコールのエントリ経由でAのカーネル実行パスに入り、ユーザー空間に戻る状態が適用可能なエントリフレームに保存されます。⁠この時点で実行中のタスクは依然としてAです。もしパイプラインに既にデータがあれば、read() はそのまま完了し、Aのユーザー空間に戻ります。この間、タスクの切り替えは発生しません。

データがない場合、カーネルはAの待機状態を登録し、条件が依然として満たされていないことを確認してから、Aをスリープさせます。スケジューラがBを選択して初めて、Aの実行コンテキストが保存され、Bの実行が続けられます。

Bがパイプラインに書き込むとAはウェイクアップされますが、Aが実行可能になるだけです。スケジューラが再びAを選択して初めて、Aは自身の待機パスから再開し、条件を再チェックしてデータを取り出し、ユーザー空間に戻ります。

1回のread:データの待機終了後も、CPUの待機が続く可能性がある スリープ:データの待機 実行可能:CPUの待機 実行中:readの継続 待機状態へ ウェイクアップ 再実行 リターン データ準備完了 ≠ CPU取得済み 時間の幅は概略であり、実測の比率を表すものではありません。キュー待ち時間と低レベルの切り替えコストは、それぞれ別々に計測する必要があります。

図中の2つの待機区間は、それぞれ別々に診断する必要があります。

A がどの区間で停止しているか不足しているもの優先して確認すべき点
データがまだ届いていない読み込み条件が満たされていないプロデューサ、I/O、ビジネスロジックの依存関係
ウェイクアップ済みだが未実行CPUの実行機会競合、クォータ、アフィニティ、プリエンプションレイテンシ
read() の実行を再開した読み込みとリターンの完了処理中実際の処理負荷と関連するメモリアクセスコスト

優先度を上げても、まだ生成されていないデータを先に出現させることはできません。まず適切な区間を特定して初めて、スケジューリングの最適化が有効かどうかを判断できます。

ハードウェア割り込みが発生しても、自動的にタスクの切り替えが行われるわけではありません。割り込みはAを中断できますが、処理後は再びAに戻ります。関連するエグジットパスや他のスケジューリングポイントで別のタスクの実行が決定されて初めて、タスクの引き継ぎが発生します。カーネルへの入り込み回数、システムコール回数、コンテキストスイッチ回数は、互いに代替できません。

2. context_switch:アドレス空間と実行フローは別軸

next が選択された後、kernel/sched/core.c を辿りながら、引き継ぎを3つの論理フェーズに分けることができます。

  1. 引き継ぎ前の準備。 スケジューリングの会計処理を調整し、新旧タスクの mm(メモリ記述子)の関係を処理します。
  2. 低レベルの切り替え。 アーキテクチャ固有の switch_to を経由してスタックを切り替え、必要なレジスタ状態を処理します。
  3. 引き継ぎ後の後処理。 新しいタスクのコンテキスト内で、直前に切り替えられたタスクの後処理を完了します。

ここで重要なのは、「後処理が新しいタスクのコンテキストで発生する」という点です。第3節では、2つのカーネルスタックを辿りながら、なぜこのように配置する必要があるかを説明します。

アドレス空間の切り替えは、prev と next の関係に依存します。

シナリオmm の関係依然として処理が必要な実行コンテキスト
同じプロセス内の2つの通常スレッド通常、ユーザー空間の mm を共有各々のカーネルスタック、レジスタ、TLS、FPU状態など
2つの独立したプロセス通常、異なる mm を使用実行環境とユーザーアドレス空間の両方を調整
カーネルスレッドへの切り替え通常 next->mm == NULLユーザープログラムに入るのではなく、active_mm の借用/遅延管理プロトコルを使用

同じ mm と異なる mm で、それぞれ何が省略されるか

同じ mm であればアドレス空間の作業を減らせますが、スレッドの切り替えだからといって「コンテキストを保存しなくてよい」というわけではありません。逆に、異なる mm であっても、TLB全体を無条件にクリアするわけではありません。x86 の CR3 は現在のページテーブルのルートを参照し、PCID が有効な場合はアドレス空間の識別子も保持します。PCID は異なるアドレス空間の翻訳エントリを区別できます。切り替えパスでは、CPUのキャッシュされたコンテキストと無効化世代を組み合わせ、刷新する必要があるかどうかを判断します。エントリがまだ有効な場合にのみ安全な再利用を許可し、期限切れのマッピングを継続して合法に使用させることはできません。

カーネルスレッドの active_mm は、カーネルが独自のユーザー mm を持たなくても、適切なアドレス空間のコンテキストを維持できるようにします。関連する参照と遅延解放は、アーキテクチャの lazy TLB プロトコルと協調する必要があり、単に mm = &init_mm と書いたり、切り替え前に旧 mm を自由に解放したりすることはできません。

3. switch_to がなぜ「別の呼び出しチェーンでリターンする」ように見えるか

各タスクには独立したカーネルスタックがあります。Aがスケジューリングパスを呼び出すとき、Aの未完了のカーネル呼び出しチェーンはAのスタック上に残ります。低レベルの切り替えは、Aが実行を再開するために必要なスタック位置とアーキテクチャ状態を保存し、スタックポインタなどをBの保存位置に切り替えます。もしBも以前にスケジューリングパスからスリープしていた場合、CPUが次に目にするのはBの最後に中断された呼び出しチェーンであり、Aのローカル変数を継続して実行するわけではありません。

AからBへの切り替え:CPUはBが以前に中断したカーネル呼び出しチェーンを再開する Aのカーネルスタック イベント待機 → schedule → switch_to Bのカーネルスタック 前回の schedule で保存された再開位置 スタックの切り替え Aの再開位置が保持される 再選択された後にリターンを継続 CPUは現在Bの呼び出しチェーンに沿ってリターンする 切り替えの後処理を完了し、Bのロジックを継続 BはAの継続コードを呼び出しているわけではありません。初めて実行される新しいタスクは、専用の起動ジャンプテーブルを通じて実行に入ります。

AまたはBを直接選択し、現在のタスクのローカル値を変更します。共有と独立した mm を比較すると、アドレス空間を共有していても、2つのカーネルスタックがマージされるわけではありません。

図を準備しています
Bに切り替えると、なぜBの呼び出しチェーンに沿ってリターンするのか?

mm を共有するスレッドでも、それぞれ独立したカーネルスタックを持ちます。SPを切り替えた後、CPUは新しいタスク自身の呼び出しチェーンとローカル状態を使用し、後処理の対象は今回直前に切り替えられたタスクになります。

2種類のコンテキスト保存を、1つのレジスタ表に混同しない

保存場所主にどの再開をサポートするか
システムコール/例外エントリフレーム(適用される pt_regs など)最後にユーザー空間の実行位置に戻るため
カーネル呼び出しスタックと低レベルの切り替えフレームタスクが中断したカーネル呼び出しチェーンを再開するため

x86-64 の低レベルアセンブリは、callee-saved レジスタとスタック位置を保存します。他の値は呼び出し規約、コンパイラスタックフレーム、またはエントリパスによって処理されます。したがって、switch_to 内の push/pop の数だけを数えても、「ユーザーコンテキストに完全に含まれるレジスタがいくつあるか」という答えは得られません。

再開するのは B だが、後処理の対象は直前に切り替えられた A

Bが最後に切り替えられた際にDに譲っていたと仮定します。今回AからBに切り替えた後、Bが実行を継続するためのスケジューリング後処理では、⁠今回直前に切り替えられたのがAであることを知る必要があり、前回のDをクリーンアップ対象のタスクとして扱うわけではありません。低レベルの切り替えインターフェースと一般的な後処理は、このスタックをまたぐ引き継ぎを調整しています。新しく作成されたタスクは通常の中断/リターンの過程をまだ踏んでいないため、事前に構築された初期コンテキストと起動ジャンプテーブルを通じて実行に入ります。

なぜエグジットタスク自身のスタック上でスタックを即座に解放できないのでしょうか?それは、CPUが現在それを使用しているからです。他のタスクに切り替えてから、適用可能な遅延解放と会計処理を完了させる必要があります。スケジューラロック、プリエンプションカウンタ、RCUなどの状態も、専用のプロトコルに従ってこの境界を越える必要があります。switch_to を単なる「配列の保存、配列の復帰」関数として切り出して使用することはできません。

4. TLS、FPU、TLB:コストがワークロードに応じて変動する理由

同じ mm を共有する2つのスレッドでも、スレッドローカルストレージは異なります。x86-64 の FS/GS 関連状態などは現在のタスクと一致する必要があり、そうでなければスレッドローカル変数にアクセスする同じ命令が、他人のデータを読み込んでしまう可能性があります。perf、デバッグレジスタ、I/O ビットマップなどの状態に追加の操作が必要かどうかは、タスクがどの機能を有効にしているかによって異なります。

FPU:保存とロードは必ずしも同時刻に行われるわけではない

FPU/SIMD 拡張状態にはタスク所有権があります。最新の x86 Linux のパスは、現在の実装に基づいて理解する必要があり、「TSフラグを設定し、次の #NM で旧タスクを保存する」という古い lazy FPU の図式を引き続き適用することはできません。

ネイティブの arch/x86/include/asm/fpu/sched.h は必要に応じて旧状態を保存し、TIF_NEED_FPU_LOAD を含むプロトコルを通じてロードを調整します。一部の再開は、適切なリターン/使用の境界まで遅延させることができます。AMX などの動的拡張は、状態のサイズ、権限、オンデマンドメカニズムに関与しており、処理コストにさらなる影響を与えます。x86 拡張状態管理

TLB:マッピング無効化とタスク切り替えは別々に分析する

TLB shootdown が解決するのは別の問題です。ページテーブルが変更された後、旧翻訳をキャッシュしている可能性のあるCPUがそれを使用しないようにします。これはタスクの切り替えが発生しなくても起こり得ますし、異なる mm への切り替えのたびに発生する必要もありません。低レベルの引き継ぎが完了した後も、次のタスクはキャッシュ、TLB、分岐予測の局所性の損失に遭遇する可能性があります。これらの後続のメモリアクセスと実行コストは、切り替えアセンブリ自体よりも、スループット低下を説明する要因となることが多いです。

以下では、CPU 1 が M1 を固定して使用し、CPU 0 が M1 / M2 の間で切り替える状況を想定します。有効な翻訳の再利用を比較した後に、タスクを切り替えずに M1 のページテーブルを変更し、2つのCPUの旧翻訳がなぜ使用できなくなるかを観察します。

図を準備しています
タスクを切り替えることは、すべての翻訳をクリアすることと同じか?

有効な PCID マークされた翻訳は、mm 切り替えを跨いで保持される可能性があります。ページテーブルの更新は、タスク切り替えが発生しなくても、旧翻訳が使用されなくなることを要求します。

したがって、観察された損失を、直接の引き継ぎ作業と局所性を回復する間接的なコストに分け、さらに実行キューの待機を別途計算することができます。これらには関連性がありますが、測定範囲を明確にした後に初めて、異なる最適化方案を比較することが適しています。

5. 待機プロトコル:スケジューラがアプリケーションの欠落した通知を補完しない

誤ったプロトコルは簡単に作れてしまいます。コンシューマがまず ready == false をチェックしてスリープの準備をし、その後プロデューサがデータを書き込んで待機キューをウェイクアップし、最後にコンシューマが自身をキューに追加します。プロデューサは空のキューを見て、ウェイクアップ対象がいませんが、コンシューマは条件がすでに真であるにもかかわらずスリープしてしまいます。後続のイベントがない場合、プログラムは次の通知を永遠に待たされる可能性があります。

ウェイクアップの喪失:通知発生時、待機者はまだ登録されていない コンシューマ プロデューサ ready = false をチェック 待機を登録してスリープ データを書き込み、ready = true に wake:この時点ではキューは空 次の通知がない場合、永遠に待機する可能性がある 正しいプロトコルは、「待機の登録 + 条件のチェック + スリープへの移行」を発行者と調整します。volatile だけでは修正できません。

カーネルの待機インターフェースは、待機項目の登録、適切な状態の設定、条件のチェック、必要に応じたスケジューリングを通じて、このウィンドウを調整します。条件の公開には、対応するロックまたはメモリ順序を使用する必要があります。READ_ONCE/WRITE_ONCE は特定のエクスコンパイルによるアクセス変形を防ぎますが、それだけでは完全なCPU間公開と待機プロトコルを確立しません。wake_up() の呼び出しは、永続的なイベントを「通知メールボックス」に詰め込むことではありません。ウェイクアップされた側はビジネス条件を再チェックする必要があり、複数の待機者が同じデータを競合する可能性もあります。

ウィンドウの修正:条件チェックと待機への移行を調整する

ユーザー空間の条件変数は類似した制約を表現します。コンシューマがロックを保持して条件をチェックし、pthread_cond_wait() がミューテックスロックをアトミックに解放して待機に入ります。リターン時にロックを再取得し、ループ内で条件を再チェックします。プロデューサは同じロックの下で条件を変更します。これにより、通知が発生した時点でコンシューマが待機していなかった場合でも、コンシューマは後で永続的な条件値を確認でき、通知を見逃したからといって必ずブロックされるわけではありません。

「チェック → 公開 → 待機」を試してから、同じロック保護プロトコルに直接切り替えてみます。操作順序は保持されますが、ロックがプロデューサがこのウィンドウ内で公開を完了できるかどうかを変更します。

図を準備しています
条件は真なのに、なぜコンシューマはスリープしているのか?

チェック後、待機登録前に通知が発生すると、コンシューマが条件が真であるにもかかわらずスリープする可能性があります。条件を同じロックで保護し、ロック解放と待機への移行を調整することで、このウィンドウを閉じることができます。

実験:1000ラウンド交互実行、条件チェックが根拠である

以下では、2つのスレッドが1000ラウンド交互に実行するコードを示し、条件と待機の連携を検証します。handoff.c として保存し、cc -std=c11 -O2 -Wall -Wextra -Werror -pthread handoff.c -o handoff でコンパイルします。2つのスレッドは同期オブジェクトのみを変更し、システムスケジューリング設定は変更しません。

#define _POSIX_C_SOURCE 200809L
#include <assert.h>
#include <pthread.h>
#include <stdio.h>
#include <time.h>

static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
static int turn; /* 0: メインスレッドが送信可能、1: ワーカースレッドが消費可能 */
static int consumed;
enum { ROUNDS = 1000 };

static void *worker(void *unused) {
    (void)unused;
    for (int i = 0; i < ROUNDS; ++i) {
        assert(pthread_mutex_lock(&mu) == 0);
        while (turn != 1)
            assert(pthread_cond_wait(&cv, &mu) == 0);
        ++consumed;
        turn = 0;
        assert(pthread_cond_signal(&cv) == 0);
        assert(pthread_mutex_unlock(&mu) == 0);
    }
    return NULL;
}

int main(void) {
    pthread_t thread;
    struct timespec start, end;
    assert(clock_gettime(CLOCK_MONOTONIC, &start) == 0);
    assert(pthread_create(&thread, NULL, worker, NULL) == 0);
    assert(pthread_mutex_lock(&mu) == 0);
    for (int i = 0; i < ROUNDS; ++i) {
        while (turn != 0)
            assert(pthread_cond_wait(&cv, &mu) == 0);
        turn = 1;
        assert(pthread_cond_signal(&cv) == 0);
    }
    while (turn != 0)
        assert(pthread_cond_wait(&cv, &mu) == 0);
    assert(consumed == ROUNDS);
    assert(pthread_mutex_unlock(&mu) == 0);
    assert(pthread_join(thread, NULL) == 0);
    assert(clock_gettime(CLOCK_MONOTONIC, &end) == 0);
    double seconds = (end.tv_sec - start.tv_sec)
                   + (end.tv_nsec - start.tv_nsec) / 1e9;
    printf("%d round trips; %.0f ns/round including startup and join\n",
           ROUNDS, seconds * 1e9 / ROUNDS);
    assert(pthread_cond_destroy(&cv) == 0);
    assert(pthread_mutex_destroy(&mu) == 0);
}

このアサーション駆動の実験を NDEBUG でコンパイルしないでください。while を if に変更できません。待機リターンが条件の満たされ方を保証するわけではないため、条件こそが実行継続の根拠です。ここでは対向待機者が1人だけなので、signal で十分です。複数コンシューマ設計では、条件の分割、ウェイクアップ数、エグジットプロトコルを再考する必要があります。

出力はこの同期ワークロードの往復時間であり、ミューテックスロック、条件変数、可能な futex システムコール、スケジューリング待機、スレッドの起動と回収を含みます。純粋なコンテキストスイッチの消費時間ではありません。各ラウンドで待機が発生したとしても、イベント統計なしで各ラウンドを固定して2回の切り替えに換算することはできません。2つのスレッドが異なるCPU上で実行される可能性もあります。

6. 数値から原因へ:どの区間を計測すべきか

/proc/PID/status または getrusage() の自発的/非自発的切り替えカウントは、変化の方向性を判断するのに役立ちますが、「自発的」がビジネスコードの明示的な yield 呼び出しを意味するわけではなく、ブロック待機もその中に含まれます。「非自発的」がシステム障害を意味するわけでもありません。増分を、完了したリクエスト数、スレッド数、CPU使用率と同じウィンドウで比較する必要があります。

ウェイクアップレイテンシを研究する場合、スケジューラ tracepoint と関連付けることができます。ターゲット TID の sched_wakeup 時間を記録し、次にそれが next となる sched_switch を見つけます。重複ウェイクアップ、タスク終了と TID の再利用、CPU間タイムスタンプ、収集ウィンドウの欠如を処理する必要があり、同じ PID のログを単純に2つ引き算することはできません。得られるのは、ウェイクアップから CPU を取得するまでの待機であり、競合と関連するスケジューリングパスが含まれており、低レベルの switch_to コストは分離されていません。

指標の変化を候補の原因と対応付け、次のステップを決定します。

観察結果継続して検証する価値のある方向
切り替え回数が増加したが、スループットはほぼ不変スレッドの協調方法が変更されたか
尾部レイテンシが上昇し、実行キューの待機も増加CPU競合、クォータ、配置制約
キューイングは顕著でないが、マイグレーション後に実行が遅延キャッシュ局所性と NUMA

これらはトラブルシューティングの方向であり、単一のカウントだけで因果関係を結論付けることはできません。スレッド数の削減、バッチ調整、ロック粒度の変更は、確認された待機または実行コストに対して行われるべきです。

ソースコードを読む場合は、kernel/sched/core.c の __schedule() / context_switch() から入り、アーキテクチャ固有の switch_to、mm/TLB、FPU パスを確認します。スケジューラがなぜこの next を選択したかは、スケジューラを参照してください。シグナルがどのように待機を終了させ、なぜ EINTR を返すことがあるかは、シグナルと ptrace を参照してください。