Skip to content

Lec 13 线程切换与调度

本讲定位:前面我们学习了系统调用,中断,页表和锁,本节我们再往底层探索,学习线程/进程的切换。

本讲讲线程概念、抢占式调度、xv6 的 线程切换/scheduler 协程式切换,以及 p->lock 的特殊用法。

一、线程与多路复用

当进程数远多于 CPU 数,需把多个进程多路复用到 CPU 上、给每个进程"独占 CPU"的假象。

为什么要支持并发任务?

Sol:因为它们能支持

  1. 分时共享(多个程序同时运行):支持多用户可以执行多个任务,让用户感觉同时在运行多个任务。
  2. 更清晰的程序结构。 即使只有一个 CPU,很多问题天然适合拆成多个任务,比如shell, grep error log.txt | sort | uniq,三个进程同时工作。如果全写成一个顺序程序会分复杂。
  3. 并行编程的需要真正的多核并行,利用多核 CPU 获得真正的并行执行能力。
什么是(线程 thread)?一个串行执行流,即程序一条指令接一条指令的顺序执行。 因为我们想要保存下来,之后再恢复它,所以每个线程都有自己的状态:程序计数器(当前执行的指令)、寄存器(保存程序的变量)、栈(用于存储函数调用记录和局部变量,反映该线程执行中的当前点)。

一个 CPU 上千个线程时,策略是分时切换:跑一会儿一个线程,保存其状态,切到下一个。

线程系统的关键区别是否共享内存。 一种可能是,你可以有一个单独的地址空间,许多线程在该地址空间中执行,它们可以看到彼此的变动,如果共享内存的一个线程修改了变量,那么其它共享该内存的线程会看到修改。所以,在线程共享内存的情况下,我们需要锁。

xv6如何使用线程?

每个进程有一个内核线程(负责执行其系统调用,所有内核线程共享内核地址空间)和一个用户线程(执行用户指令,各进程地址空间独立)。

用户线程和内核线程不是并发的,一个xv6进程同一时刻,只能在一个地方执行,要么用户空间要么内核空间。

  • 如果在内核执行,用户线程会等待trap返回
  • 如果在用户态执行,执行流中讲没有内核线程。

用户线程和内核线程其实是同一个执行流,只是在用户态和内核态之间切换。

为什么这里本节lec中进程和线程可以混用?

因为,xv6 每进程仅一个线程,一一对应,不支持用户级多线程,例如xv6的scheduler()调度的是struct proc

对比 Linux:支持一个进程内多线程共享内存(可并行加速),实现更复杂。

线程之外还有事件驱动/状态机等多任务方式,但线程通常最方便。

为什么需要线程 以及 线程切换?
  • 人们希望计算机能够支持多任务处理;简
  • 化程序结构,通过使用线程,可以优雅地分解复杂的程序,降低编程的复杂性
    • 比如实验0的素数筛选的例子
  • 多核并行加速,可以充分利用多核CPU

二、抢占式调度

线程系统三大挑战:

  1. 线程切换与调度(决定谁运行、交替执行,由调度器负责,xv6 每核一个调度器);
  2. 保存/恢复线程状态(存哪、何时存、如何准确恢复);
  3. 处理计算密集线程(不会主动让出 CPU)。
