Linux 上下文切换

本页目录

一个空管道上的 read(),足以串起上下文切换:任务 A 等待数据,CPU 转去执行 B,B 写入数据后唤醒 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,它才从自己的等待路径继续,重新检查条件、取出数据并返回用户态。

一次 read:数据等待结束后,还可能继续等待 CPU 睡眠:等待数据 可运行:等待 CPU 运行:继续 read 进入等待 被唤醒 重新运行 返回 数据已就绪 ≠ 已经拿到 CPU 示意时间段,宽度不代表实测比例;排队时间与低层切换成本需要分别测量。

图中的两个等待区间,需要分别诊断:

A 停在哪一段此时缺什么优先检查
数据还未到达读取条件尚未满足生产者、I/O 和业务依赖
已唤醒但尚未运行CPU 执行机会竞争、配额、亲和性、抢占延迟
已重新执行 read正在完成读取与返回实际执行工作及相关访存成本

提高优先级无法让尚未产生的数据提前出现;只有先找对时间段,才能判断调度优化是否有效。

硬件中断也不自动意味着切换任务。中断可以打断 A,处理后仍返回 A;只有相关退出路径或其他调度点决定运行另一个任务,才产生任务交接。进入内核次数、系统调用次数、上下文切换次数不能相互替代。

2. context_switch:地址空间与执行流是两条线

选出 next 后,可沿 kernel/sched/core.c 把交接分成三个逻辑阶段:

  1. 交接前准备。 协调调度记账,并处理新旧任务的 mm 关系。
  2. 低层切换。 经架构 switch_to 换栈并处理必要的寄存器状态。
  3. 交接后收尾。 已在新任务上下文中,完成对刚换下任务的收尾。

这里的“收尾发生在新任务中”很重要。第 3 节会沿两条内核栈解释它为什么必须这样安排。

地址空间切换取决于 prev 与 next 的关系:

场景mm 关系仍然需要处理的执行上下文
同一进程的两个普通线程通常共享用户 mm各自内核栈、寄存器、TLS、FPU 状态等
两个独立进程通常使用不同 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 恢复执行所需的栈位置和架构状态,然后把栈指针等换到 B 的保存位置。若 B 以前也从调度路径睡眠,那么 CPU 接下来看到的是 B 上次暂停的调用链,而非继续沿 A 的局部变量执行。

从 A 切到 B:CPU 接续 B 先前暂停的内核调用链 A 的内核栈 等待事件 → schedule → switch_to B 的内核栈 上次 schedule 保存的恢复位置 换栈 A 的恢复位置保留下来 未来被重新选中后继续返回 CPU 现在沿 B 的调用链返回 完成切换收尾,再继续 B 的逻辑 B 不是调用 A 的后续代码;首次运行的新任务通过专门的启动跳板进入执行。

直接选择 A 或 B,再改变当前任务的局部值。对比共享与独立 mm:共享地址空间不会合并两条内核栈。

正在呈现知识画面
换到 B,为什么会沿 B 的调用链返回?

共享 mm 的线程仍有各自的内核栈。切换 SP 后,CPU 使用新任务自己的调用链与局部状态,收尾对象是本次刚换下的任务。

两类保存现场,不要混在一张寄存器表里

保存位置主要服务于哪次恢复
系统调用/异常入口帧,如适用的 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 的线程也有不同的线程局部存储。x86-64 的 FS/GS 相关状态等需要与当前任务匹配,否则同一条访问线程局部变量的指令可能读到别人的数据。perf、调试寄存器、I/O bitmap 等状态是否需要额外操作,还取决于任务启用了什么功能。

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 的页表,观察两个 CPU 的旧翻译为何都不能继续使用。

正在呈现知识画面
切换任务,等于清空所有翻译吗?

有效的 PCID 标记翻译可以跨 mm 切换保留;页表更新则要求旧翻译不再被使用,即使没有发生任务切换。

因此可将观察到的损失分成直接交接工作与恢复局部性的间接代价,并另外计算运行队列等待。它们存在联系,但只有在明确了测量边界后,才适合比较不同优化方案。

5. 等待协议:调度器不会替应用补上丢失的通知

