xv6 调度过程经典场景详解

场景设定

  1. 初始状态:系统启动后,shell 进程正在运行。
  2. 启动 P1:用户在 shell 中输入 ./prog1 &,启动 P1 进程在后台运行。fork() 创建了 P1,其状态被设置为 RUNNABLE。
  3. 启动 P2:用户接着输入 ./prog2,启动 P2 进程在前台运行。fork() 创建了 P2,其状态也是 RUNNABLE。

现在,进程表里至少有 shell、P1、P2 三个进程处于 RUNNABLE 状态。我们假设调度器接下来选择了 P1 来运行。


第一步:调度器选择 P1 运行

CPU 当前在 scheduler 函数的循环中。scheduler 的任务就是找到一个 RUNNABLE 的进程。

代码位置:kernel/proc.c

// 每个CPU核心的调度线程
void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();

  c->proc = 0;
  for(;;){ // 无限循环,永不停止
    // 在进程表中寻找一个可运行的进程
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      if(p->state == RUNNABLE) { // <--- 找到了!假设这里找到了 P1
        // 切换到该进程
        p->state = RUNNING; // 1. 标记 P1 为 RUNNING
        c->proc = p;        // 2. CPU 记录下当前运行的是 P1

        // 3. 核心:进行上下文切换,从 scheduler 切换到 P1
        // swtch() 会保存 scheduler 的寄存器,然后加载 P1 的寄存器
        // 当 swtch 返回时,代码已经运行在 P1 的内核栈上了
        swtch(&c->context, &p->context);

        // --- P1 正在执行 ---
        // ... 经过一段时间后,P1 会因为某种原因放弃 CPU ...
        // 当 P1 放弃 CPU 后,swtch 会返回到这里

        // 4. P1 已经放弃 CPU,清理工作
        c->proc = 0;
      }
      release(&p->lock);
    }
  }
}

讲解:

scheduler 遍历进程表,发现了 P1 的状态是 RUNNABLE。它将 P1 的状态改为 RUNNING,然后调用 swtch(&c->context, &p->context)。这个调用是关键:

  • &c->context:当前上下文(调度器的上下文)。
  • &p->context:目标上下文(P1 的上下文)。

swtch 会保存 scheduler 的寄存器(比如栈指针)到 c->context,然后从 p->context 中加载 P1 的寄存器。执行流神奇地“跳转”到了 P1 上次被中断的地方,P1 开始在用户空间执行它的代码(比如 prog1 的 main 函数)。


第二步:时间片耗尽,硬件中断发生

P1 正在愉快地执行。突然,硬件定时器发出一个中断信号。CPU 会立即停止执行 P1 的代码,并跳转到内核的中断处理程序。

代码位置:kernel/trap.c

// 用户空间代码产生的中断、异常或系统调用都会进入这里
void
usertrap(void)
{
  // ... 省略其他类型的 trap 处理 ...

  // scause 寄存器告诉我们中断原因
  // 8 表示是 supervisor 模式下的外部中断
  // 5 表示是 supervisor 模式下的时钟中断
  if(r_scause() == 8 && (which_dev = devintr()) != 0){
    // 是外部设备中断
    if(which_dev == 2) // <--- 2 号设备就是时钟中断
      yield(); // <--- 关键!时间到了,主动让出 CPU

    // ...
    return;
  }

  // ...
}

讲解:

CPU 跳转到 usertrap。代码检查中断原因,发现是时钟中断 (which_dev == 2)。这时,它会调用 yield(),告诉内核:“P1 的时间片用完了,该轮到别人了”。


第三步:P1 主动让出,切换回调度器

yield() 的逻辑非常直接:把自己变回 RUNNABLE,然后调用 sched() 来启动一次调度。

代码位置:kernel/proc.c

// 进程主动让出 CPU
void
yield(void)
{
  struct proc *p = myproc();
  acquire(&p->lock);
  p->state = RUNNABLE; // 1. 将自己的状态从 RUNNING 改回 RUNNABLE
  sched();             // 2. 调用 sched() 启动调度
  release(&p->lock);
}

// 切换到调度器
void
sched(void)
{
  struct proc *p = myproc();
  // ... 省略检查 ...

  // 2. 切换回 scheduler 的上下文
  //    保存 P1 的当前上下文到 p->context
  //    恢复 scheduler 的上下文 c->context
  swtch(&p->context, &mycpu()->context);
}

讲解:

  1. yield() 将 P1 的状态设置为 RUNNABLE,这样它未来还有机会被再次调度。
  2. sched() 调用 swtch(&p->context, &mycpu()->context)。注意这次的参数和第一步是相反的!
    • &p->context:当前上下文(P1 的上下文)。
    • &mycpu()->context:目标上下文(调度器的上下文)。
  3. swtch 保存 P1 的所有寄存器(程序计数器、栈指针等)到 p->context 中,然后恢复 scheduler 的寄存器。
  4. 执行流回到了 scheduler 函数中,正好是上次调用 swtch 的下一行!

第四步:调度器选择 P2 运行

现在 CPU 又回到了 scheduler 的主循环中。

它会从上次找到 P1 的地方继续向后遍历进程表。很快,它会发现 P2 的状态也是 RUNNABLE。

此时,整个流程就和第一步完全一样了,只不过这次的主角是 P2。scheduler 会将 P2 的状态设为 RUNNING,然后 swtch 到 P2 的上下文中,P2 开始执行。


场景:进程因 I/O 而睡眠

我们再考虑一种不同的情况:P2 不是因为时间片用完,而是需要读取文件。比如执行 cat file.txt

当 cat 进程调用 read() 系统调用,而磁盘数据尚未准备好时,它不能空等,必须让出 CPU。

代码位置:kernel/file.c(示例)

fileread -> virtio_disk_rw -> … 最终会调用 sleep

代码位置:kernel/proc.c

// 在某个 channel 上原子地释放锁并睡眠
void
sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();

  // ...

  // 切换到调度器,但在那之前,必须设置好自己的状态
  p->chan = chan;         // 1. 记录下自己在等什么 (e.g., 磁盘数据)
  p->state = SLEEPING;    // 2. 将状态设置为 SLEEPING

  sched();                // 3. 调用 sched() 切换到调度器

  // --- 当磁盘数据准备好后,wakeup 会唤醒我,最终会从这里返回 ---

  p->chan = 0; // 清理 channel
  // ...
}

当磁盘操作完成,会触发一个磁盘中断。中断处理程序最终会调用 wakeup(chan),将所有等待在这个 chan 上的进程(包括我们的 cat 进程)的状态从 SLEEPING 改回 RUNNABLE,这样它们就有机会再次被调度器选中。


总结

这个完整的循环展示了 xv6 调度的精髓:

  1. 调度(Scheduler):一个永恒的循环,负责从 RUNNABLE 队列中挑选进程。
  2. 切换(swtch):底层的魔术师,负责保存和恢复寄存器,实现执行流的跳转。
  3. 中断(Trap):外部事件(如时钟)的入口,是实现抢占式调度的基础。它通过调用 yield 来触发调度。
  4. 让出(Yield/Sleep):进程主动或被动让出 CPU,保证系统的公平与高效。