Definition 抢占式调度 (preemptive scheduling定时器中断周期性夺取 CPU(即使运行代码不愿让出),把控制交给内核,内核再自愿 yield 给调度器。反义是自愿式调度。

机制:每核硬件定时器周期性(如每 10ms)中断 → 控制转入内核中断处理 → 内核 yield 自愿让出给调度器。线程状态:RUNNING、RUNNABLE、SLEEPING 等。

RUNNING vs RUNNABLE 的状态在哪? RUNNING 线程的 PC/寄存器在 CPU 硬件寄存器里;RUNNABLE 线程没有关联 CPU,故其全部 CPU 状态必须已保存到内存。把 RUNNING 转为 RUNNABLE,就是把寄存器从 CPU 复制到内存;反之再复制回 CPU。

三、xv6 的间接切换:用户↔内核↔调度器

定义(xv6 不做用户→用户直接切换)每次进程切换都经过一次内核线程切换:从进程 A 的内核线程切到进程 B 的内核线程,B 再返回其用户空间。

完整流程:用户进程 P1 运行 → 定时器中断 → trampoline 保存用户寄存器到 trapframe、usertrap 处理 → P1 在内核里(如等 I/O)决定让出 → 调 swtch 保存 P1 内核线程寄存器到 p->context、切到本核调度器线程 → 调度器遍历进程表找到 RUNNABLE 的 P2 → swtch 切到 P2 的 context → P2 内核线程恢复、最终返回用户空间。

定义(两个上下文存储位置)trapframe用户寄存器;p->context内核线程寄存器(swtch 时用);每核调度器的 context 存在 struct cpu

schedulersched 互为协程 (coroutine):内核线程只在 sched 里让出 CPU、且总切到调度器同一位置,调度器又几乎总切回某个之前调过 sched 的线程。新进程例外:allocproc 把新进程 ra 设为 forkret,故其首次 swtch "返回"到 forkret(释放 p->lock 后走 usertrapret 回用户态)。


四、p->lock 的特殊用法

例题1(为什么 swtch 时必须持有 p->lock,且在一个线程里 acquire、在另一个线程里 release?)

因为 p->lock 保护进程的 state 与 context 等不变量,而 swtch 执行期间这些不变量并不成立。例如:若 yield 已把 P1 状态设为 RUNNABLE 但 swtch 还没停止使用其内核栈时锁就被释放,另一个核可能看到 RUNNABLE 就开始运行 P1 → 两核同时跑在同一个栈上,灾难。所以锁必须从 yield 修改状态起一直持有,直到调度器(在自己的栈上)清掉 c->proc、不变量恢复后才释放——这就打破了"谁 acquire 谁 release"的惯例(yield 里 acquire、scheduler 里 release)。

定义(mycpu / myproc)多核下用每核的 tp 寄存器存 hartid 来索引 struct cpu 数组,从而找到当前核/当前进程。

mycpu 的返回值脆弱:若定时器中断使线程换核,旧值失效——故调用者需禁用中断后使用。myproc 则禁中断调 mycpu 取 c->proc,其返回值即使开中断也安全(换核后 struct proc 指针不变)。


五、swtch 的机制:寄存器到底怎么换

swtch(old, new) 是上下文切换的核心,它只做一件事:把当前寄存器存进 old(即 p->context,对应汇编里的 a0),再从 new(即调度器的 c->context,对应 a1)加载寄存器。它不区分“线程”,只是在两组寄存器之间倒腾。

为什么 swtch 只保存 14 个寄存器(ra、sp、s0–s11)?RISC-V 有 32 个寄存器,另外 18 个呢?因为 swtch 是一次普通的函数调用,编译器遵守调用约定:调用方已经把 caller-saved 寄存器(t0–t6a0–a7)该存的存到栈上了,swtch 无需理会;zero 恒为 0、gp(全局指针)、tp(hartid)不需要随线程切换。剩下需要 swtch 亲自保存/恢复的,就是 callee-saved 寄存器:返回地址 ra、栈指针 sp、以及 s0–s11,共 14 个。
为什么 swtch 不需要保存/恢复 PC(程序计数器)?因为切换是通过保存/恢复 ra 实现的。swtch 加载新线程的 ra 后执行 retret 会跳到 ra 指向的地址——也就是“新线程上一次调用 swtch 的下一条指令”。所以新 PC 是被 ret 隐式恢复的,无需单独保存。

控制流的“乒乓”ret 返回到哪由 ra 决定,而新 ra 来自 c->context.ra,它正好是调度器上次调用 swtch 的返回地址。于是从某进程 sched 里调 swtch,会“神奇地”出现在 scheduler 里——仿佛是 scheduler 之前那次 swtch 返回了。加载新 sp 则等价于换栈,因为每个线程有自己的内核栈(保存各自的调用者参数、局部变量、返回地址)。

因此如果打印 xv6 切换线程时的行号,会看到稳定的来回模式:sched 里的 swtch ↔ scheduler 里的 swtch,这正是 schedscheduler 互为协程的体现。


六、内核也可被抢占:三套保存的寄存器

定时器中断不仅能打断用户代码,也能打断正在执行系统调用的内核代码——kerneltrap() 同样会调用 yield()

当内核代码在系统调用中途被抢占时,一个进程会有三套被保存的寄存器:
  1. 用户寄存器 → 保存在 trapframe,由 trampoline 在进入内核时保存;
  2. 被打断的内核代码的寄存器 → 保存在内核栈上,由 kernelvec 保存;
  3. 中断处理线程的寄存器 → 保存在 p->context,由 swtch 保存。

内核抢占是必要的吗? 不是——内核里没有无限的 CPU 密集循环。但如果某些系统调用计算量很大,或需要严格的线程优先级,内核抢占就很有价值。这也是 usertrap 早期代码在开中断前要小心保存 sepc 等状态的原因:它随时可能被定时器中断、被换到另一个核上。

例题2:为什么除 p->lock 外,不允许在持有自旋锁时进行上下文切换?

考虑:

P1:                 P2:
  acquire(L1)         acquire(L2)
  yield()             acquire(L1)

P2 持有 L2,所以它在 acquire(L1) 自旋时中断是关闭的 → 定时器中断不会发生 → P2 不会 yield → P1 得不到 CPU → P1 永远不会 release(L1) → 死锁。结论:自旋锁会关中断,跨上下文切换持锁会让“锁的释放”依赖一个永远等不到 CPU 的线程,从而死锁。


七、几个设计问题(来自课件 Q&A)

为什么需要一个独立的“每核调度器线程”?能否去掉,让 sched 直接 swtch 到下一个线程?直接切会更快(少一次 swtch),但有麻烦:调度循环会跑在某个线程的内核栈上——如果那个线程正在 exiting(栈要被回收)怎么办?如果线程数比 CPU 还少(栈不够用)怎么办?独立调度器线程保证“永远有一个栈可以跑调度循环”,简化了这些边界情况。
为什么 scheduler() 要用 intr_on() 开中断?可能此刻没有任何 RUNNABLE 线程(都在等磁盘/控制台等 I/O)。如果不开中断,设备就没机会发完成中断来唤醒线程,系统会冻死。开中断让设备能够发信号、唤醒某个等待的线程。
线程高效吗?开销有两块:内存——每个线程一个栈;CPU 时间——每次 swtch。通常够高效,也确实方便;但线程很多(耗内存)或切换很频繁(耗 CPU)时会成问题。交替执行任务还有别的办法:事件驱动编程 / 状态机。线程不是最高效的,但最方便。

另外两个值得想的问题:scheduler() 的 for 循环从 proc[0] 开始,是否对 proc[0] 不公平?xv6 的“调度策略”到底是什么、好不好?(见下一节。)


5. 现代系统:调度策略

xv6 用最简单的轮转 (round robin) 策略。真实 OS 有优先级等复杂策略,但易引发冲突目标(公平 vs 吞吐)与意外交互:优先级反转(低优先级进程持锁挡住高优先级进程)、队列阻塞。复杂调度器需额外机制(如优先级继承)应对。xv6 不适合硬实时(调度不看 deadline、有长时间关中断路径);硬实时 OS 常是与应用链接的库,以便分析最坏响应时间。


6. 自测清单

  • [ ] 线程的状态有哪些?RUNNING 与 RUNNABLE 的状态分别存在哪里?
  • [ ] 抢占式调度如何用定时器中断 + yield 实现?
  • [ ] 为什么 xv6 进程切换总要经过内核线程(不做用户→用户直接切换)?
  • [ ] trapframe 与 p->context 各存什么?sched 与 scheduler 为什么是协程?
  • [ ] 为什么 swtch 期间必须持 p->lock,且跨线程 acquire/release?
  • [ ] 为什么 mycpu 的结果脆弱、要禁中断使用?优先级反转是什么?

处理计算密集型线程

关键思想:定时器中断。 每个核心,每个 CPU 上都有一个硬件,它会产生周期性的中断,而 xv6 或任何操作系统将这些中断传送给内核,所以,即使我们在用户级运行一些循环,比如计算 $\pi $​的前 10 亿位,尽管如此,定时器中断还是会在某个时间点发生,可能是每 10 毫秒一次,将控制从该用户级代码转移到内核中的中断处理程序,这是内核获得控制权的第一步,用来在不同的用户级线程之间切换。

基本的方案是:在中断处理器中,所以我们有处理这些程序的内核中断。我们会看到,内核处理程序让出,这个名称是 yields ,内核处理程序自愿让出 CPU 给调度器,告诉调度器,你现在可以运行其他线程。而这个让出是一种线程切换的形式,它保存了当前线程的状态,这样以后就可以恢复。我们会在这里看到整个流程,实际上,你已经在这里看到了整个流程,因为它涉及到中断,你已经知道整个流程有些复杂,但是基本的想法是定时器中断将控制权交给内核,而内核自愿让出 CPU ,这个术语称为抢占调度。它的意思是,抢占的意思是,即使正在运行的代码不愿意,没有明确地让出 CPU ,定时器中断会夺走控制权,我们会让出给它,抢占式调度的反义词称为自愿式调度。有趣的事情是,抢占式调度在 xv6 等操作系统中的实现是定时器中断强制夺取 CPU ,然后内核自愿让出,切换到那个进程的线程。

这里出现一个问题, 怎么确定要让哪个线程上呢?

线程状态,xv6中有RUNNING、RUNNABLE,SLEEPING(表示线程等待某个 IO 事件,只有在 IO 事件发生之后运行)等。

这个定时器中断和 yield 所做的是,将一个运行时线程,即被定时器中断的线程,转换为一个可运行线程。RUNNING的线程的PC寄存器在执行它的 CPU 的硬件寄存器中,RUNNABLE没有,因为它现在没有关联CPU,因此,对于RUNNABLE状态,我们需要事先保存好所有 CPU 状态,而对于RUNNING状态的线程,我们需要复制CPU的内容,不是RAM,而是寄存器(也就是程序计数器和 CPU 寄存器),从CPU到内存的某个地方保存他们。作为再次运行该线程的步骤一部分,我们会看到程序计数器,保存的程序计数器寄存器被复制回 CPU ,到调度器决定运行线程的 CPU 的寄存器上。

用户线程与内核线程的切换机制

  • 用户线程的组成
    • 用户程序计数器、用户寄存器、用户堆栈、用户内存。
    • 当程序运行时,它在用户空间中执行,并拥有自己的控制线程,处理用户指令。
  • 系统调用或中断时的处理
    • 当程序进行系统调用或发生中断时,系统进入内核模式。
    • 用户级的寄存器和程序计数器被保存在 trapframe 中,随后内核线程被激活。
    • 切换到内核线程后,CPU 开始使用 内核堆栈。此时,用户寄存器不需要被恢复,因为用户线程已经暂停,正在执行的是内核线程。
  • 内核线程执行与恢复
    • 内核线程执行系统调用或处理中断,之后可能会返回用户空间,恢复用户寄存器和程序计数器。
    • 如果只涉及当前进程,则直接返回用户空间;如果调度器决定切换到另一个进程,则需要进行内核线程间的切换。

进程切换机制

进程切换的高层次步骤:

  1. 保存当前进程状态:如果需要切换到另一个进程,首先保存当前进程的内核线程状态(如内核寄存器和内核堆栈)。
  2. 调度器选择下一个进程:调度器决定切换到另一个进程的内核线程。
  3. 恢复新进程状态:恢复下一个进程的内核寄存器和内核线程状态。
  4. 返回用户空间:当内核线程完成任务时,返回到用户空间并恢复该进程的用户寄存器和程序计数器。

示例:进程间的切换

  • 假设 C 编译器 需要从磁盘读取数据,它会发起系统调用并进入休眠状态,等待磁盘操作完成。

  • 此时,调度器可以选择切换到另一个进程,比如

    ls
    • 如果 ls 处于可运行状态,调度器会恢复 ls 的内核线程状态(包括内核寄存器和堆栈)。
    • ls 的内核线程继续执行,可能完成一个系统调用,并最终返回用户空间,恢复用户寄存器,然后继续执行 ls 程序。

重点总结

  • xv6: 不会进行用户到用户的直接切换
    • 每次进程切换都涉及一个 内核线程的切换
    • 切换是从一个进程的内核线程切换到另一个进程的内核线程,然后该进程返回用户空间,恢复用户寄存器并继续执行。一直是这种间接的策略

调度器

假设我们有进程一 P1 正在运行,而进程二 P2 是可运行的,但是现在没有运行,还有一些额外的细节,我们在 xv6 中有多个核心,假设我们有两个核心,这意味着在硬件层面上,我们有 CPU0 ,它是其中一个核心,还有 CPU1。更完整的故事是我们如何从执行用户空间到,在用户空间中执行的一个进程,到另一个可运行但没有运行的进程。)

  1. 定时器中断与用户进程
  • 当用户空间中的进程 P1 正在运行时,定时器中断触发,强制将控制从用户进程切换到内核空间。
  • xv6 使用 trampoline 代码保存进程 P1 的用户寄存器和 trapframe,随后执行 usertrap,它处理这个 trap 或中断。
  • 系统调用或其他中断会执行一些内核代码(如 C 代码),使用进程的内核堆栈。
  1. 进程让出 CPU
  • 假设 P1 进入内核后,通过某种方式(如等待 I/O)决定让出 CPU,最终会调用 switch 函数。
  • switch 保存当前进程的内核线程寄存器,切换到当前 CPU 的调度器线程。每个 CPU 有一个专门的调度器线程。
  • 调度器线程的上下文包括堆栈指针等寄存器,switch 返回时,会从保存的寄存器恢复,并执行调度器函数。
  1. 调度器选择下一个进程
  • 调度器函数通过遍历进程表查找下一个可运行的进程。
  • 如果找到一个新进程(如 P2),调用 switch 切换到 P2 的上下文。P2 的寄存器恢复后,将开始执行其内核或用户代码。
  • 如果没有找到合适的新进程,则可能会返回到 P1。
  1. 上下文存储位置
  • 每个进程的内核寄存器状态存储在其进程结构体的 p->context 中,而 trapframe 只包含用户寄存器。
  • 每个 CPU 的调度器上下文存储在 CPU 结构体中。
  • 内核线程的上下文可以存储在多个地方,但 xv6 选择将用户寄存器与内核寄存器分开存储,以简化代码结构。
  1. 用户进程的自愿让出
  • 用户进程不能直接调用让出 CPU,而是通过系统调用间接完成(例如读操作阻塞时)。等待 I/O 时,内核调用 yieldsleep,将当前进程置于等待状态,并切换到调度器线程。
  • 这种让出在计时器中断或 I/O 操作中发生。
  1. 每个 CPU 的调度器线程
  • 每个 CPU 都有一个独立的调度器线程及其堆栈,这些堆栈在引导阶段被设置。
  • 调度器堆栈和上下文在内核引导时为每个核心初始化。
  1. 上下文切换的通用流程
  • 上下文切换指的是从一个进程切换到另一个,包括保存当前线程的寄存器并恢复目标线程的寄存器。
  • 在每次上下文切换中,调用 switch 函数,它保存当前线程的上下文并切换到另一个线程。
  1. 线程与进程
  • 在 xv6 中,一个进程只能有一个线程(用户空间或内核空间)。尽管有时用“线程”来描述用户和内核代码的执行,但 xv6 并不支持真正的多线程,用户线程在 xv6 中要么执行用户级代码,要么执行内核级代码。
  1. 进程结构体中的相关字段
  • trapframe:保存用户级寄存器的结构。
  • context:保存内核线程的寄存器,在进程切换时使用。
  • 内核堆栈:用于保存当前执行的内核函数调用栈。
  • 状态变量:表示进程是否正在运行、可运行、休眠或未分配。
  • 锁机制:保护进程状态的修改,防止多个调度器线程同时操作同一进程。

