xv6 源码精读:sched 与 scheduler ------ 把 swtch 串成调度循环
本篇是 swtch.S 源码精读 的姊妹篇。上篇我们把
swtch拆到字母级,知道它「存旧现场、载新现场、ret 跳到新 ra」。这篇看proc.c里谁在调用swtch、怎么编排成完整调度 :scheduler(调度器循环)、sched(切走原语)、yield(主动让出)、forkret(新进程首跑)。
先建立全局视角:调度是「两个线程对切」
-
xv6 里每个 CPU 都有一个调度器线程 ,它运行
scheduler(),永不返回;每个用户进程在内核态也有一条内核线程 ,运行到某个点会调用sched()切走。 -
swtch是底层搬运工,而sched和scheduler是互相切换的两个协程(coroutine) :sched把 CPU 从「进程内核线程」切到「调度器线程」;scheduler把 CPU 从「调度器线程」切到「选中的进程内核线程」。
-
两者靠
p->lock这个进程锁来维护「切换过程中状态不变量」。理解了这个,整篇就通了。
调度器线程的本质:它到底是什么
结论:只有「调度器线程」会跑 scheduler(),而且每个 CPU 在内核里各有一个调度器线程。 它不是 struct proc、没有 PID、不是用户进程,而是「CPU 自己在没有运行任何进程时所处的那段执行上下文」。
1. 它的「身体」是 c->context
proc.h 里每个 CPU 有一份描述符,其中 context 字段就是调度器线程的现场:
c
struct cpu {
struct proc *proc; // 当前跑的进程,或 0
struct context context; // swtch() here to enter scheduler()
};
c->context 这个 struct context(上篇精读的 14 个寄存器)就是调度器线程的「身体」。前面 scheduler 源码里 swtch(&c->context, &p->context) 的 old 端,用的正是它。
2. 每个 CPU 都跑一个 scheduler()
main.c 里每个核启动时都进 scheduler():
c
void
main(void)
{
if(cpuid() == 0){ /* 一次性初始化 */ }
else { while(started == 0); }
scheduler(); // ← 所有核(每个 hart)最后都进这里
}
所以 N 个 CPU = N 个 scheduler() 调用 = N 个并行的调度器循环 ,各自独立扫进程表、各自挑进程。scheduler() 永不返回(死循环),它跑在每个 hart 自己的内核启动栈上。
别把这里的「线程」理解成 pthread------xv6 没有用户级线程。它是内核级概念,有时也叫 idle thread / 每 CPU 空闲循环。
3. 只有调度器线程能跑 scheduler()
分工是固定的,进程永远只调 sched()(让出),绝不会调 scheduler():
| 角色 | 谁在跑 | 函数 | 职责 |
|---|---|---|---|
| 调度器线程 | CPU 自己(c->context) |
scheduler() |
主动挑下一个进程,切过去 |
| 进程内核线程 | 某个 struct proc(p->context) |
sched() |
被动让出 CPU,切回调度器 |
调度决策只 发生在 scheduler() 里------它是 sched/scheduler 这对协程的「另一半」:进程 sched 切到调度器,scheduler 切到进程,两个上下文(p->context ↔ c->context)来回搬。进程没有「选下一个」的资格,只能放弃 CPU。
4. 调度器线程的作用
- 唯一分发器(dispatcher) :扫描进程表找
RUNNABLE的进程,置RUNNING、设c->proc=p、swtch切过去。调度集中在此,进程只能「让」不能「选」。 - 保存/恢复自己的 CPU 现场 :用
c->context当自己的寄存器现场,在「自己」和「选中进程」之间搬寄存器。 - 锁的接手方(完成「甲拿乙放」) :进程
yield带着p->lock切走,调度器在切回来之后(proc.c:485)release(&p->lock),把锁安全交还。 - 维护状态不变量 :持有
p->lock期间保证RUNNABLE → RUNNING的状态切换是原子的;放锁点选在最安全的位置(清完c->proc之后)。 - 空闲/idle 省电 :系统只剩
init和sh且都没在跑时(nproc <= 2),wfi让 CPU 休眠等中断,避免死循环空转烧 CPU。
一句话:调度器线程 = 每 CPU 的「空闲循环 + 分发器 + 锁接手方」,是进程之间切换必经的「中转站」。
完整源码
scheduler ------ 调度器主循环(proc.c:456)
c
456 void
457 scheduler(void)
458 {
459 struct proc *p;
460 struct cpu *c = mycpu();
461
462 c->proc = 0;
463 for(;;){
464 // Avoid deadlock by ensuring that devices can interrupt.
465 intr_on();
466
467 int nproc = 0;
468 for(p = proc; p < &proc[NPROC]; p++) {
469 acquire(&p->lock);
470 if(p->state != UNUSED) {
471 nproc++;
472 }
473 if(p->state == RUNNABLE) {
474 // Switch to chosen process. It is the process's job
475 // to release its lock and then reacquire it
476 // before jumping back to us.
477 p->state = RUNNING;
478 c->proc = p;
479 swtch(&c->context, &p->context);
480
481 // Process is done running for now.
482 // It should have changed its p->state before coming back.
483 c->proc = 0;
484 }
485 release(&p->lock);
486 }
487 if(nproc <= 2) { // only init and sh exist
488 intr_on();
489 asm volatile("wfi");
490 }
491 }
492 }
第 460 行 :struct cpu *c = mycpu(); 拿到当前 CPU 的描述符。每个 CPU 有自己的 struct cpu,里面有 c->proc(当前跑的进程)和 c->context(调度器自己的上下文)。
第 462 行 :c->proc = 0; 初始化------启动阶段还没有进程在跑。
第 463 行 :for(;;) 死循环,调度器永不返回(注释里也写了 Scheduler never returns)。
第 465 行 :intr_on(); 开中断。为什么?注释说「避免死锁,确保设备能中断」。调度器在扫描进程表时开着中断,这样时钟中断、设备中断能正常进来;否则整个 CPU 卡死。
第 467--472 行 :遍历进程表 proc[0..NPROC],数一下非 UNUSED 的进程个数(nproc)。这一步顺便也 acquire(&p->lock) 拿了锁------注意拿锁发生在进入循环体、判断 state 之前,这是后面「持锁调 swtch」约定的起点。
第 473--484 行 :核心分支。如果 p->state == RUNNABLE,说明这个进程可以跑:
- 第 477 行 :
p->state = RUNNING;把状态改成 RUNNING(占用 CPU 了)。 - 第 478 行 :
c->proc = p;告诉这个 CPU「现在跑的是 p」。 - 第 479 行 :
swtch(&c->context, &p->context);------ 切!把当前(调度器)现场存进c->context,从p->context加载进程现场。执行完这条指令后,CPU 就跑到进程 p 的内核线程里去了 (具体回到哪,取决于 p 上次sched里swtch保存的ra)。 - 第 483 行 :当进程 p 将来某时刻又
swtch切回调度器时,CPU 会从第 479 行swtch的下一条 继续------也就是执行到这里,把c->proc = 0清掉,表示「这个进程这次跑完了,暂时不占用 CPU」。
第 485 行 :release(&p->lock); 释放刚才拿的进程锁。(关键 :进程 p 是在持有自己 p->lock 的状态下被 swtch 切走的,锁随它一起「带着走」;要等它切回来、跑完、再回到调度器这里才释放。)
第 487--490 行 :如果整个系统只有 init 和 sh 两个进程(nproc <= 2)且都没在跑,就 wfi(wait for interrupt)让 CPU 休眠省电,等中断唤醒。
一个容易忽略的点 :调度器用的是
c->context作为「old」、进程的p->context作为「new」。所以swtch(&c->context, &p->context)和上篇讲的「存 old 载 new」完全一致------只是这里的 old 是调度器线程自己。
sched ------ 切走原语(proc.c:501)
c
494 // Switch to scheduler. Must hold only p->lock
495 // and have changed proc->state. Saves and restores
496 // intena because intena is a property of this
497 // kernel thread, not this CPU. It should
498 // be proc->intena and proc->noff, but that would
499 // break in the few places where a lock is held but
500 // there's no process.
501 void
502 sched(void)
503 {
504 int intena;
505 struct proc *p = myproc();
506
507 if(!holding(&p->lock))
508 panic("sched p->lock");
509 if(mycpu()->noff != 1)
510 panic("sched locks");
511 if(p->state == RUNNING)
512 panic("sched running");
513 if(intr_get())
514 panic("sched interruptible");
515
516 intena = mycpu()->intena;
517 swtch(&p->context, &mycpu()->context);
518 mycpu()->intena = intena;
519 }
注意函数头注释:「Switch to scheduler. Must hold only p->lock and have changed proc->state」 ------ 调 sched 之前,调用方必须已经拿着 p->lock,并且已经把 p->state 改好(比如 yield 改成 RUNNABLE)。
第 505 行 :struct proc *p = myproc(); 拿到当前进程。
第 507--514 行:四道安全断言(panic 检查),任何一个不满足就内核崩溃,防止「在不该切的时候切」:
!holding(&p->lock)→panic("sched p->lock"):没拿锁不能调,因为切换全程要靠这把锁保护状态不变量。mycpu()->noff != 1→panic("sched locks"):noff是push_off嵌套深度。值必须为 1,意味着除了p->lock之外不能再持有其他任何锁(否则切走后别的 CPU 想拿那些锁会死锁)。这是「Must hold only p->lock」的硬保证。p->state == RUNNING→panic("sched running"):状态不能是 RUNNING(要切走的进程状态应该是已经改成 RUNNABLE / SLEEPING 等),否则和「正在被调度器设为 RUNNING 后再切」的逻辑冲突。intr_get()→panic("sched interruptible"):中断必须已禁用 。因为swtch搬寄存器期间不能被中断打断,否则现场会被破坏。
第 516--518 行 :保存 / 恢复 intena(中断开关状态)。intena 是「这个内核线程」的属性(这个进程希望中断开还是关),不是 CPU 的属性。注释里解释了为什么用 cpu->intena 而不是 proc->intena(因为在少数「拿了锁但没有进程」的场景会崩)。切走前记下来,切回来后还原,保证线程的中断偏好不丢失。
第 517 行 :swtch(&p->context, &mycpu()->context); ------ 这是和 scheduler 里那句对偶 的切:把当前进程现场存进 p->context,从 c->context(调度器现场)加载回来。执行后 CPU 就回到 scheduler 第 479 行的 swtch 之后继续。这就是 book 里说的「swtch 在调度器的栈上返回,像是 scheduler 的 swtch 返回一样」。
yield ------ 主动让出 CPU(proc.c:522)
c
521 // Give up the CPU for one scheduling round.
522 void
523 yield(void)
524 {
525 struct proc *p = myproc();
526 acquire(&p->lock);
527 p->state = RUNNABLE;
528 sched();
529 release(&p->lock);
530 }
最干净的「让出 CPU」入口:
- 第 526 行 :
acquire(&p->lock);拿自己的锁。 - 第 527 行 :
p->state = RUNNABLE;标记自己可以再被调度(不再是 RUNNING)。 - 第 528 行 :
sched();切走。 - 第 529 行 :
release(&p->lock);------ 注意这行不是在切走前执行的,而是将来切回来之后才执行 !因为sched内部swtch之后要等很久(直到调度器下次选回这个进程),回来后才走到第 529 行释放锁。
yield 是被时钟中断(yield 在 usertrap 里被定时器调)或其它场景调用的,是「进程主动放弃 CPU 一个时间片」的标准姿势。
forkret ------ 新进程第一次被调度时的入口(proc.c:534)
c
532 // A fork child's very first scheduling by scheduler()
533 // will swtch to forkret.
534 void
535 forkret(void)
536 {
537 static int first = 1;
538
539 // Still holding p->lock from scheduler.
540 release(&myproc()->lock);
541
542 if (first) {
543 // File system initialization must be run in the context of a
544 // regular process (e.g., because it calls sleep), and thus cannot
545 // be run from main().
546 first = 0;
547 fsinit(ROOTDEV);
548 }
549
550 usertrapret();
551 }
这是理解「swtch 不一定回到 sched」的关键。
一个新 fork 出来的进程,第一次被调度器选中 时,p->context 里存的 ra 不是某个 sched 调用点,而是被 allocproc(建进程时)特意设成了 forkret 的地址。所以 scheduler 第 479 行 swtch 切过去后,新进程第一次「醒」来的入口是 forkret,而不是从某次 sched 返回。
- 第 540 行 :
release(&myproc()->lock);------ 注意注释「Still holding p->lock from scheduler」。新进程是带着调度器给它的p->lock进来的(scheduler第 469 行拿的锁,切走时没放),所以forkret的第一个任务就是替调度器把这个锁释放掉 。这就是为什么 book 说「xv6 在一个线程获取p->lock、在另一个线程释放」------这里是 scheduler 拿、forkret 放。 - 第 542--548 行 :如果是系统第一次跑(静态变量
first == 1),就在这个普通进程上下文里跑一次fsinit(ROOTDEV)初始化文件系统(因为main()里不能调sleep,得在进程上下文做)。只做一次。 - 第 550 行 :
usertrapret();准备返回用户态,新进程正式开始执行用户代码。
把四段代码拼成一次完整切换
先说清一个前提 :调度器本身也是一条内核线程,跑在 c->context 上、停在自己的 swtch 调用点。所以一次切换就是「进程内核线程」和「调度器线程」之间的两次 swtch 往返,不是「调度器主动扫描到谁就切谁」那么简单。
以「A 正在运行 → 时间片到 → 调度器改选 B 运行」为例,分三段看:
(1) A 让出 CPU(A 线程 → 调度器线程)
定时器中断 → usertrap → yield():
acquire(&A->lock) // A 拿自己的锁
A->state = RUNNABLE
sched():
swtch(&A->context, &c->context) // 存 A 现场,载入调度器现场
swtch 之后 CPU 不再跑 A,而是从调度器上次暂停的地方续上 ------即 scheduler() 里那句 swtch(&c->context, ...) 的下一条。
(2) 调度器收尾 + 选 B(调度器线程)
// 回到 scheduler 的 swtch 之后
c->proc = 0
release(&A->lock) // ★ A 的锁在这里释放(甲拿乙放:A 拿、调度器放)
... 扫描进程表,找到 B(RUNNABLE) ...
acquire(&B->lock)
B->state = RUNNING
c->proc = B
swtch(&c->context, &B->context) // 存调度器现场,载入 B 现场
(3) B 运行(B 线程)
// CPU 从 B 上次暂停处继续(首次则进 forkret)
B 执行... 时间片到或主动 yield → swtch(&B->context, &c->context)
→ 又回到 scheduler 的 swtch 之后,release(&B->lock),继续扫描...
一句话抓住重点 ------ 锁的「跨线程接力」 :
yield 拿 A->lock 后带着锁切走 ,调度器在切回来之后才 release。锁从「进程线程」交到「调度器线程」手里释放。这正是不寻常之处:平时谁拿谁放,这里必须甲拿乙放,否则切换中途另一个 CPU 可能把还没停栈的 A 又调度起来,两个 CPU 跑同一个内核栈必崩。
两个不变量
xv6 靠持有 p->lock 在切换时维护两条不变量:
不变量 1(进程是 RUNNING) :如果一个进程处于 RUNNING 状态,那么定时器中断的 yield 必须能安全地把它切走。这意味着:
- CPU 寄存器里存的就是该进程的寄存器值(
swtch还没把它们搬到context); c->proc指向该进程。
不变量 2(进程是 RUNNABLE):如果一个进程处于 RUNNABLE 状态,那么空闲 CPU 的调度器必须能安全地运行它。这意味着:
p->context里保存着该进程的寄存器(它们此刻不在真实寄存器里);- 没有 CPU 在该进程的内核栈上执行;
- 没有 CPU 的
c->proc引用它。
锁必须「跨线程」持有的根本原因 :一旦 yield 把 RUNNING 改成 RUNNABLE,在「不变量 2 完全恢复之前」(swtch 真正让进程停止用自己的内核栈之前),这把锁不能放 。否则另一个 CPU 可能在 A 刚标记 RUNNABLE、但还没真正停在自己的栈上时,就把 A 调度起来------两个 CPU 跑在同一个内核栈上,必崩。所以最早能安全放锁的点,是调度器在自己栈上把 c->proc 清掉之后(第 483--485 行)。
协程(coroutine)视角
xv6 book 点明:sched 和 scheduler 是彼此的协程。如果打印 xv6 切换发生的行号,会看到极规律的来回:
proc.c:475 (scheduler 里的 swtch) → proc.c:509 (sched 里的 swtch)
→ proc.c:475 → proc.c:509 ...
(注:行号随版本略有差异,仓库里 scheduler 的 swtch 在 479 行 、sched 的 swtch 在 517 行 ,所以你实际看到的是 475→509 的变体 479↔517。)
唯一的例外 就是 forkret:进程第一次被调度,不回到 sched,而是从 forkret 开始,由它释放锁、再 usertrapret 回用户态。
小结
- 调度器在哪、干什么? 每个 CPU 跑一个
scheduler()死循环,扫描进程表找RUNNABLE的进程,设RUNNING、设c->proc、swtch切过去;进程切回来后清c->proc、放锁,继续扫。 - sched 和 scheduler 的关系? 互为协程:
sched切到调度器,scheduler切到进程;来回切换,行号规律交替。 - 为什么 p->lock 是「甲拿乙放」? 切换期间要维护「RUNNING/RUNNABLE 状态不变量」,
yield改完状态到swtch真正让进程停栈之前锁不能放,所以最早的放锁点在调度器里(或 forkret 里)。 - sched 的四个 panic 检查分别防什么? 没拿锁 / 还持其他锁(noff≠1)/ 状态还是 RUNNING / 中断没关------任意一条不满足,切换都会破坏现场或死锁。
- 新进程第一次调度为什么从 forkret 开始?
allocproc把新进程的context.ra设成forkret;调度器swtch过去后先替调度器释放p->lock,再usertrapret回用户态。这是「swtch 不一定回到 sched」的唯一例外。
下一篇可以精读 sleep / wakeup,看 xv6 如何用 p->lock + chan 避免「丢失唤醒」,那是调度之外另一个用锁维护不变量的经典案例。