xv6 调度过程经典场景详解
场景设定
- 初始状态:系统启动后,shell 进程正在运行。
- 启动 P1:用户在 shell 中输入
./prog1 &,启动 P1 进程在后台运行。fork()创建了 P1,其状态被设置为 RUNNABLE。 - 启动 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);
}
讲解:
yield()将 P1 的状态设置为 RUNNABLE,这样它未来还有机会被再次调度。sched()调用swtch(&p->context, &mycpu()->context)。注意这次的参数和第一步是相反的!&p->context:当前上下文(P1 的上下文)。&mycpu()->context:目标上下文(调度器的上下文)。
swtch保存 P1 的所有寄存器(程序计数器、栈指针等)到p->context中,然后恢复 scheduler 的寄存器。- 执行流回到了 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 调度的精髓:
- 调度(Scheduler):一个永恒的循环,负责从 RUNNABLE 队列中挑选进程。
- 切换(swtch):底层的魔术师,负责保存和恢复寄存器,实现执行流的跳转。
- 中断(Trap):外部事件(如时钟)的入口,是实现抢占式调度的基础。它通过调用 yield 来触发调度。
- 让出(Yield/Sleep):进程主动或被动让出 CPU,保证系统的公平与高效。