跳转到正文
Siolin'Log
返回

xv6 进程切换

概述

这篇只关心一件事:一个正在用户态运行的进程,如何因为时钟中断让出 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 恢复用户态寄存器,回到用户态继续运行

这个过程可以分成三段:

  1. 中断把 A 带进内核:这是 trap 机制负责的。
  2. 内核让 A 经由 yield() 交出 CPU:这是 yield -> sched -> swtch 负责的。
  3. scheduler 选择 B 并切过去:这是调度器循环和第二次 swtch 负责的。

关键心智模型是:进程不会直接切到另一个进程;它先切回当前 CPU 的 scheduler,再由 scheduler 切到下一个进程。

时钟中断进入 usertrap

在这个版本的 xv6 中,machine timer interrupt 会先进入 timervectimervec 设置 sip.SSIP,把它转发为 supervisor software interrupt;CPU 随后才通过 uservecusertrap() 进入常规的用户态 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);
}

这一段做了三件事:

  1. 取得 p->lock,保护 A 的进程状态
  2. 把 A 从 RUNNING 改回 RUNNABLE
  3. 调用 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;
}

这些检查保证几件事:

真正的切换发生在:

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() 本身并不知道“进程”是什么,也不负责选择下一个进程。它只是按照两个 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 做两件事:

  1. 认为刚才那个进程“本轮运行结束”,设置 c->proc = 0
  2. 释放该进程的 p->lock,然后继续扫描进程表

这里有一个容易忽略的点:p->lock 会跨 swtch 交接。

简单来说就是:

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 最终会走到:

usertrapret()
  -> userret
  -> sret
  -> 回到 B 的用户态 pc


上一篇
Java 线程池:参数、任务调度与拒绝策略
下一篇
英语学习:Anki+查拉查词+HypperTTS