Skip to content

第四十七章:上下文切换

结丹后期

涉及内核源码:

林小源站在调度竞技场的中央,亲眼目睹了一场"换人"。

一个进程正在 CPU 上运行,突然,时钟中断响起。调度器做出了决定——下一个进程该上场了。但问题是:CPU 上还残留着旧进程的一切——寄存器里的值、栈指针的位置、页表的地址。如果不清除干净,新进程根本无法运行。

一个身穿灰袍的老者从暗处走出。他的袍子上绣满了寄存器的名字——x0 到 x31、PC、SP、SATP、sstatus。他的双手枯瘦如柴,但动作极快,手指在空中飞舞,仿佛在进行一场精密的手术。

"我是上下文切换,"老者说,"调度器的双手。每次调度器做出决定,都是我来执行。"

林小源看着老者的动作,目不暇接。只见他左手在旧进程身上一抓,一道光芒从旧进程体内抽出——那是它的全部"记忆":程序计数器、栈指针、通用寄存器、浮点寄存器、CSR 寄存器。这些"记忆"被封存在一块透明的水晶中。

"保存旧进程的寄存器状态,"老者边做边说,"这是第一步。"

然后,他的右手按在一块巨大的石碑上——那是页表。石碑上的文字开始变化,从旧进程的地址空间切换到新进程的地址空间。林小源感觉到一阵眩晕,仿佛整个世界的坐标系都被重新定义了。

"切换页表,"老者说,"这是第二步。也最耗时——切换页表后,TLB 中的缓存条目可能失效,需要重新建立映射。"

林小源看到无数细小的光点从石碑上飘落——那是 TLB 中的缓存条目,正在逐个失效。

"如果没有 ASID,"老者叹了口气,"整个 TLB 都要清空。代价高昂。"

林小源走近那块石碑,仔细观察 TLB 的变化。

"前辈,什么是 ASID?"他问。

老者停下手中的动作,指了指 TLB 上的标签:"ASID,Address Space ID。每个进程都有一个唯一的标签。TLB 条目带着这个标签,不同进程的 TLB 条目不会冲突。"

他抬手在空中画了一幅图:两个进程的虚拟地址都是 0x1000,但映射到不同的物理地址。如果没有 ASID,切换页表时必须清空 TLB,否则旧进程的缓存条目会干扰新进程。但有了 ASID,TLB 条目带着标签,新进程查找时只匹配自己的标签,不会被旧进程的条目干扰。

"ASID 让 TLB 刷新从'全部清空'变成'按标签过滤',"老者说,"开销从上千纳秒降到几乎为零。"

林小源恍然大悟。他看着老者继续完成切换:内核栈被切换到新进程的栈,新进程的寄存器状态从水晶中恢复出来,程序计数器被设置到新进程上次暂停的位置。

"恢复新进程的寄存器状态,"老者说,"这是第三步。"

最后,新进程开始执行。整个过程——从旧进程暂停到新进程恢复——通常只需要一到十微秒。

"但缓存失效的代价不可量化,"老者补充道,"如果新进程的数据不在缓存中,它可能需要从内存加载,这会花费数百纳秒甚至更多。"

林小源坐在老者身边,看着调度器不断做出决定,老者不断执行切换。

"前辈,切换太频繁会怎样?"林小源问。

老者摇摇头:"频繁切换意味着 CPU 时间浪费在保存恢复寄存器、切换页表、刷新 TLB 上,而不是做真正有用的工作。吞吐量下降。"

"那切换太少呢?"

"切换太少意味着进程等待 CPU 的时间变长,响应延迟升高。交互式应用会卡顿,用户体验下降。"老者看着林小源,"调度器需要在切换频率和响应延迟之间找到平衡。时间片太长,吞吐量高但响应慢;时间片太短,响应快但吞吐量低。"

林小源想起了 CFS 的调度周期——六毫秒到二十四毫秒之间,根据可运行进程的数量动态调整。那正是在寻找这个平衡点。

"所以,"林小源说,"上下文切换是调度器的分辨率——分辨率越高,画面越精细,但渲染的代价也越大。"