Code: 调度

现在讨论如何通过调度器在不同进程的内核线程之间进行切换。每个 CPU 上,调度器以一个特殊线程的形式存在,这些线程运行scheduler函数。调度函数负责选择下一个要运行的进程。想要放弃 CPU 的进程必须获取自己的进程锁 p->lock,释放它持有的其他锁,更新自己的状态(p->state),然后调用 sched。你可以在 yield(见 kernel/proc.c:496)、sleepexit 中看到这个过程。sched 会再次检查这些要求(见 kernel/proc.c:480-485),然后检查一个推论:由于持有锁,中断应当被禁用。最后,sched 调用 swtch,将当前上下文保存在 p->context 中,并切换到调度器的上下文 cpu->schedulerswtch 返回时是在调度器的堆栈上,就像调度器的 swtch 已经返回一样。调度器继续其循环,找到一个进程并切换到它,然后循环重复。

我们刚刚看到,xv6 在调用 swtch 时持有 p->lockswtch 的调用者必须已经持有该锁,锁的控制权会传递给切换后的代码。这个习惯在锁的使用中是很不寻常的;通常情况下,获取锁的线程也负责释放锁,这样更容易确保正确性。对于上下文切换,打破这一惯例是必要的,因为 p->lock 保护着进程状态和上下文字段上的不变性,而在执行 swtch 时这些不变性并不成立。一个例子是,如果在 swtch 执行时 p->lock 没有被持有,另一个 CPU 可能在 yield 将进程状态设置为 RUNNABLE 后但在 swtch 停止使用其内核堆栈之前决定运行该进程。结果是两个 CPU 会同时运行在同一个堆栈上,造成混乱。

