概述
这篇只关心一件事:一个正在用户态运行的进程,如何因为时钟中断让出 CPU,再由 scheduler 选择接下来运行的进程。
trap 的基础流程见 xv6 Trap 机制;理解下面的切换路径时,需要特别区分保存用户态现场的 trapframe 和保存内核调度现场的 context。
下面假设用户进程 A 被时钟中断打断后,scheduler 选择了另一个 RUNNABLE 进程 B:
A 在用户态运行
-> machine timer interrupt 进入 timervec
-> timervec 转发为 supervisor software interrupt
-> uservec 保存 A 的用户态寄存器到 A.trapframe
-> usertrap() 识别时钟中断
-> yield()
-> sched()
-> swtch(&A.context, &cpu.context)
-> 回到当前 CPU 的 scheduler()
-> scheduler() 选择 RUNNABLE 的进程 B
-> swtch(&cpu.context, &B.context)
-> 回到 B 上次停下的内核执行流(首次调度则进入 forkret)
-> 如果 B 随后准备返回用户态,则进入 usertrapret()
-> userret 从 B.trapframe 恢复用户态寄存器,回到用户态继续运行
这个过程可以分成三段:
- 中断把 A 带进内核:这是 trap 机制负责的。
- 内核让 A 经由
yield()交出 CPU:这是yield -> sched -> swtch负责的。 - scheduler 选择 B 并切过去:这是调度器循环和第二次
swtch负责的。
关键心智模型是:进程不会直接切到另一个进程;它先切回当前 CPU 的 scheduler,再由 scheduler 切到下一个进程。
时钟中断进入 usertrap
在这个版本的 xv6 中,machine timer interrupt 会先进入 timervec。timervec 设置 sip.SSIP,把它转发为 supervisor software interrupt;CPU 随后才通过 uservec 和 usertrap() 进入常规的用户态 trap 路径。
对进程切换来说,最重要的是:
A 的用户态寄存器已经保存在 A.trapframe
A 现在运行在内核态
当前 C 函数路径进入 usertrap()
usertrap() 会调用 devintr() 判断 trap 来源。如果 which_dev == 2,说明收到了由 machine timer interrupt 转发而来的 software interrupt:
if(which_dev == 2)
yield();
这里要注意:时钟中断只是触发了让出 CPU 的机会,真正的进程切换还没有发生。 此时 A 仍然是当前 CPU 的运行进程,只是已经从用户态进入了内核态。
yield():把 A 改回 RUNNABLE
yield() 的语义是:当前进程暂时不继续占用 CPU,但它还可以继续运行。
void
yield(void)
{
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE;
sched();
release(&p->lock);
}
这一段做了三件事:
- 取得
p->lock,保护 A 的进程状态 - 把 A 从
RUNNING改回RUNNABLE - 调用
sched(),准备切回 scheduler
RUNNABLE 的含义不是“正在运行”,而是“以后还可以被 scheduler 选中”。所以时钟中断导致的抢占并不会让 A 睡眠或退出,只是把它放回可运行集合。
sched():切回当前 CPU 的 scheduler
sched() 是进入 swtch() 前的检查层:
void
sched(void)
{
int intena;
struct proc *p = myproc();
if(!holding(&p->lock))
panic("sched p->lock");
if(mycpu()->noff != 1)
panic("sched locks");
if(p->state == RUNNING)
panic("sched running");
if(intr_get())
panic("sched interruptible");
intena = mycpu()->intena;
swtch(&p->context, &mycpu()->context);
mycpu()->intena = intena;
}
这些检查保证几件事:
- 当前进程已经不再是
RUNNING - 当前进程锁还握在手里,scheduler 接手后能看到一致状态
- 中断处于关闭状态,避免切换中途被再次打断
真正的切换发生在:
swtch(&p->context, &mycpu()->context);
对 A 来说,这句话的意思是:
把当前内核执行流保存到 A.context
加载当前 CPU 的 scheduler context
ret 后回到 scheduler() 之前停下的位置
这里保存的是内核调度上下文,不是用户态寄存器。用户态寄存器已经在 trap 入口保存进 A.trapframe 了。
swtch():只换内核执行流
swtch(old, new) 的行为很小:
保存 ra/sp/s0-s11 到 old
从 new 恢复 ra/sp/s0-s11
ret
在这条主线里,只需要记住:
- 第一次
swtch(&A.context, &cpu.context):从 A 的内核执行流切回 scheduler。 - 第二次
swtch(&cpu.context, &B.context):从 scheduler 切到 B 的内核执行流。
swtch() 本身并不知道“进程”是什么,也不负责选择下一个进程。它只是按照两个 struct context 指针保存和恢复寄存器。
scheduler 接回控制权
每个 CPU 都运行一个不会返回的 scheduler() 循环:
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
intr_on();
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);
c->proc = 0;
}
release(&p->lock);
}
}
}
当 A 通过 swtch(&A.context, &cpu.context) 切回 scheduler 后,执行流会回到 scheduler 中上一次调用 swtch(&c->context, &p->context) 的下一行。
这时 scheduler 做两件事:
- 认为刚才那个进程“本轮运行结束”,设置
c->proc = 0 - 释放该进程的
p->lock,然后继续扫描进程表
这里有一个容易忽略的点:p->lock 会跨 swtch 交接。
- A 在
yield()中拿到自己的锁并切回 scheduler 后,scheduler 会在swtch返回后的路径上释放这把锁;而当 scheduler 将来再次选中 A 时,会先重新拿到 A 的锁,再swtch回 A。 - A 从
sched()返回到yield()后执行的release(&p->lock),释放的是 scheduler 为这次切入 A 而持有的锁。
简单来说就是:
- A 在
yield()中获取自己的锁后调用swtch;切换到 scheduler 从swtch返回后,scheduler 释放 A 的锁 - scheduler 选中 A,获取 A 的锁;A 从
sched返回到yield后,释放自己的锁
scheduler 切到 B
scheduler 扫描进程表,找到某个 RUNNABLE 的进程 B 后:
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);
xv6 只是顺序扫描 proc[] 并选择遇到的 RUNNABLE 进程,并不保证一定切到与 A 不同的进程。这里继续沿用前面的假设:本轮选中的是 B;如果没有更合适的进程,A 之后也可能再次被选中。
这次 swtch 的方向反过来:
保存 scheduler 当前执行流到 cpu.context
加载 B.context
ret 后回到 B 上次被切走的位置
B 可能有两种情况:
- B 曾经运行过:它会从上一次
sched()中的swtch(&p->context, &mycpu()->context)返回,然后沿着当时的内核路径继续。 - B 是第一次被调度:
allocproc()预先把B.context.ra设为forkret,所以第一次swtch到 B 时会从forkret()开始,最后进入usertrapret()。
对于时钟中断抢占后恢复用户态的常见路径,B 最终会走到:
usertrapret()
-> userret
-> sret
-> 回到 B 的用户态 pc