TaterLi 个人博客

[内核学习]任务切换

单核再忙,同一时刻也只有一个任务在跑.所谓"多任务",是调度器在毫秒尺度上不停换人:保存旧任务的现场,恢复新任务的现场,放它上CPU.这篇把这个"换人"动作拆开看:一次切换到底贵多少(实测),切换点在哪发生(两种,计数器分开记),以及最底层的 cpu_switch_to 那二十条指令在换什么.

这篇回答几件事:

先测成本,管道 ping-pong 法:两个执行流隔着一对管道互相发一个字节,A 写 B 读 B 写 A 读,一轮往返等于两次切换,跑两万轮取平均,计时用 1.1 篇认过的 cntvct.钉在 cpu4(A72)上,线程对和进程对各测一遍:

线程对: 3501 ns/切换   (同进程两线程, 不换 mm)
进程对: 4047 ns/切换   (两进程, 换 mm, 贵约 15%)

先把丑话说在前面:这个数是"两次系统调用加一次切换"的打包价,管道的 read/write 本身就要进出内核各一回,纯切换的裸价要更小.但有意义的部分是相对差:进程对每次多换一套地址空间,activate_mm 换 TTBR0,顺带把旧进程的 ASID 相关 TLB 作废,这 15% 就是地址空间隔离的过路费.这也是各类高性能服务(worker 池,数据库后台线程)偏爱线程的量化注脚 — 隔离该花的花,能省的省.

切换发生的位置有两种,内核分开记账./proc/PID/status 里两行,实测两个极端场景.一个 CPU 密集任务和别的任务抢一个核,两秒钟:

voluntary_ctxt_switches: 1 / nonvoluntary: 245

它从来不想睡,每次下CPU都是被抢占的 — 非自愿.反过来一个反复 sleep 的循环:

voluntary_ctxt_switches: 281 / nonvoluntary: 4

几乎全是主动睡 — 自愿.这对计数器是免费的性能体检表:延迟敏感的任务非自愿数高,说明它在被频繁抢占,可以考虑绑核或提优先级;自愿数高而吞吐低,说明时间都花在等待上,该查它等的资源.系统级汇总在 /proc/stat 的 ctxt 字段,那两个数的总和再乘个放大系数就是系统总切换量.

内核侧流程,schedule() 一路向下:先关抢占拿住 rq 锁,挑下一个任务(pick_next_task,按上一节的调度类顺序问),然后 context_switch.它干三件事:如果新旧任务地址空间不同,切换页表基址;调用 switch_to 宏,传 prev 和 next;还有一个形似玄学的第三参数 — switch_to(prev, next, prev) 最后那个 prev 是输出,因为切换回来时栈上世界已变,调用者需要内核帮忙把"上一个任务"的指针递回来,这是 C 语言的函数调用约定和任务切换语义打架的地方,内核用这个土办法和解.

最底层在 arch/arm64 的 cpu_switch_to,这段汇编值得盯着看:把 x19 到 x28,fp,sp 存进旧任务的 task_struct 里预留的 cpu_context 结构 — 注意只存这十几个,因为 x0-x18 属于调用者保存寄存器,切换发生前它们早被 C 代码的红酒杯规则(scheduler 调用约定)处理完了;然后 sp 指向新任务的 cpu_context,同样一组寄存器弹出来,ret 走人.妙处在最后一步顺手把 sp_el0 换成新任务的 task_struct 指针 — 1.1 篇说过内核拿 SP_EL0 白嫖存 current,兑换的时刻就在这:current 不是个变量,是每次切换都要搬一次的行李.整段代码不到三十条指令,换的是 CPU 的全部"人格":寄存器,栈,地址空间,和身份.

最后留几个容易忽略的细节:

退出移动版