内核线程放弃其 CPU 的唯一地点是在 sched 中,并且它总是切换到调度器中的同一位置,而调度器几乎总是切换回之前调用过 sched 的某个内核线程。因此,如果你打印出 xv6 切换线程的行号,你会观察到如下简单的模式:kernel/proc.c:456kernel/proc.c:490kernel/proc.c:456kernel/proc.c:490,如此往复。那些通过线程切换有意转移控制权的过程有时被称为协程;在这个例子中,schedscheduler 是彼此的协程。

有一种情况调度器的 swtch 调用不会返回到 schedallocproc 会将新进程的 ra 寄存器设置为 forkret(见 kernel/proc.c:508),因此其第一次 swtch 会“返回”到该函数的开头。forkret 的作用是释放 p->lock;否则,由于新进程需要像从 fork 返回那样返回到用户空间,它可以直接从 usertrapret 开始。

Scheduler(见 kernel/proc.c:438)运行一个循环:找到一个进程,运行它直到它放弃 CPU,然后重复。调度器会遍历进程表,查找一个可运行的进程,也就是状态为 RUNNABLE 的进程。一旦找到进程,它会设置每个 CPU 的当前进程变量 c->proc,将该进程标记为 RUNNING,然后调用 swtch 开始运行它(见 kernel/proc.c:451-456)。