错误协议很容易写出来:消费者先检查 ready == false,准备睡眠;生产者随后写入数据并唤醒等待队列;消费者最后才把自己放入队列。生产者看到空队列,没有人可唤醒,消费者却在条件已经为真的情况下睡下。若没有后续事件,程序可能永远等不到下一次通知。

丢失唤醒:通知发生时,等待者尚未登记 消费者 生产者 检查 ready = false 登记等待并睡眠 写入数据,令 ready = true wake:此时队列为空 若没有下一次通知,可能一直等待 正确协议让“登记等待 + 检查条件 + 进入睡眠”与发布者协调;单加 volatile 无法修补。

内核等待接口通过登记等待项、设置适当状态、检查条件并在需要时调度来协调这个窗口。条件的发布还要使用匹配的锁或内存序;READ_ONCE/WRITE_ONCE 防止特定编译访问变形,并不单独建立完整的跨 CPU 发布和等待协议。调用 wake_up() 也不是把一个持久事件塞进“通知邮箱”:被唤醒者应重新检查业务条件,多个等待者还可能竞争同一份数据。

修复窗口:让条件检查与进入等待协调起来

用户态条件变量表达相似约束。消费者持锁检查条件,pthread_cond_wait() 原子地释放互斥锁并进入等待;返回时重新持有锁,再用循环检查条件。生产者在同一把锁下改变条件。这样,即使某次通知发生时消费者尚未等待,消费者后来也能看到持久的条件值,不会仅因错过通知就必然阻塞。

尝试“检查 → 发布 → 等待”,再直接切换到同锁保护协议。操作顺序会保留,但锁改变了生产者能否在这个窗口内完成发布。

正在呈现知识画面
条件为真,消费者为什么还在睡?

检查后、登记等待前发生通知,可能让消费者在条件为真时睡下。同锁保护条件,并协调释放锁与进入等待,可以关闭这个窗口。

实验:交替 1000 轮,检查条件才是依据

下面用两线程交替执行 1000 轮,验证条件和等待的配合。保存为 handoff.c,编译命令为 cc -std=c11 -O2 -Wall -Wextra -Werror -pthread handoff.c -o handoff。两条线程只改自己的同步对象,不修改系统调度设置。

#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:等待返回并不保证条件必然满足,条件才是继续执行的依据。这里仅有一位对端等待者,signal 足够;多消费者设计需要重新考虑条件划分、唤醒数量和退出协议。

输出是这项同步工作负载的往返时间,包含互斥锁、条件变量、可能的 futex 系统调用、调度等待、线程启动与回收,不是纯上下文切换耗时。即使每轮都发生等待,也不能未经事件统计就把每轮固定折算成两次切换;两个线程还可能在不同 CPU 上执行。

6. 从计数到原因:应该测量哪一段

/proc/PID/status 或 getrusage() 的自愿/非自愿切换计数可以帮助判断变化方向,但“自愿”不等于业务代码主动调用 yield,阻塞等待也常在其中;“非自愿”也不等于系统发生故障。应把增量与完成的请求数、线程数、CPU 使用量放在同一窗口比较。

若研究唤醒延迟,可关联调度 tracepoint:记录目标 TID 的 sched_wakeup 时间,再找到它作为 next 的 sched_switch。需要处理重复唤醒、任务退出与 TID 复用、跨 CPU 时间戳以及采集窗口缺失,不能简单把任何两条同 PID 日志相减。得到的是唤醒到获得 CPU 的等待,包含竞争和相关调度路径,并没有隔离低层 switch_to 成本。

把指标变化与候选原因对应起来,再决定下一步:

观察结果值得继续验证的方向
切换更多,吞吐基本不变线程协作方式是否改变
尾延迟上升,运行队列等待也增长CPU 竞争、quota 与放置约束
排队不明显,迁移后执行变慢缓存局部性与 NUMA

这些是排查方向,不能单凭一项计数就下因果结论。减少线程、调整批量或锁粒度,都应针对已经确认的等待或执行成本。

阅读源码可依次从 kernel/sched/core.c 的 __schedule() / context_switch() 进入,再看架构 switch_to、mm/TLB 和 FPU 路径。调度器为何选择这个 next,回到调度器;信号如何使某些等待结束,以及为什么可能返回 EINTR,继续看信号与 ptrace。