6.1810 / 6.1810 · 第 13 课 · rtm

线程切换

xv6 如何实现进程/线程切换?本节课深入内核,通过 GDB 跟踪 timer 中断、yield()、sched()、swtch() 的完整流程,理解上下文切换的本质:保存和恢复寄存器。

授课教师:rtm

日期:2025 年春季

难度:中等

代码:xv6 kernel/proc.c, kernel/swtch.S

为什么需要并发任务?

什么是线程?

想象正在以普通串行方式执行的代码。如果我们想暂停它、放到一边,做其他事,稍后再恢复那段代码——我们需要保存什么?

线程在执行代码和挂起时的保存状态之间交替。线程管理系统管理线程间的切换。


xv6 如何使用线程

xv6 进程/线程的保存状态

保存内容说明
用户内存进程的虚拟地址空间
用户保存寄存器(trapframe, TF)用户态寄存器,通过 trampoline 保存
内核栈线程在内核态使用的栈
内核保存寄存器(context, CTX)内核态寄存器,通过 swtch 保存
p->state进程状态(RUNNING/RUNNABLE/SLEEPING)

p->state 的作用

状态含义
RUNNING正在 CPU 上执行(用户态或内核态)
RUNNABLE想执行,但未运行(在内核中挂起)
SLEEPING等待某事件(如 read(), wait())

上下文切换(Context Switch)

从用户进程 P1 到 P2 的完整路径

用户态 → 内核态:保存用户寄存器到 trapframe
内核线程 → 调度器线程:保存内核线程寄存器到 context
调度器线程 → 内核线程:从 context 恢复内核线程寄存器
内核态 → 用户态:从 trapframe 恢复用户寄存器

struct proc 关键字段

struct proc {
  struct trapframe *trapframe;  // 保存的用户寄存器
  struct context context;       // 保存的内核寄存器
  uint64 kstack;                // 内核栈指针
  enum procstate state;         // RUNNING/RUNNABLE/SLEEPING
  struct spinlock lock;         // 保护 p->state 等
  // ...
};

为什么有独立的调度器线程?


代码跟踪:抢占式切换演示

设置

两个 CPU 密集进程 aaa 和 bbb,QEMU 单核,通过定时器中断驱动切换。

跟踪流程

  1. 在 trap.c:85(定时器中断)设断点
  2. 进入 usertrap() → 发现是设备中断(定时器)
  3. step into yield()
  4. p->state = RUNNABLE——放弃 CPU 但想再次运行
  5. 注意:yield() 获取 p->lock,防止另一个 CPU 运行此线程!
  6. step into sched()——做 sanity checks,然后调用 swtch()

swtch() —— 上下文切换的核心

kernel/swtch.S 的伪代码:

swtch(a0=旧上下文, a1=新上下文):
  保存 ra, sp, s0-s11 到 a0(旧上下文的内存)
  从 a1 加载 ra, sp, s0-s11
  ret  // 跳转到新上下文的 ra

关键洞察

swtch 为什么只保存 14 个寄存器?

RISC-V 有 32 个寄存器,swtch 保存 ra, sp, s0-s11(14 个)。其他 18 个呢?

寄存器为什么不保存
zero, gp, tp恒定的或全局/线程指针
t0-t6, a0-a7临时参数/调用者保存(caller-saved),在函数调用时已被保存到栈

swtch 不需要保存/恢复 PC?

因为 ret 跳转到 ra——PC 实际上是通过 ra 隐式切换的。


p->lock 的跨线程释放

不寻常的模式

yield():              scheduler():
  acquire(&p->lock);     swtch();
  p->state = RUNNABLE;   release(&p->lock);
  swtch();

锁由不同线程释放!yield 在进程的内核线程中获取,scheduler 在调度器线程中释放。

为什么在 swtch 期间持有 p->lock?


内核抢占

内核代码能被抢占吗?

是的!kerneltrap() 也调用 yield()。三套保存的寄存器:

保存内容保存位置保存者
用户寄存器trapframetrampoline
被打断的内核代码寄存器栈kernelvec
中断处理程序寄存器contextswtch

内核抢占是否必要?

不必要(内核没有无限 CPU 密集循环),但如果某些系统调用计算量大,或需要严格的线程优先级,则很有价值。


重要规则

为什么不允许持有自旋锁跨越上下文切换?(除了 p->lock)

// 单核上的灾难场景:
P1: acquire(L1);    P2: acquire(L2);
   sched();             acquire(L1); // 等待 L1
// P2 自旋等待 L1,中断已禁用
// 无时钟中断 → P2 不 yield → P1 不运行
// P1 不释放 L1 → 死锁!

解决方案:禁止在持有锁时放弃 CPU!(除了 p->lock)

为什么调度器启用中断?

可能没有 RUNNABLE 线程(都在等待 I/O)。启用中断让设备有机会完成并唤醒线程,否则系统会冻结。

线程高效吗?


测验

Q1:swtch() 如何实现线程切换?

Q2:为什么在 swtch 期间持有 p->lock?

Q3:为什么不允许持有自旋锁(除 p->lock)跨越上下文切换?

Q4:xv6 的内核抢占是如何工作的?

Q5:调度器启用中断(intr_on)的原因是什么?

要点总结

上下文切换本质

保存当前寄存器,恢复目标寄存器,切换栈,跳转到目标返回地址。

调度器线程

每个 CPU 一个独立调度器线程,确保始终有栈可供调度循环运行。

p->lock 跨线程

yield 获取,scheduler 释放——保护从 RUNNABLE 设置到寄存器保存完成的整个过程。

抢占规则

内核可被抢占(通过 kerneltrap),但持有自旋锁时禁止 yield,避免死锁。

延伸阅读