可以认为调度代码的结构是它强制执行了每个进程的一组不变性,并在这些不变性不成立时持有 p->lock。其中一个不变性是:如果一个进程处于 RUNNING 状态,计时器中断的 yield 必须能够安全地从该进程切换走;这意味着 CPU 的寄存器必须持有该进程的寄存器值(即 swtch 还没有将它们移动到上下文中),并且 c->proc 必须指向该进程。另一个不变性是:如果一个进程是 RUNNABLE,那么空闲 CPU 的调度器必须能够安全地运行它;这意味着 p->context 必须保存进程的寄存器(即它们实际上不在真实的寄存器中),没有 CPU 在该进程的内核堆栈上执行,并且没有 CPU 的 c->proc 指向该进程。请注意,当持有 p->lock 时,这些属性通常不成立。

保持上述不变性的原因是,xv6 经常在一个线程中获取 p->lock,而在另一个线程中释放它,例如在 yield 中获取并在 scheduler 中释放。一旦 yield 开始修改正在运行进程的状态以使其变为 RUNNABLE,该锁必须保持持有状态,直到不变性恢复:最早的正确释放时机是在调度器(运行在自己的堆栈上)清除 c->proc 之后。同样,一旦调度器开始将一个 RUNNABLE 的进程转换为 RUNNING,该锁不能被释放,直到内核线程完全运行(例如在 yield 中的 swtch 之后)。

