MIT 6.S081 xv6 源码精读(Scheduling):sched、scheduler

xv6 源码精读:sched 与 scheduler ------ 把 swtch 串成调度循环


本篇是 swtch.S 源码精读 的姊妹篇。上篇我们把 swtch 拆到字母级,知道它「存旧现场、载新现场、ret 跳到新 ra」。这篇看 proc.c谁在调用 swtch、怎么编排成完整调度scheduler(调度器循环)、sched(切走原语)、yield(主动让出)、forkret(新进程首跑)。


先建立全局视角:调度是「两个线程对切」

  • xv6 里每个 CPU 都有一个调度器线程 ,它运行 scheduler(),永不返回;每个用户进程在内核态也有一条内核线程 ,运行到某个点会调用 sched() 切走。

  • swtch 是底层搬运工,而 schedscheduler互相切换的两个协程(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 procp->context sched() 被动让出 CPU,切回调度器

调度决策 发生在 scheduler() 里------它是 sched/scheduler 这对协程的「另一半」:进程 sched 切到调度器,scheduler 切到进程,两个上下文(p->contextc->context)来回搬。进程没有「选下一个」的资格,只能放弃 CPU。

4. 调度器线程的作用

  1. 唯一分发器(dispatcher) :扫描进程表找 RUNNABLE 的进程,置 RUNNING、设 c->proc=pswtch 切过去。调度集中在此,进程只能「让」不能「选」。
  2. 保存/恢复自己的 CPU 现场 :用 c->context 当自己的寄存器现场,在「自己」和「选中进程」之间搬寄存器。
  3. 锁的接手方(完成「甲拿乙放」) :进程 yield 带着 p->lock 切走,调度器在切回来之后(proc.c:485)release(&p->lock),把锁安全交还。
  4. 维护状态不变量 :持有 p->lock 期间保证 RUNNABLE → RUNNING 的状态切换是原子的;放锁点选在最安全的位置(清完 c->proc 之后)。
  5. 空闲/idle 省电 :系统只剩 initsh 且都没在跑时(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 上次 schedswtch 保存的 ra)。
  • 第 483 行 :当进程 p 将来某时刻又 swtch 切回调度器时,CPU 会从第 479 行 swtch 的下一条 继续------也就是执行到这里,把 c->proc = 0 清掉,表示「这个进程这次跑完了,暂时不占用 CPU」。

第 485 行release(&p->lock); 释放刚才拿的进程锁。(关键 :进程 p 是在持有自己 p->lock 的状态下被 swtch 切走的,锁随它一起「带着走」;要等它切回来、跑完、再回到调度器这里才释放。)

第 487--490 行 :如果整个系统只有 initsh 两个进程(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 != 1panic("sched locks")noffpush_off 嵌套深度。值必须为 1,意味着除了 p->lock 之外不能再持有其他任何锁(否则切走后别的 CPU 想拿那些锁会死锁)。这是「Must hold only p->lock」的硬保证。
  • p->state == RUNNINGpanic("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」入口:

  1. 第 526 行acquire(&p->lock); 拿自己的锁。
  2. 第 527 行p->state = RUNNABLE; 标记自己可以再被调度(不再是 RUNNING)。
  3. 第 528 行sched(); 切走。
  4. 第 529 行release(&p->lock); ------ 注意这行不是在切走前执行的,而是将来切回来之后才执行 !因为 sched 内部 swtch 之后要等很久(直到调度器下次选回这个进程),回来后才走到第 529 行释放锁。

yield 是被时钟中断(yieldusertrap 里被定时器调)或其它场景调用的,是「进程主动放弃 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),继续扫描...

一句话抓住重点 ------ 锁的「跨线程接力」

yieldA->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 点明:schedscheduler 是彼此的协程。如果打印 xv6 切换发生的行号,会看到极规律的来回:

复制代码
proc.c:475 (scheduler 里的 swtch)  →  proc.c:509 (sched 里的 swtch)
→  proc.c:475                     →  proc.c:509  ...

(注:行号随版本略有差异,仓库里 schedulerswtch479 行schedswtch517 行 ,所以你实际看到的是 475→509 的变体 479↔517。)

唯一的例外 就是 forkret:进程第一次被调度,不回到 sched,而是从 forkret 开始,由它释放锁、再 usertrapret 回用户态。


小结

  1. 调度器在哪、干什么? 每个 CPU 跑一个 scheduler() 死循环,扫描进程表找 RUNNABLE 的进程,设 RUNNING、设 c->procswtch 切过去;进程切回来后清 c->proc、放锁,继续扫。
  2. sched 和 scheduler 的关系? 互为协程:sched 切到调度器,scheduler 切到进程;来回切换,行号规律交替。
  3. 为什么 p->lock 是「甲拿乙放」? 切换期间要维护「RUNNING/RUNNABLE 状态不变量」,yield 改完状态到 swtch 真正让进程停栈之前锁不能放,所以最早的放锁点在调度器里(或 forkret 里)。
  4. sched 的四个 panic 检查分别防什么? 没拿锁 / 还持其他锁(noff≠1)/ 状态还是 RUNNING / 中断没关------任意一条不满足,切换都会破坏现场或死锁。
  5. 新进程第一次调度为什么从 forkret 开始? allocproc 把新进程的 context.ra 设成 forkret;调度器 swtch 过去后先替调度器释放 p->lock,再 usertrapret 回用户态。这是「swtch 不一定回到 sched」的唯一例外。

下一篇可以精读 sleep / wakeup,看 xv6 如何用 p->lock + chan 避免「丢失唤醒」,那是调度之外另一个用锁维护不变量的经典案例。

相关推荐
书生执笔画浮沉2 小时前
记录一个由浮点运算引起的崩溃
linux
xx~t2 小时前
嵌入式——Linux软件编程2
linux·运维·服务器·c语言·学习方法
Shell运维手记3 小时前
Linux (挂载|rpm|yum|源码编译)
linux·运维·笔记·网络协议·bash
RisunJan3 小时前
Linux命令-vgchange(修改 LVM 卷组属性)
linux·运维·服务器
ZMacros3 小时前
Buildroot构建文件系统
linux·服务器
深念Y4 小时前
AI Agent 时代运维安全:rm 防误删方案对比
linux·运维·人工智能·安全·自动化·agent
hkNaruto4 小时前
【Linux】Ubuntu 22.04 LTS GNOME 原生远程桌面配置:告别vnc、nomachine
linux·运维·ubuntu
崇子嵘5 小时前
基于zynqMPsoc15eg的linux端axi dma教训总结
linux·运维·服务器
动力 continue5 小时前
Linux 基础篇 · Vim 三种模式与常用指令
linux·运维·vim