このページの目次

コンテキストスイッチ

カバー範囲: context_switch() の完全パス → switch_mm (ページテーブル/TLB/PCID) → switch_to (レジスタ/FPU) → 遅延TLB → 切り替えオーバーヘッド分析 カーネルバージョン: 2.6 ~ 6.x。5.x以降のPCID/ASIDおよびFPU最適化を重点的に注記

概要

コンテキストスイッチはスケジューラの「実行側」です。スケジューラが「誰が実行するか」を決定し、コンテキストスイッチは「どのように切り替えるか」を担当します。毎回の切り替えにおいて、カーネルは現在実行中のプロセスのCPUレジスタ状態を保存し、次プロセスのレジスタを復元すると同時に、アドレス空間の切り替え(ページテーブル、TLB)およびFPU/SIMDコンテキストの処理を行います。

1回のコンテキストスイッチの典型的なオーバーヘッドは 1~5 μs の範囲にあり、以下に依存します:

  • アドレス空間を跨ぐかどうか(プロセス切り替え vs スレッド切り替え)
  • TLBのフラッシュが必要かどうか(PCID/ASIDにより削減可能)
  • FPUがダーティかどうか(XSAVE領域の保存/復元が必要)
  • キャッシュの局所性(直前にウェイクアップされたプロセスは、データがまだキャッシュ内にある可能性がある)

エントリポイント: context_switch()

// kernel/sched/core.c
// schedule() → __schedule() から呼び出される
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next, struct rq_flags *rf) {

    prepare_task_switch(rq, prev, next);

    // === ステップ1: アドレス空間の切り替え ===
    // 2つのプロセスが異なるmmを持つ場合 → ページテーブルの切り替えが必要
    if (!next->mm)
        // カーネルスレッド: mmがない → prevのactive_mmを借用
        enter_lazy_tlb(prev->active_mm, next);
    else
        // ユーザープロセス: 通常の切り替え
        switch_mm_irqs_off(prev->active_mm, next->mm, next);

    // === ステップ2: レジスタコンテキストの切り替え ===
    // prevのレジスタを保存 → nextのレジスタを復元 → nextが最後に停止した場所に復帰
    switch_to(prev, next, prev);

    // ここから先は、すでにnextのコンテキストで実行されている
    // ただし、prevのカーネルスタックとrqはまだ使用中であり、finish_task_switch() がクリーンアップを行う

    return finish_task_switch(prev);
}

重要な洞察:context_switch()switch_to() を呼び出した後、⁠実行主体はすでにnextになっている ことです。switch_to は(Cの視点からは)戻りません。これは、制御フローをnextプロセスが最後に切り替えられた時の switch_to の復帰ポイントに渡します。次にprevがスケジューリングされて実行に戻ってくると、switch_to の復帰地点から finish_task_switch() の実行を再開します。


switch_mm: アドレス空間の切り替え

これはコンテキストスイッチの中で最も高価な部分であり、特にアドレス空間を跨ぐ場合に顕著です。

x86-64 実装

// arch/x86/mm/tlb.c
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
                         struct task_struct *tsk) {
    unsigned cpu = smp_processor_id();

    if (likely(prev != next)) {
        // 本当にアドレス空間を切り替える必要がある

        // 1. CR3 (ページテーブルのルートポインタ) の切り替え
        u64 new_asid = this_cpu_read(cpu_tlbstate.loaded_mm_asid);

        if (IS_ENABLED(CONFIG_PTI)) {
            // KPTI: 2つのページテーブルがある → 切り替え時にカーネルCR3を書き込む
            // ユーザー空間のCR3は entry_SYSCALL_64 で切り替えられる
            load_new_mm_cr3(next->pgd, new_asid, true);
        }

        // 2. 本CPUのTLBをフラッシュする必要がある場合
        //    PCIDにより、グローバルでないTLBエントリはフラッシュせずにマーキング可能
        //    PCIDがない場合 → CR3への書き込みでグローバルでないエントリがすべてクリアされる

        // 3. 他のCPUにTLBフラッシュを通知する必要がある場合 → IPI (TLB shootdown) を送信
        //    arch/x86/mm/tlb.c: flush_tlb_mm_range() を参照

        this_cpu_write(cpu_tlbstate.loaded_mm, next);
    } else {
        // prev == next: スレッド切り替えまたはアイドルプロセスの再利用 → CR3を変更する必要はない
        // ただし、遅延TLBの状態を確認する必要がある
    }
}