Code: mycpu和myproc

xv6经常需要指向当前进程的 proc 结构体的指针。在单处理器系统中,可以使用一个全局变量指向当前的 proc。但在多核机器上,这种方法不可行,因为每个核心执行不同的进程。解决这个问题的办法是利用每个核心都有自己的一组寄存器,我们可以使用其中一个寄存器来帮助查找每核的相关信息。

xv6 为每个 CPU 维护一个 struct cpu(见 kernel/proc.h:22),该结构记录了当前在该 CPU 上运行的进程,CPU 调度线程的保存的寄存器,嵌套自旋锁计数(用于管理中断禁用)。函数 mycpu(见 kernel/proc.c:72)返回指向当前 CPU 的 struct cpu 的指针。RISC-V 给每个 CPU 分配一个 hartid。xv6 确保每个 CPU 的 hartid 存储在该 CPU 的 tp 寄存器中,这样 mycpu 可以使用 tp 来索引 cpu 结构体数组,找到正确的结构体。

确保一个 CPU 的 tp 始终保存 CPU 的 hartid 是有一定复杂性的。mstart 在 CPU 启动序列的早期设置 tp 寄存器,仍在机器模式下(见 kernel/start.c:51)。usertrapret 将 tp 保存到 trampoline 页面,因为用户进程可能会修改 tp。最后,uservec 在从用户空间进入内核时恢复保存的 tp(见 kernel/trampoline.S:70)。