老者露出一丝赞许的微笑:"不错。你理解了。"


c
/*
 * 上下文切换的步骤:
 *
 * 1. 保存旧进程的寄存器状态
 *    - 通用寄存器 (x0-x31)
 *    - 程序计数器 (PC)
 *    - 栈指针 (SP)
 *    - 浮点寄存器 (f0-f31)
 *    - CSR 寄存器
 *
 * 2. 切换页表 (mm_struct)
 *    - 切换到新进程的地址空间
 *    - 刷新 TLB(除非 ASID)
 *
 * 3. 切换内核栈
 *    - 切换到新进程的内核栈
 *
 * 4. 恢复新进程的寄存器状态
 *    - 从新进程的 pt_regs 中恢复
 *
 * 5. 新进程继续执行
 */

struct pt_regs {
    unsigned long pc;
    unsigned long sp;
    unsigned long ra;
    unsigned long gp;
    unsigned long tp;
    unsigned long t0, t1, t2;
    unsigned long s0, s1;
    unsigned long a0, a1, a2, a3, a4, a5, a6, a7;
    /* ... 更多寄存器 */
};

struct context {
    struct pt_regs regs;
    unsigned long satp;     /* 页表寄存器 */
    unsigned long sstatus;  /* 状态寄存器 */
};

printf("=== 上下文切换 ===\n\n");

/* 旧进程的上下文 */
struct context old_ctx = {
    .regs = { .pc = 0x1000, .sp = 0x7fff0000, .a0 = 42 },
    .satp = 0x8000000000001234,
    .sstatus = 0x0000000a00006000,
};

/* 新进程的上下文 */
struct context new_ctx = {
    .regs = { .pc = 0x2000, .sp = 0x7ffe0000, .a0 = 0 },
    .satp = 0x8000000000005678,
    .sstatus = 0x0000000a00006000,
};

printf("旧进程上下文:\n");
printf("  PC:    0x%lx\n", old_ctx.regs.pc);
printf("  SP:    0x%lx\n", old_ctx.regs.sp);
printf("  SATP:  0x%lx\n", old_ctx.satp);
printf("\n");

printf("新进程上下文:\n");
printf("  PC:    0x%lx\n", new_ctx.regs.pc);
printf("  SP:    0x%lx\n", new_ctx.regs.sp);
printf("  SATP:  0x%lx\n", new_ctx.satp);

printf("\n--- 上下文切换的开销 ---\n");
printf("1. 保存/恢复寄存器: ~10-20 ns\n");
printf("2. 切换页表: ~100-500 ns\n");
printf("3. TLB 刷新: ~1000+ ns (无 ASID)\n");
printf("4. 缓存失效: 不可量化(最大开销)\n\n");
printf("总开销: 通常 1-10 微秒\n");
printf("但缓存失效可能导致数十微秒的延迟\n");

printf("\n--- 减少上下文切换的方法 ---\n");
printf("1. 减少进程数(更少的进程 = 更少的切换)\n");
printf("2. 增加时间片(更长的运行 = 更少的切换)\n");
printf("3. CPU 亲和性(减少跨 CPU 迁移)\n");
printf("4. ASID(避免 TLB 刷新)\n");

道藏笔记

内核启示

上下文切换是调度器的核心操作。

上下文切换的步骤:

  1. 保存旧进程的寄存器状态
  2. 切换页表(
  3. 切换内核栈
  4. 恢复新进程的寄存器状态
  5. 新进程继续执行

上下文切换的开销:

  • 保存/恢复寄存器:~10-20 ns
  • 切换页表:~100-500 ns
  • TLB 刷新:~1000+ ns(无 ASID)
  • 缓存失效:不可量化(最大开销)

减少上下文切换的方法:

  1. 减少进程数
  2. 增加时间片
  3. CPU 亲和性
  4. ASID(避免 TLB 刷新)

上下文切换是调度的"代价"——越少越好,但不能没有。


破关试炼

上下文切换之试

上下文切换时若进入另一个用户进程,CPU 还要切换哪一个地址空间描述结构?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道