PCID: TLBフラッシュの回避

// x86 PCID (Process-Context Identifier):
//   各アドレス空間に12ビットのASID (Address Space Identifier) を割り当て
//   CR3への書き込み時にASIDを付与 → TLB内の他のASIDのエントリはヒットせず
//   フラッシュする必要もない
//
// PCIDがない場合: CR3への書き込みごとにすべてのnon-global TLBエントリをフラッシュ → キャッシュミスが非常に多発
// PCIDがある場合: アドレス空間の切り替え → CR3 + new ASIDへの書き込みのみ → TLBは旧エントリを保持
//               (旧エントリには旧ASIDが付いており、誤ヒットしない)
//
// カーネルは、CPUごとのASIDビットマップを使用してASIDを割り当ておよび回収する

遅延TLB (Lazy TLB)

// カーネルスレッド (mm == NULL) の切り替え時:
//   enter_lazy_tlb(prev->active_mm, next)
//      → next->active_mm = prev->active_mm
//      → CR3への書き込みを行わない (prevのページテーブルを引き続き使用)
//      → カーネルアドレス空間は共有されているため、切り替え不要
//
// これにより、カーネルスレッドの切り替えが非常に高速になる → TLBフラッシュのオーバーヘッドがない

ARM64 の同等実装

// ARM64 は PCID の代わりに ASID (8/16ビット) を使用
// TTBR0_EL1: ユーザー空間ページテーブル (プロセスごとに異なる)
// TTBR1_EL1: カーネル空間ページテーブル (全プロセスで共有)
//
// switch_mm(): TTBR0_EL1 + ASID を書き込み
//   → TLBフラッシュ用の追加命令は不要 (ASIDでハードウェアが区別)
//
// VMID (Virtual Machine ID):
//   仮想化拡張機能。ハイパーバイザによって管理
//   stage-2 ページテーブルのTLBタグとして使用

switch_to: レジスタコンテキスト

// arch/x86/entry/entry_64.S
// switch_to() はマクロであり、以下に展開される:
//
// 1. prevの汎用レジスタをカーネルスタックに保存
// 2. prevのRIP/RSPをtask_struct.threadに保存
// 3. nextのRIP/RSPを復元
// 4. nextの汎用レジスタを復元
// 5. nextのRIPにジャンプ

thread_struct — 保存されるハードウェアコンテキスト

// arch/x86/include/asm/processor.h
struct thread_struct {
    // Cコンパイラの保護対象 (callee-saved) レジスタ — switch_toのコンパイラ動作により暗黙的に保存
    unsigned long           sp;         // カーネルスタックポインタ (RSP)
    unsigned long           sp0;        // プライビレッジレベル0で使用されるスタックポインタ

    // FPU/SIMD状態
    struct fpu              fpu;        // 内包されるfpu構造体

    // IOビットマップ (ioperm)
    struct io_bitmap        *io_bitmap;

    // デバッグ
    unsigned long           debugreg[8];
    unsigned long           cr2, trap_nr, error_code;

    // 仮想化
    unsigned long           gs_base;
};

FPUの遅延切り替え

// arch/x86/kernel/fpu/core.c
// 最新のx86 (XSAVE/XSAVES) におけるFPUの切り替え戦略:

// 1. 各プロセスは独立したfpstate (XSAVE領域、AMXの場合最大約10KB) を持つ
// 2. 切り替え時に「遅延」処理を行う:
//    - switch_to() 内でFPU状態の自動保存/復元は行わない
//    - TS (Task Switched) ビットまたはXFDビットを設定
//    - 新プロセスが初めてFPUを使用 → #NM例外発生 → その時点でカーネルがXRSTORを実行
//
// 3. なぜか? XSAVE領域は大きい (AMXの場合数KB)。多くのプロセスはFPUを使用しない
//    遅延保存により、意味のないデータ転送を回避できる

