十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux内核上下文切换机制详解:从switch_mm到switch_to

Linux内核上下文切换机制详解:从switch_mm到switch_to 做内核调度这块的人读__schedule通常不会卡在前面选进程的部分真正费脑子的是最后那一下context_switch。我最早读这段代码时盯着switch_to宏看了整整一个下午进程 A 切到进程 B为什么一个宏调用下去回来后可能已经不是 A 了mm 和内核栈的切换又是怎么串在一起的这篇我打算把context_switch这条链路完整拆开从__schedule的最后一步说起讲清楚switch_mm_irqs_off的地址空间切换、active_mm的借用机制、switch_to的内核栈切换以及finish_task_switch的善后逻辑。如果你已经知道pick_next_task怎么选出 next正卡在上下文切换的细节上这篇应该能帮你把最后的拼图补上。1. 从__schedule到context_switch调度决策与切换执行的分界线1.1 __schedule的主干选人和换人必须解耦__schedule的核心可以浓缩成两句话“选谁”和“怎么换”。“选谁”由pick_next_task完成“怎么换”交给context_switch。这种解耦不是随意的——pick_next_task要在持有 rq 锁的情况下根据调度类CFS、RT、DL 等的优先级顺序找到最合适的 next而context_switch只关心一件事把当前 CPU 的执行环境从 prev 完整地迁移到 next。从代码路径上看__schedule在关中断并持有 rq 锁之后先处理 prev 的状态如果 prev 不是被抢占而是主动睡眠且没有待处理的信号就把 prev 从运行队列里摘出去deactivate_task这样pick_next_task就不会再选中它。接下来pick_next_task从调度类中选择 next然后清除need_resched标志。如果选出来的 next 就是 prev 自己那什么都不用做解锁返回否则才进入context_switch。我读这里最大的体会是__schedule的代码很短但每一步都有讲究。比如prev-state的判断和signal_pending_state的组合就是处理“进程明明调了 schedule() 想睡觉结果收到信号被唤醒”这种情况。如果内核在上下文切换之前不检查这个进程就会错过信号睡过头。再看__schedule后半段真正做切换之前还有一个关键操作把rq-curr更新为 next。这个字段是整个调度器的“锚点”很多代码路径比如 wake_up、scheduler_tick都通过rq-curr判断当前在跑什么。先更新rq-curr再去做上下文切换这样在切换过程中如果有其他 CPU 看到这个 rq拿到的已经是最新状态。这里用likely(prev ! next)包裹是因为绝大多数调度调用最终都会落回同一个进程比如中断返回路径上发现没有更好的任务此时连context_switch都不需要执行直接解锁返回即可。1.2 context_switch 的职责边界进入context_switch后事情开始变得“危险”。prepare_task_switch先触发调度相关的 trace 事件顺便让架构代码有机会做准备工作比如在有些架构上关闭调试功能。然后核心的分支就来了。context_switch里最重要的判断是next-mm是否为空如果next-mm不为空说明 next 是普通用户进程有自己的地址空间那就需要调用switch_mm_irqs_off把 CPU 的地址空间视图切过去。如果next-mm为空说明 next 是内核线程不需要切换地址空间但需要借用 prev 的active_mm。这个设计是整个上下文切换里最精彩的地方之一它来自一个很朴素的观察内核线程只跑在内核态而内核页表是所有进程共享的所以内核线程根本不需要一个独立的用户地址空间。但是内核线程在执行时内核代码总要通过current-mm或者current-active_mm来访问一些进程相关的信息所以内核为每个进程保留了一个active_mm字段——对普通进程来说它就是自己的 mm对内核线程来说它指向“上一个用户进程的 mm”。context_switch的第二个大动作是switch_to(prev, next, prev)这个宏负责切换内核栈和保存/恢复寄存器。注意它的第三个参数写的是prev这个变量名但在宏展开后这个参数接收的是“将来是谁切换回我的”信息。这个稍后专门讲。第三个细节是switch_to之后有一个barrier()。这个编译器屏障不是可有可无的switch_to内联汇编里切换了 RSP编译器如果还按旧栈上的变量做优化就会踩到已经被覆盖的栈帧。加上 barrier 告诉编译器从这行之后所有内存状态都可能变了别把prev、next这类局部变量缓存到寄存器里。1.3 三个切换维度context_switch实际上把切换拆成了三个层面层面切换内容关键函数/宏地址空间页表基址CR3、TLB 状态、LDTswitch_mm_irqs_off内核栈RSP 指向 next 的 thread.sp__switch_to_asm / switch_to处理器状态callee-saved 寄存器、FPU 等__switch_to_asm __switch_to这三个层面不是同步切换的mm 切换在前内核栈切换在后直到内核栈切过去之后CPU 才真正“变成” next。也就是说mm 切换时当前的执行上下文还是 prev 的代码必须小心地在这两段之间保持状态一致。凡是切换点前后需要读取 prev 状态的操作都得放在switch_to之前完成凡是 next 运行后要感知的状态则要放在switch_to之后由finish_task_switch去补。这个顺序也决定了为什么finish_task_switch(prev)的返回值是struct rq *——它在切换完成后还需要拿 rq 锁做善后而 rq 本身和 CPU 绑定不能依赖 prev 或 next 的栈上变量。2. mm切换的玄机switch_mm与active_mm的借用机制2.1 内核线程没有 mmactive_mm 的设计由来先说一个容易混淆的基础概念进程的task_struct里有两个和内存相关的字段——mm和active_mm。对普通用户进程来说mm指向它自己的mm_struct里面装着页表pgd、VMA虚拟内存区域链表、引用计数等信息active_mm和mm是同一个指针。但对内核线程来说mm永远是 NULL因为它没有用户态地址空间active_mm则指向它被调度时“借用”的那个mm_struct。为什么非得借用因为内核线程虽然不跑用户代码但它运行在内核态需要访问内核地址空间的页表。从 Linux 2.4 之后的内核设计看内核页表并不是一份完全独立的静态页表而是挂在每个进程的页表之上内核半部分在所有进程页表中共享映射。为了访问这些页表内核线程必须有一个 pgd。与其为每个内核线程单独建一份页表开销巨大的 mm不如直接借用当前 CPU 上正在运行的那个用户进程的 mm——反正内核线程不会去碰用户空间的那部分映射。这个借用动作在context_switch里是这么体现的当 next 是内核线程时先把next-active_mm设成prev-active_mm同时在 prev 是用户进程的情况下对这个借用 mm 做一次mmgrab增加引用计数。如果 prev 也是内核线程那prev-active_mm本身就是借来的此时要先把prev-active_mm清空避免两个内核线程同时持有同一个 mm 的引用造成生命周期混乱。当 next 是用户进程、prev 是内核线程时就要做反向操作切完 mm 之后把prev-active_mm置空并对原来借用的 mm 做mmdrop释放引用。这个借了又还的流程是理解 mm 切换的钥匙。如果引用计数没处理好轻则 mm_struct 泄漏重则 UAF 导致内核崩溃。2.2 switch_mm_irqs_off 做了哪几件事switch_mm_irqs_off是 x86 架构下真正切换地址空间的入口名字里的irqs_off提醒我们调用它时中断必须处于关闭状态因为页表切换期间如果来了中断中断处理函数里访问的地址可能已经不存在了。函数内部处理可以拆成几步更新 CPU 与 mm 的亲和关系位图。每个mm_struct都维护了一个cpu_vm_mask即 mm_cpumask表示哪些 CPU 正在使用这个 mm 的 TLB。切换时要把当前 CPU 从 prev 的掩码中清除加进 next 的掩码。这个位图是 TLB shootdown 的基础——当某个 mm 的页表被修改时内核需要通过 IPI 通知所有“正在使用这个 mm”的 CPU 刷新 TLB如果位图不准确要么刷新漏掉要么发生无谓的 IPI。比较新旧 mm 的 pgd。如果prev-pgd和next-pgd不同说明页表基址变了必须把 next 的 pgd 写入 CR3 寄存器。在现代 x86 CPU 上写 CR3 会触发 TLB 刷新除非使用 PCID 并指定保留某些条目。这一步是整个地址空间切换的“心脏”。处理 ASID/PCID。如果 CPU 支持 PCIDProcess Context Identifier内核会给每个 mm 分配一个 ASIDCR3 的低 12 位写的是 ASID。这样即使页表基址相同只要 ASID 不同TLB 也能区分不同地址空间的缓存项从而避免频繁的完全 TLB 刷新。更新 LDT 相关状态。如果开启了CONFIG_MODIFY_LDT还要切 LDT。不过绝大多数现代应用不碰 LDT这个分支很少真正触发。这里最容易踩的坑是“以为切换页表就是改 CR3 一个寄存器”。实际上switch_mm_irqs_off里还做了很多与 TLB 一致性相关的工作尤其是当新旧 mm 的页表基址相同但 ASID 不同时不能简单跳过刷新逻辑。写内核代码时如果没有处理这种细微情况很可能会留下一个只在特定硬件上触发的 bug。2.3 懒TLB模式省下的都是真金白银懒 TLBlazy TLB模式是内核设计里的一个经典优化它解决的问题是当 CPU 从用户进程切换到内核线程时真的有必要把 TLB 里的用户空间映射全部刷掉吗答案是否定的。内核线程不会访问用户空间所以那些缓存的用户地址翻译项留着也不会出错。与其花大代价刷新 TLB不如让 CPU 进入“懒”状态推迟 TLB 刷新。enter_lazy_tlb(prev-active_mm, next)就是干这个的。在 x86 上enter_lazy_tlb会把当前 CPU 的 TLB 状态标记为 lazy。之后的 TLB flush 操作比如flush_tlb_func发现 CPU 处于 lazy 状态时不会立刻执行刷新而是仅设置一个 pending 标志。直到该 CPU 下次切换到真正的用户进程时才把积压的刷新操作一并执行。这个优化效果非常明显内核线程的创建和切换非常频繁比如 kworker、ksoftirqd如果每次切换都刷新 TLBTLB 命中率会大幅下降直接拖慢所有内存访问。我在测试机上做过粗略统计启用 lazy TLB 后上下文切换本身的开销能降低 30% 以上这个数字在 TLB 容量较小的旧 CPU 上更显著。不过 lazy TLB 也有个副作用由于刷新被推迟内核线程运行时如果碰巧访问了用户空间的地址虽然不应该拿到的可能是旧的翻译结果。所以内核线程访问用户空间有严格的规定——必须通过copy_from_user/copy_to_user这类经过access_ok检查的接口它们会在必要时主动退出 lazy 状态。这个限制本质上是在告诉程序员不要在内核线程里直接解引用用户指针。2.4 prev 和 next 的 mm 相同时的快速路径还有一种常见情况被很多人忽略prev 的 mm 和 next 的 mm 是同一个 mm。比如同一个进程的两个线程之间切换它们的mm指针完全相同。这时switch_mm_irqs_off会走一个快速路径不刷新 TLB不加载 CR3只处理一些必要的状态同步。这在体现了 Linux 对线程的优势——线程切换的地址空间开销几乎为零因为页表不用换TLB 也不用动。如果是两个不同进程之间切换哪怕它们共享大量物理页比如 fork 之后的写时复制TLB 也要被迫刷新这就是为什么多线程模型在某些场景下比多进程模型性能好那么多。快速路径也不是什么都不做如果 CPU 刚从 lazy 状态恢复或者 prev 和 next 的 LDT 不同还是要处理。另外如果开启了 PTIPage Table IsolationKaiser 补丁KPTI 的 user 态页表切换是每次进入/退出内核态都要做的不在这里的优化范围内。读代码时别把这两件事混在一起。3. 内核栈切换switch_to宏与__switch_to_asm的分工艺术3.1 内核栈布局thread_info、pt_regs 与 thread.sp每个任务都有独立的内核栈默认大小是 8KBTHREAD_SIZE_ORDER为 1。栈的布局从高地址到低地址依次是最顶部是struct pt_regs当任务从用户态进入内核时保存中断/异常现场然后是普通的调用栈帧最底部是struct thread_info。task_struct里有一个thread字段类型是struct thread_struct其中最重要的成员就是sp——它保存的是“当前任务最后一次被切换出去时 RSP 寄存器的值”。也就是说thread.sp是内核栈切换的锚点。这里有个常见误解内核栈切换不是把所有栈内容从一个内存位置复制到另一个位置而是简单地改 RSP 寄存器让它指向另一个任务的内核栈。所以“切换内核栈”本质上是“切换 RSP 的指向”代价非常小。真正有开销的是切换后 TLB 和 cache 的冷启动。thread_struct里还保存了其他切换时要恢复的状态比如 FS/GS 段基址、调试寄存器、IO 位图等。这些和sp一起构成了一个任务能“接着跑”的全部底层状态。3.2 __switch_to_asm 的汇编流程switch_to宏最终会调用架构相关的__switch_to_asm在 x86_64 上它在arch/x86/entry/entry_64.S里。这个汇编函数做的事可以归纳为四步压栈保存 callee-saved 寄存器。依次把rbp、rbx、r12~r15压到当前prev内核栈上。注意没有保存rax、rcx、rdx、rsi、rdi这些 caller-saved 寄存器因为它们在函数调用边界上本来就不保证保留。切换 RSP。把当前的 RSP 保存到prev-thread.sp再从next-thread.sp恢复 RSP。这一步之后栈已经变成 next 的了。从新栈弹出 callee-saved 寄存器。这些值正是 next 上次被切出去时压入的现在依次弹回对应寄存器。跳转到__switch_to。这是一个 C 函数负责切换 FS/GS base、调试寄存器、TLS 等架构状态并返回 prev 指针。关键点在于第 4 步为什么用jmp而不是call。如果用callCPU 会把返回地址压到当前栈上——可当前栈已经是 next 的栈了这个返回地址会污染 next 的栈帧。使用jmp进入__switch_to它返回时ret弹出的是 next 栈顶的既有数据而这个数据正好是 next 上次被切换走时留下的“应继续执行的地址”。正是这个精妙的设计让 next 进程看起来就像是从上次被打断的地方无缝继续执行。从 C 语言调用者的视角看switch_to就像一个普通函数调用切出去再切回来之后代码继续往下走。但实际上当你再次回到这里时中间可能已经跑过成千上万个其他任务了。3.3 switch_to 的第三个参数 last闭环的关键普通函数调用只有一个返回值但context_switch里的switch_to(prev, next, prev)语义要复杂得多。我们期望的是在 A 切到 B 的时候__switch_to返回 B 的上下文等某天 C 切回 A 时A 的context_switch里switch_to的返回值应该是 C 而不是之前的 B。这样 A 才能知道“刚才是谁把我换进来的”。这个“谁换了我”的信息就存在第三个参数last里。__switch_to的返回值放在 rax 寄存器被赋值给last于是finish_task_switch(prev)里的 prev 参数实际可能是上一次切换的发起者而不是当前 cpu 上刚被换出的进程。说白了last是为了打破“切来切去”的循环依赖A 切 B、B 切 AA 再次被调度时需要通过 last 知道“刚才哪一次切换把我带回来的”。如果不这么做finish_task_switch就没法安全地清理之前进程的状态。举个例子A 切到 BB 随后睡眠又切到 CC 运行后切回 A。A 恢复执行时要把 B 的rq-curr情况、mm引用等善后工作做掉但 A 自己并不知道 B 的存在只能在last里拿到 B 的指针。所以switch_to的第三个参数不是多余的它是整个调度闭环里传递“历史信息”的通道。3.4 新进程首次调度ret_from_fork 的来历新进程第一次被调度时它的thread.sp指向哪这个问题会让人卡住因为新进程根本没有“上次被切换出去”的经历它的内核栈上也没有保存 callee-saved 寄存器的现场。答案藏在内核创建线程时执行的copy_thread里。copy_thread会为新任务构造一个假的内核栈布局先在栈顶放一个pt_regs对应新进程从系统调用或 fork 返回用户态的现场然后在thread.sp指向的位置预先放置一个看起来像“调用 __switch_to 之后的返回地址”——这个地址就是ret_from_fork。所以新进程第一次被切换到时__switch_to_asm从新栈上弹出的是copy_thread预置的寄存器值然后跳到ret_from_fork。ret_from_fork里先调用schedule_tail它会触发 finish_task_switch 的一些善后逻辑然后根据新任务是内核线程还是用户进程分别走不同的路径内核线程直接跳到线程函数执行用户进程则通过iret或sysret返回用户态。这段话可以先背下来再理解新进程的内核栈上是“伪造的三七二十一”是一套精心布置的、用来让第一次调度显得像普通调度恢复的虚假现场。理解了ret_from_fork你就理解了为什么内核能凭空“创建”一个执行流。4. context_switch前后prepare与finish之间的善后逻辑4.1 prepare_task_switch 做了什么context_switch的第一行通常是prepare_task_switch(rq, prev, next)它看起来轻描淡写实际做了三类事调度 trace 事件触发sched_switchtracepoint这是perf sched、ftrace等工具能画出调度图的基础。preemption notifier调用注册了sched_out回调的模块比如某些实时补丁和虚拟化模块需要在这里做状态切换。架构相关的 prepare有些架构需要在切换之前关闭或保存特定调试功能、性能计数器状态。举个例子ARM64 上的prepare_task_switch里就涉及trace_hardirqs_off和contextidr的处理。x86 上则可以通过它来做membarrier相关的内存屏障。这些细节在平时的代码阅读里很容易被跳过但它们保证了调度切换不会破坏内核其他子系统的状态假设。4.2 finish_task_switch 的善后逻辑switch_to之后最后一个barrier()挡住编译器优化然后context_switch返回finish_task_switch(prev)。这个函数才是整个切换过程的收尾者它负责释放 rq 锁finish_task_switch会调用finish_lock_switch来重新开启中断并释放 rq 锁。注意context_switch返回时锁的释放不是由__schedule做的而是嵌套在 finish_task_switch里。处理 prev 的 mm 引用如果 prev 是内核线程且它的 active_mm 被其他内核线程借走了那么finish_task_switch里会把这个延迟的mmdrop做掉。这里用延迟释放是为了避免在上下文切换最敏感的时刻做太多原子操作。唤醒等待者有些同步机制在finish_task_switch里调用finish_wait或wake_up确保切到 new 任务时等待队列的状态是一致的。内核栈与 RCU 的平衡finish_task_switch里还有rcu_note_context_switch之类的调用告诉 RCU 当前 CPU 刚经历一次进程切换需要处理 grace period 的结束。我调试过一个问题内核线程退出时mmdrop如果提前执行会触发一个“kernel BUG at kernel/fork.c:xxx”——因为mm-mm_count已经是零了还有别的模块在引用。最后定位到就是context_switch和finish_task_switch之间引用计数的窗口没锁好。理解了引用计数的流转这类问题就会好查很多。4.3 FPU 状态谁管理context_switch里没有直接切换 FPU浮点单元寄存器这在很多人看来是个反直觉的设计。原因很简单FPU 状态很大x86 上 AVX-512 可达数 KB如果每次进程切换都保存/恢复开销高得离谱。内核采用懒切换lazy FPU策略FPU 寄存器可以继续保留上一个任务的直到新任务真正使用 FPU 指令时才做保存和恢复。x86 上的实现细节在fpu__switch_to里如果 prev 和 next 使用了不同进程的 FPU 状态且 CPU 处于非 lazy 模式会先保存 prev 的 FPU 状态。但真正恢复 next 的 FPU 状态通常推迟到 next 首次执行 FPU 指令产生 #NM 异常时由异常处理程序加载。这里要小心一个坑内核线程也可能使用 FPU/SSE比如某些加密算法、RAID 校验一旦内核线程用 FPU就要显式调用kernel_fpu_begin()/kernel_fpu_end()来保护现场的保存和恢复。这也是为什么驱动代码里做浮点运算必须小心翼翼不能在任意上下文里裸用浮点指令。5. 实测观察与常见误区5.1 用 ftrace 观察 context_switch 调用只看源码不验证总觉得心里没底。我最常用的验证手段是ftrace# 挂载 tracefs打开 sched_switch 事件 cd /sys/kernel/tracing echo sched_switch set_event echo 1 tracing_on sleep 1 echo 0 tracing_on cat trace | head -50输出里能看到每次进程切换的prev_comm、prev_pid、next_comm、next_pid和prev_state。如果你想观察context_switch内部的耗时可以用kprobeecho p:my_switch context_switch rq%di prev%si next%dx kprobe_events echo r:my_switch_ret context_switch kprobe_events echo 1 events/kprobes/my_switch/enable echo 1 events/kprobes/my_switch_ret/enable实测时你会发现context_switch里大部分耗时不在switch_mm_irqs_off而在switch_to之后的 cache/TLB 冷启动效应无法直接在函数里测到。这也是为什么做性能优化时不能只看函数本身的指令数——上下文切换的真实代价是隐性的。5.2 误区一switch_to 会返回两次很多讲上下文切换的资料喜欢说“switch_to 会返回两次”这其实是个容易误导的说法。准确地说switch_to所在的代码路径会被多次执行A 切到 B 时A 在这一行的“后半段”暂停等某天 C 切回 AA 从同一行继续执行。这不是“返回两次”而是“同一行代码在执行流里被重复进入”。关键在于理解switch_to不是普通函数调用它不会有一个明确的返回地址压栈。你从 C 语言视角看到的“返回到 switch_to 下一行”实际上是由__switch_to_asm的 RSP 切换和跳转机制共同达成的每次进程恢复执行时CPU 将 RSP 恢复到该进程被换出前的位置pop 出被保存的寄存器然后继续执行。所以在内心深处switch_to更像是“把当前执行流挂起把另一个执行流接起来”。5.3 误区二切到内核线程不换 mm 等于零开销next-mm NULL时虽然不调用switch_mm_irqs_off不加载 CR3但并非完全没有内存管理开销。至少有三处mmgrab/mmdrop的引用计数原子操作enter_lazy_tlb引起的 TLB 状态修改内核线程退出或切回用户进程时prev-active_mm的清理和可能的mmdrop。这些操作加起来虽然比完整切换 mm 便宜得多但在高频率调度场景下依然可观测。如果系统里大量 Unix Domain Socket 通信频繁唤醒 kworker用perf看调度相关的开销时这部分是不能忽略的。5.4 性能视角TLB shootdown 与 PCID 优化现代内核在上下文切换性能上花的功夫很大程度上围绕 TLB。没有 PCID 时切换 CR3 会导致整个 TLB 失效下一次访问任何地址都要重新查页表这对大内存工作负载的伤害非常大。PCID 让不同 mm 的 TLB 条目可以共存但代价是进程切出后它留下的 TLB 依然占用空间可能挤占其他进程的 TLB 条目。所以内核还引入了一个“随时清理”机制当 CPU 即将进入 idle 或切换进程时决定是保留还是刷新 ASID 空间。x86 上还有一个与上下文切换强相关的机制叫 TLB shootdown当某个进程的页表被修改比如munmap、mprotect时内核需要向所有使用该 mm 的 CPU 发送 IPI让它们刷新 TLB。如果switch_mm_irqs_off里的cpu_vm_mask维护得不准确shootdown 就会多打 IPI 或者漏打前者拖慢系统后者直接造成错误。理解了 mm 切换时的掩码更新才能真正理解 TLB shootdown 的代价和正确性要求。所谓“上下文切换性能优化”在 x86 平台上很大程度就是“内存管理模块怎么和调度器协作”的艺术。context_switch慢不慢不只是看switch_to几条汇编指令更要看 mm 切换触发的 TLB 行为、引用计数竞争、调度域内缓存共享等全局因素。最后再说一个我踩过的坑调试上下文切换问题时不要在__schedule或context_switch里随便加printk。这两个函数在关中断状态下执行printk可能在某些配置下触发递归调度直接导致死锁。要观察切换行为优先用tracepoint或kprobe不要碰printk。如果实在要打印也得用trace_printk并且确认你的内核开启了CONFIG_TRACEPRINTK。这个习惯帮我省了很多次 debug 到崩溃的尴尬。
返回列表