编译器保证不会使用 tp 寄存器。虽然如果 xv6 可以随时询问 RISC-V 硬件当前的 hartid 会更方便,但 RISC-V 只允许在机器模式下这样做,而不能在特权模式下。

cpuidmycpu 的返回值是脆弱的:如果计时器中断导致线程让步并转移到不同的 CPU,之前返回的值可能不再正确。为避免这个问题,xv6 要求调用者禁用中断,并在使用返回的 struct cpu 后再启用中断。

函数 myproc(见 kernel/proc.c:80)返回当前 CPU 上运行的进程的 struct proc 指针。myproc 禁用中断,调用 mycpu,从 struct cpu 中提取当前进程指针(c->proc),然后重新启用中断。即使中断启用,myproc 的返回值也是安全的:如果计时器中断将调用进程移到不同的 CPU,它的 struct proc 指针仍会保持不变。

定时器中断

Xv6 使用定时器中断来维护当前时间的概念,并在计算密集型进程之间进行切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。Xv6 会对每个 CPU 的时钟硬件进行编程,使其周期性地中断 CPU。

start.c (kernel/start.c:53) 中的代码设置了一些控制位,允许管理模式下访问定时器控制寄存器,然后请求第一个定时器中断。时间控制寄存器包含一个计数,硬件以稳定的速率递增该计数,作为当前时间的概念。stimecmp 寄存器包含 CPU 将在某个时间点触发定时器中断的时间;将 stimecmp 设置为当前时间加上 x 将安排在未来 x 时间单位后发生中断。在 qemu 的 RISC-V 仿真中,1000000 个时间单位大约相当于 0.1 秒。

定时器中断与其他设备中断一样,通过 usertrapkerneltrap 以及 devintr 来到达。定时器中断时,scause 的低位设置为 5;trap.c 中的 devintr 检测到这种情况并调用 clockintr (kernel/trap.c:164)。后者函数递增 ticks,让内核能够跟踪时间的流逝。时间递增仅发生在一个 CPU 上,以避免多个 CPU 导致时间加速。clockintr 唤醒任何通过 sleep 系统调用等待的进程,并通过写入 stimecmp 来安排下一次定时器中断。

对于定时器中断,devintr 返回 2,以便指示 kerneltrapusertrap 调用 yield,从而使 CPU 能够在可运行进程之间进行多路复用。内核代码可以被定时器中断打断,并通过 yield 强制进行上下文切换,这也是 usertrap 早期代码在启用中断前小心保存 sepc 等状态的原因之一。这些上下文切换意味着内核代码必须编写得足够谨慎,以应对可能在没有预警的情况下从一个 CPU 切换到另一个 CPU 的情况。

总结

某些计算机应用要求系统必须在限定时间内做出响应。例如,在安全关键系统中,错过时限可能会导致灾难。Xv6 不适用于硬实时环境。硬实时操作系统通常是与应用程序链接的库,允许分析确定最坏情况下的响应时间。Xv6 也不适用于软实时应用(偶尔错过时限是可以接受的),因为 Xv6 的调度程序过于简单,并且它的内核代码路径中有时会长时间禁用中断。

xv6调度器实现了一种简单的调度策略,即依次运行每个进程。这种策略称为轮转调度(round-robin)。实际的操作系统实现了更复杂的策略,例如允许进程具有优先级。其理念是调度器在可运行的进程中,优先选择高优先级的进程,而不是低优先级的进程。这些策略可能会迅速变得复杂,因为通常存在相互冲突的目标:例如,操作系统可能还想保证公平性和高吞吐量。此外,复杂的策略可能会导致意外的交互问题,如优先级倒置和队列阻塞。优先级倒置可能发生在低优先级和高优先级的进程都使用某个特定锁时,低优先级进程获得锁后,可能会阻止高优先级进程继续执行。当多个高优先级进程都在等待一个低优先级进程释放共享锁时,就可能形成一个长时间的队列阻塞,一旦队列形成,它可能会持续很长时间。为了避免这些问题,复杂的调度器需要额外的机制。

参考资料