// 遅延保存の例外:
//   - 次プロセスがFPUを明示的に必要とする場合 (__fpu_state_activeチェックで確認)
//   - かつFPUがダーティである場合 → switch_to内でXSAVE/XSAVESを必ず実行する必要がある

アーキテクチャ間の差異

x86-64ARM64RISC-V
切り替え関数__switch_to_asm() (アセンブリ)cpu_switch_to() (アセンブリ)__switch_to() (アセンブリ)
FPUXSAVE/XRSTOR (~10KB)FPSIMD/SVE (遅延)FPU (遅延)
アドレス空間CR3書き込み + PCIDTTBR0書き込み + ASIDSATP書き込み + ASID
バリアmembar 明示的TTBR書き込み後に isbsfence.vma

TLB Shootdown

グローバルなページテーブルエントリを変更した場合(例: munmap)、他の全CPUにTLBフラッシュを通知する必要があります:

// kernel/smp.c → arch/x86/mm/tlb.c
void flush_tlb_mm_range(struct mm_struct *mm, unsigned long start,
                         unsigned long end, unsigned int stride_shift,
                         bool freed_tables) {
    // 1. ローカル: 自身のTLBをフラッシュ
    // 2. リモート: このmmを実行中の全CPUにIPI (Inter-Processor Interrupt) を送信
    //    → 対象CPUがIPIを受信 → 割り込み処理内でTLBをフラッシュ → 完了を返信
    // 3. 全リモートCPUの完了を待機 (CPUごとのflush状態を追跡)
}

マルチコアでのTLB shootdownはオーバーヘッドが大きい(IPI + 待機)ため、カーネルは複数の最適化を行っています:

  • 遅延TLBモード⁠: 対象CPUがカーネルスレッド上で実行中(ユーザー空間を実行していない)の場合、フラッシュを遅延できる
  • バッチ処理⁠: 複数のTLB操作を1つのIPIに結合
  • セルフスヌープ⁠: 一部のハードウェアではページテーブル変更を自動検知でき、ソフトウェアIPIが不要

切り替えオーバーヘッド分析

典型的な数値 (x86-64, ~3GHz):

シナリオレイテンシ理由
スレッド切り替え (同一プロセス)~0.5-1 μsCR3書き込みなし、FPUは通常遅延
プロセス切り替え (PCIDあり)~1-3 μsCR3書き込みあり、ただしTLBは保持
プロセス切り替え (PCIDなし)~3-5 μsCR3書き込み + 全non-global TLBの冷間起動
NUMA間+1-3 μs新CPUのキャッシュが冷えているため、リモートからのデータ取得が必要

測定方法

# 1. perf: レイテンシヒストグラム
perf sched record -- sleep 1
perf sched latency -s max

# 2. bpftrace: schedule() レイテンシを直接測定
bpftrace -e '
kprobe:finish_task_switch {
    $ns = nsecs - @last;
    if (@last > 0) { @lat_us = hist($ns / 1000); }
    @last = nsecs;
}'

# 3. /proc/<pid>/sched
cat /proc/self/sched | grep nr_switches
# nr_voluntary_switches: プロセスが自発的に譲渡 (block/sleep/yield)
# nr_involuntary_switches: タイムスライス終了によりプリエンプト
# involuntaryが高い → CPU集約型;voluntaryが高い → IO集約型

参考と拡張

  • カーネルドキュメント⁠: Documentation/x86/tlb.rst, Documentation/arch/x86/topology.rst
  • LWN:
    • "PCID and the TLB" (lwn.net/Articles/729754/)
    • "Lazy FPU state restoration" (lwn.net/Articles/675957/)
  • ソースファイル⁠:
    • kernel/sched/core.c — context_switch()
    • arch/x86/mm/tlb.c — switch_mm_irqs_off()
    • arch/x86/kernel/process_64.c — __switch_to()
    • arch/x86/entry/entry_64.S — アセンブリレベルのswitch_to
    • arch/x86/kernel/fpu/core.c — FPUの遅延切り替え

キーワード: context_switch, switch_mm, switch_to, PCID, ASID, TLB shootdown, lazy TLB, FPU lazy restore, XSAVE