第四十七章:上下文切换
结丹后期涉及内核源码:
一
林小源站在调度竞技场的中央,亲眼目睹了一场"换人"。
一个进程正在 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 的调度周期——六毫秒到二十四毫秒之间,根据可运行进程的数量动态调整。那正是在寻找这个平衡点。
"所以,"林小源说,"上下文切换是调度器的分辨率——分辨率越高,画面越精细,但渲染的代价也越大。"
老者露出一丝赞许的微笑:"不错。你理解了。"
/*
* 上下文切换的步骤:
*
* 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");#include <stdio.h>
/*
* 上下文切换的步骤:
*
* 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; /* 状态寄存器 */
};
int main() {
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");
return 0;
}道藏笔记
内核启示
上下文切换是调度器的核心操作。
上下文切换的步骤:
- 保存旧进程的寄存器状态
- 切换页表()
- 切换内核栈
- 恢复新进程的寄存器状态
- 新进程继续执行
上下文切换的开销:
- 保存/恢复寄存器:~10-20 ns
- 切换页表:~100-500 ns
- TLB 刷新:~1000+ ns(无 ASID)
- 缓存失效:不可量化(最大开销)
减少上下文切换的方法:
- 减少进程数
- 增加时间片
- CPU 亲和性
- ASID(避免 TLB 刷新)
上下文切换是调度的"代价"——越少越好,但不能没有。
上下文切换之试
上下文切换时若进入另一个用户进程,CPU 还要切换哪一个地址空间描述结构?