Loading

MIT6.S081 ---- Preparation: Read chapter 7

Chapter 7 Scheduling

任何操作系统运行的进程数量都可能超过计算机 CPUs 的数量,所以这些进程需要一个策略分时共享 CPUs。理想情况下,共享对用户进程是透明的(用户进程不感知共享)。一种常用的方法是通过多路复用(\(multiplexing\)),在硬件 CPUs 上调度这些进程,让每个进程感觉独占虚拟 CPU。本章解释 xv6 如何实现多路复用。

xv6 内核共享内存:xv6 支持内核线程的概念,每个进程有一个内核线程来执行这个进程的系统调用,所有这些内核线程共享内核内存。
xv6 每个用户进程都有一个控制线程,执行这个进程的用户指令。
xv6 内核线程机制大部分是支持在多个用户进程之间切换,每个用户进程有一个 线程运行在进程的内存上,所以用户进程内的线程间不共享内存,因为每个进程都有单独的地址空间和单个线程。
Linux 使用了一些基础的技术,允许在用户进程内有多个线程,这些线程共享所在进程的内存。

Multiplexing

在两种情况下,xv6 将 CPU 从一个进程切换到另一个进程,从而实现多路复用:

  • 首先,当一个进程等待设备或者管道 I/O 完成,或者等待子进程 exit,或者等待 sleep 系统调用时,xv6 的 sleepwakeup 机制负责切换。
  • 其次,xv6 周期性的强制处理计算密集型任务(长时间运行没有 sleep)。

多路复用给进程一个感觉:自己独占一个 CPU,就像 xv6 使用内存分配器硬件页表使进程感觉自己独占内存一样。

实现多路复用带来了一些挑战:

  • 第一,如何从一个进程切换到另一个进程?尽管上下文切换的想法很简单,但它的实现是 xv6 中最难懂的代码。
  • 第二,如何以对用户进程透明的方式强制切换(抢占式调度,非自愿式调度)?xv6 使用标准的技术,硬件定时器的中断驱动上下文切换。(用户进程没有直接的切换方式,而是通过定时器中断进入内核,内核让出 CPU 给其他进程,对哟用户进程是透明的)
  • 第三,所有 CPUs 都在一组共享进程之间切换,为避免竞争,需要一个 locking plan。
  • 第四,当进程 exit 时,进程的内存和其他资源必须被释放,但不能自己释放,因为(例如)不能在仍使用内核栈使释放内核栈。
  • 第五,多核机器的每个核心必须记录哪个进程正在运行该核心上运行,以便系统调用能影响对应的进程的内核状态。
  • 最后,sleepwakeup 允许进程放弃 CPU 并且等待被另一个进程或中断唤醒。

小心避免竞争(races),竞争会导致丢失 wakeup 通知。xv6 尝试尽可能简单的解决这些问题,但是代码仍然很难搞。

Code: Context switching

Figure 7.1: Switching from one user process to another. In this example, xv6 runs with one CPU
(and thus one scheduler thread).

图 7.1 描述了从一个用户进程切换到另一个用户进程的相关步骤:

  • 一次用户态-内核态的转换(系统调用或中断),转换到被旧的(被切换的进程)进程的内核线程;
  • 一次上下文切换,切换到当前 CPU 的调度线程;
  • 一次上下文切换,切换到新的(切换到的进程)进程的内核线程;
  • 一次 trap 返回到用户级进程。(进程的切换实质是内核线程的切换和trap机制的结合)

xv6 中,每个 CPU 有一个专用的调度线程(保存的寄存器和栈),因为在旧的进程的内核栈上执行调度不安全:一些其他核心可能唤醒进程并运行,在不同的核心上使用相同的栈是灾难(调度线程有独立的栈)。
本节阐述内核线程和调度线程之间切换的机制。

从一个线程切换到另一个线程涉及保存旧线程的 CPU 寄存器,恢复之前保存的新线程的寄存器;保存和恢复 $sp$pc 表示 CPU 将切换栈,以及将要执行的代码。

任何时候,一个 CPU 核心只能运行一个线程:用户线程,内核线程,调度线程。
一个进程执行用户级指令,或者在内核中执行指令,或者不执行,状态被保存在 trapframe 和上下文中。
每个进程有两个线程:一个是用户级线程,一个是内核级线程,进程要么在用户空间执行,要么在内核空间执行。

函数 swtch 为内核线程切换保存和恢复上下文。swtch 不直接感知线程;只是保存和恢复 \(32\) 个 RISC-V 寄存器,称为 \(contexts\)。当进程要放弃 CPU 时,进程的内核线程调用 swtch 保存自己的 \(context\),返回调度线程的 \(context\)
每个 \(context\)struct context(kernel/proc.h)表示,进程的 struct proc 和 CPU 的 struct cpu 都有 struct context 这个结构体。
swtch 接受两个参数:struct context *oldstruct context *new。在 old 中保存当前寄存器,恢复 new 中的寄存器,然后返回。

通过一个进程的 swtch 观察调度。在 Chapter4 中,中断结束有可能 usertrap 调用 yieldyield 调用 schedsched 调用 swtch 保存当前 context 在 p->context,并且切换到之前保存在 cpu->scheduler(kernel/proc.c) 中的调度 context。

swtch(kernel/swtch.S)只保存 callee-saved 寄存器;C 编译器在调用 swtch 处生成代码,在栈上保存 caller-saved 寄存器。swtch 知道 struct context 中每个寄存器域的偏移。
不保存 $PC寄存器(因为正在执行的就是 swtch ,这的 $PC 总是可预测的)。但是,swtch 要保存 $ra 寄存器,存储调用 swtch 处的返回地址(需要知道从哪里调用的 swtch,方便切换回这个线程时,从调用点继续执行)。
swtch 从新的上下文中恢复寄存器,新的上下文是上一个 swtch 保存的寄存器的值。
swtch 返回时,返回到恢复的 $ra 寄存器指向地址的指令,就是新的线程之前调用 swtch 的指令。另外,会返回新线程的栈,因为恢复的 $sp 寄存器指向了这里。

为什么 swtch 只保存了 \(14\) 个寄存器,但 RISC-V 代码可以使用 \(32\) 个寄存器?
调用 swtch 是按照调用函数的方式进行调用的,C 编译器生成代码保存 caller-saved 寄存器。

在我们的例子中,sched 调用 swtch 切换到 cpu->scheduler(每个 CPU 有一个 scheduler context)。当 scheduler 调用 swtch (kernel/proc.c) 切换为放弃 CPU 的进程时(我理解这里放弃 CPU 的进程指的是由运行态转为就绪态的进程),上下文被保存。当跟踪 swtch 返回时,它没有返回到 sched ,而是返回到 scheduler,栈指针在当前 CPU 的 scheduler 栈中。

Code: Scheduling

上小节看了 swtch 的底层细节;现在将 swtch 作为封装,检查从一个进程的内核线程通过 scheduler 切换到另一个进程。scheduler 在每个 CPU 中用一个特殊的线程表示,这些线程运行 scheduler() 函数。函数负责选择下一个要运行的进程。
一个进程想放弃 CPU 必须: 持有自己的进程锁 p->lock,释放持有的其他锁(不释放其他锁容易造成死锁:比如单 CPU 单 core,进程 \(1\) 持有锁 L,切换到进程 \(2\),然后进程 \(2\) 自旋(自旋锁的持有先关中断,再自旋)在锁 L 上,形成死锁),更新自己的状态(p->state),然后调用 schedyield(kernel/proc.c)、sleep 以及 exit 就是这个处理流程。
sched 仔细检查了一些要求(kernel/proc.c),然后检查了一个细节:因为锁被占有,中断应为关闭状态。
最后,sched 调用 swtchp->context 中保存当前上下文,并且切换为 cpu->context 中保存的 scheduler context。swtch 返回到 scheduler 的栈上,好像 scheduler 调用 swtch 然后返回一样。scheduler 继续 for 循环,找到一个可以运行的进程,切换到这个进程,如此循环。

刚看到 xv6 对于 swtch 的调用需要先占有 p->lockswtch 的调用者必须已经先占有锁,锁的控制传递到切换到的代码。
这个规则对于锁的使用不同寻常:通常 acquire 锁的线程有责任 release 锁,这样会更容易验证正确性。对于上下文切换很有必要打破这个规则,因为在执行 swtchp->lock 保护的不变量(进程的 statecontext 域)不是真的(我理解这里的意思是虽然修改了进程的 state,但是当前并没有完成切换,还在切换的过程,所以不变量不是真的)。例如有个问题,如果 p->lock 没有在 swtch 期间被占有:其他 CPU 可能决定在 yield 设置一个进程状态为 RUNNABLE 后,且 swtch 完成切换前,即进程对应的内核线程的内核栈还在被原 CPU 使用期间,运行这个进程。后果就是两个 CPU 运行在同一个栈上,会造成混乱。

内核线程放弃 CPU 的唯一地方就是 sched,它总是切换到 scheduler 的固定位置,scheduler 几乎总是调度一些之前调用 sched 的内核线程。因此,如果打印出 xv6 线程切换的行号,可能观察到如下的简单模式:(kernel/proc.c:scheduler():swtch),(kernel/proc.c:sched():swtch),(kernel/proc.c:scheduler():swtch),(kernel/proc.c:sched():swtch),以此循环。有意通过线程切换相互之间转移控制权的过程称为协程(\(coroutines\));在这个例子中,schedscheduler 相互是协程。

有一种情况 scheduler 调用 swtch 没有在 sched 中结束。allocproc 设置新进程的上下文 $ra 寄存器为 forkret(kernel/proc.c),因此它的第一次 swtch 调用返回 forkret 的开头。forkret 释放锁 p->lock;此外,因为新进程需要返回用户空间(就像 fork 的返回),可以从 usertrapret 开始执行。

scheduler (kernel/proc.c)运行一个循环:找一个要运行的进程,运行这个进程直到这个进程放弃 CPU,重复这个过程。
scheduler 遍历进程表,找到一个就绪的进程(p->state == RUNNABLE)。一旦找到一个就绪进程,则设置对应 CPU 的 c->proc,标记进程状态为 RUNNING,然后调用 swtch 开始运行。(kernel/proc.c)

思考 scheduling 代码的结构的一个方法是:它强制维护一组进程的不变量(进程状态 p->state,CPU 上正在运行的进程 c->proc,进程的上下文 p->context),在这些不变量不真时,都占有 p->lock
一个不变量是如果一个进程状态是 RUNNING,定时器中断的 yield 必须能安全的切换掉这个进程;这意味着 CPU 寄存器必须保存进程的寄存器值(如,swtch 还没有将这些值写入 context) ,c->proc 必须指向这个进程。
另一个不变量是如果进程的状态是 RUNNABLE,一个空闲 CPU 的 scheduler 必须能安全的运行这个进程;这意味着 p->context 必须保存进程的寄存器(如,它们不是真正的寄存器),还意味着 CPU 没有执行在进程的内核栈上,意味着 CPU 的 c->proc 没有指向这个进程。
观察发现,在占有 p->lock 期间,这些不变量属性经常不是真的。(所以需要锁保护)

维护这些不变量解释了为什么 xv6 经常在一个线程中 acquire p->lock ,在另一线程 release p->lock,例如,在 yield 中 require ,在 scheduler 中 release。
一旦 yield 已经开始修改一个运行进程的状态为 RUNNABLE,则锁必须保持被占有,直到恢复不变量:最早的正确的 release 位置在 scheduler(运行在自己的栈上)清除 c->proc 之后。类似的,一旦 scheduler 开始将一个 RUNNABLE 进程转为 RUNNING,直到内核线程完全运行锁才能被 release (如在 yield 中的 swtch 之后)。

Code: mycpu and myproc

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

xv6 为每个 CPU 维护一个 struct cpu (kernel/proc.h),里面记录有:这个 CPU 上正在运行的进程(如果有),为 CPU 的 scheduler 线程保存的寄存器,用于管理关中断的嵌套的 spinlocks 的数量。函数 mycpu (kernel/proc.c)返回一个指向当前 CPU 的 struct cpu 的指针。RISC-V 用 hartid 为它的 CPU 编号。在内核中时, xv6 确保每个 CPU 的 hartid 存储在 CPU 的 $tp 寄存器中。这使得 mycpu 能使用 $tp 索引 cpu 结构体数组找到对应的 struct cpu

确保一个 CPU 的 $tp 总是保存 CPU 的 hartid 有点复杂。
在 CPU 的启动早期,处于 machine-mode 时,start 设置 $tp 寄存器(kernel/start.c)。usertrapret 保存 $tptrapframe->kernel_hartid ,因为用户空间可能修改 $tp 寄存器。
最后,当从用户空间进入内核时,uservec 恢复保存的 $tp 寄存器。编译器保证永远不会使用 $tp 寄存器。如果 xv6 在需要时能向 RISC-V 硬件请求当前的 hartid,这样会更方便,但是 RISC-V 只允许在 machine-mode 而不是 supervisor-mode 可以直接获取 hartid。

cpuid()mycpu() 的返回值是易损的:如果定时器中断并且使线程 yield,然后调度到另一个 CPU,之前的返回值将是错误的。为了避免这个问题,xv6 要求调用者关中断,在使用完返回的 struct cpu 之后再开中断。

函数 myproc() 返回指向正运行在当前 CPU 上的进程的 struct proc 指针。myproc 关中断,调用 mycpu,从当前进程指针(c->proc) 中取出 struct cpu,然后开中断。即使开中断,myproc 的返回值也可以安全使用:如果定时器中断将调用进程调度到其他 CPU ,它的 struct proc 指针也保持相同。

Sleep and wakeup

调度和锁帮助隐藏了一个线程对另一个线程的操作,但是我们也需要一个帮助线程主动交互的抽象。
例如,xv6 中读管道可能需要等待一个写进程产生数据;一个父进程调用 wait 可能需要等待子进程退出;一个读硬盘的进程需要等待硬盘硬件完成读取。
xv6 使用一个称为 sleep 和 wakeup 的机制应对这些情况。sleep 允许一个内核线程等待一个特定的事件;另一个线程能调用 wakeup 通知那个等待事件的线程恢复。sleep 和 wakeup 通常称为序列协作或条件同步机制(\(sequence\ coordination\) or \(conditional\ synchronization\ mechanisms\))。

sleep 和 wakeup 提供了一个相对底层的同步接口。为了在 xv6 中应用这种方法,使用它们构建一个更高层次的同步机制,称为信号量(\(semaphore\))协调生产者和消费者(xv6 没有使用信号量)。一个 semaphore 维护了一个计数并提供了两个操作。V 操作(用于生产者)增加计数。P 操作(用于消费者)等待直到计数值非零,然后将计数值减一,然后返回。如果只有一个生产者,一个消费者,它们在不同的 CPUs 上运行,并且编译器没有做太多优化,则下列实现可能是正确的:

struct semaphore {
    struct spinlock lock;
    int count;
};

void
V(struct semaphore *s)
{
    acquire(&s->lock);
    s->count += 1;
    release(&s->lock);
}

void
P(struct semaphore *s)
{
    while(s->count == 0)
        ;
    acquire(&s->lock);
    s->count -= 1;
    release(&s->lock);
}

上述实现代价较高。如果生产者很少运行,消费者将花费大量时间自旋(while 循环判断是否非零)。消费者 CPU 也许能找到比忙等(\(busy\ waiting\))轮询(\(polling\)s->count 更高效的方法。避免忙等,需要消费者让出 CPU ,当 V 操作增加计数值之后恢复运行。

实现这些还远远不够。想象下这一对调用,sleepwakeup 按如下方式工作。sleep(chan) 暂停运行在任意值 chan 上,称为 \(wait\ channel\)sleep 让调用它的进程睡眠,释放 CPU 给其他进程运行。
wakeup(chan) 唤醒所有睡眠在 chan (如果有)上的进程,使它们的 sleep 调用能返回。如果没有进程等待在 chan 上,wakeup 什么都不做。通过使用 sleepwakeup 改变信号量的实现。

void
V(struct semaphore *s)
{
    acquire(&s->lock);
    s->count += 1;
    wakeup(s);
    release(&s->lock);
}

void
P(struct semaphore *s)
{
    while(s->count == 0)
        sleep(s);
    acquire(&s->lock);
    s->count -= 1;
    release(&s->lock);
}

P 没有自旋,而是放弃 CPU ,这很好。然而,事实证明,没有遇到所谓的 \(lost\ wake-up\) 问题,用这个接口设计 sleepwakeup 并不简洁。
假设 P 发现 s->count == 0。之后当 P 运行在 \(13\)\(14\) 行之间时,在另一个 CPU 上运行 V:它改变 s->count 为非 \(0\),并且调用 wakeup,结果发现没有进程睡眠,然后 wakeup 什么都不做。之后 P 继续执行 \(14\) 行:它调用 sleep 并且睡眠。这引入一个问题:P 正在睡眠等待一个已经运行完了的 V 操作。
除非很幸运,生产者再次调用 V,否则即使计数值非零,消费者也将永远等待。

根本问题是不变量(只有当 s->count == 0 时,P 才运行)被运行在错误时间的 V 所违反。保护不变量的一个不正确的方法是在 P 中应用锁,将检查 count 和 调用 sleep 作为一个原子执行。

void
V(struct semaphore *s)
{
    acquire(&s->lock);
    s->count += 1;
    wakeup(s);
    release(&s->lock);
}

void
P(struct semaphore *s)
{
    acquire(&s->lock);
    while(s->count == 0)
        sleep(s);
    s->count -= 1;
    release(&s->lock);
}

这个版本的实现避免 lost wakeup,因为锁防止 V\(14\)\(15\) 之间执行。但这可能造成死锁:P 睡眠的时候持有锁,所以 V 将一直阻塞等待锁。

通过更改 sleep 接口修复之前的方案:调用者必须传递条件锁(\(condition\ lock\))给 sleep ,在调用者进程被标记为睡眠并且在 sleep channel 上等待,之后释放锁。
锁将强制并发的 V 等待,直到 P 已经使进程进入睡眠,以便 wakeup 能发现睡眠的消费者并且唤醒它。一旦消费者被唤醒,sleep 在返回前会再次要求占有锁。下面的 sleep/wakeup 方案是可用的。

void
V(struct semaphore *s)
{
    acquire(&s->lock);
    s->count += 1;
    wakeup(s);
    release(&s->lock);
}

void
P(struct semaphore *s)
{
    acquire(&s->lock);
    while(s->count == 0)
        sleep(s, &s->lock);
    s->count -= 1;
    release(&s->lock);
}

P 持有 s->lock, 防止 V 尝试在 P 检测 c->count 和调用 sleep之间唤醒进程。然而,为了避免 lost wakeup,我们需要 sleep 释放 s->lock 并让消费者进程睡眠作为一个原子操作。

Code: Sleep and wakeup

xv6 sleepwakeup 提供了上个例子展示的接口,这样的实现(以及如何使用它们的规则)确保不会出现 lost wakeup。基本的思想是: sleep 标记当前进程为 SLEEPING 并且调用 sched 释放 CPU; wakeup 查找睡眠在给定 wait channel 上的进程,然后标记为 RUNNABLEsleepwait 的调用者能使用任何双方都方便的数字作为 channel。xv6 经常使用与等待相关的内核数据结构的地址。

sleep 持有锁 p->lock。现在将要睡眠的进程持有 p->locklk。持有 lk 对于调用者(本例中是 P)是必要的:确保其他进程(本例中是 V)不能开始调用 wakeup(chan)sleep 持有 p->lock,安全的释放 lk:其他进程可能开始调用 wakeup(chan),但是 wakeup 将等待持有 p->lock,直到 sleep 已经使进程进入睡眠,防止 wakeup 错过 sleep

现在 sleep 持有 p->lock 并且没有占有其他锁,通过记录 sleep channel、改变进程状态为 SLEEPING、调用 sched 将使进程进入睡眠。接下来才明白为什么直到进程被标记为 SLEEPING 之后才释放 p->lock(被 scheduler 释放)很关键。

在某个时刻,一个进程将持有条件锁,设置睡眠进程等待的条件,调用 wakeup(chan)。当占有条件锁使,再调用 wakeup 很重要。wakeup 循环进程表,占有要检查的进程的 p->lock:因为可能操作进程的状态,还因为 p->lock 能确保 sleepwakeup 不会相互错过彼此。当 wakeup 找到一个进程状态为 SLEEPING 并且和 chan 匹配,则改变进程的 RUNNABLE。下次 scheduler 运行,将发现该进程可运行。

为什么 sleepwakeup 的 locking rules 能保证睡眠进程不会错过一个 wakeup
睡眠进程占有条件锁或者自己的 p->lock 或者在检查条件之前和标记 SLEEPING 之后占有两个锁。
调用 wakeup 的进程在 wakeup 循环之间占有两个锁。因此唤醒进程要么在消费线程检查条件之前使条件为真;要么唤醒进程的 wakeup 在睡眠线程被标记 SLEEPING 之后严格检查睡眠线程。wakeup 找到睡眠进程并唤醒它(除非其他进程先唤醒了它)。

有时,多个进程在同一个 channel 上睡眠;例如,多个进程读一个管道。调用一次 wakeup 将唤醒它们。其中一个进程将首先运行,并占有 sleep 被调用时带有的锁,并且(如果是管道)读取等待在管道中的数据。其他进程被唤醒时将发现没有数据可以读取。在这些进程看来,wakeup 是假的,它们必须再次睡眠。因此,sleep 总是在检查条件的循环中被调用。

如果 sleep/wakeup 被意外在同一个 channel 调用了两次,它们将看到虚假的 wakeup,但上述循环会容忍这个问题。sleep/wakeup 的吸引力在于既轻量(不需要创建特殊的数据结构作为睡眠 channel)并且提供了一个间阶层(调用者不需要知道他们正和哪个特定的进程进行交互)。

Code: Pipes

使用 sleepwakeup 同步生产者和消费者的一个更复杂的例子是 xv6 的管道实现。我们在 Chapter1 中看到的管道接口:写入管道一端的字节数据被拷贝到内核中的缓冲区,然后从管道的另一端读出。后面的章节研究管道相关的支持基础----文件描述符,本章先研究 pipewritepiperead 的实现。

管道可以用 struct pipe 表示,包含一个 lockdata 缓冲区。nreadnwrite 统计读取和写入缓冲区的字节数。
缓冲区是环形:buf[PIPESIZE-1] 之后的下一个字节是 buf[0]。计数不环绕。这个约定使实现将缓冲区满(nwrite == nread+PIPESIZE)和缓冲区空(nwrite == nread)进行区分,但这意味着缓冲区索引必须使用 buf[nread % PIPESIZE] 而不是 buf[nread]nwrite 类似)。

思考在不同的 CPUs 上同时调用 pipereadpipewritepipewrite(kernel/pipe.c)获取管道锁,该锁保护计数值、data 和相关的不变量。piperead 也尝试获取锁,但是不能获得,它在 acquire 中自旋等待锁。当 piperead 等待时,pipewrite 循环处理要被写入的字节(addr[0..n-1]),逐一添加到管道中。循环中,可能出现缓冲区满的情况,此时,pipewrite 调用 wakeup 提醒所有睡眠状态的读进程缓冲区中有等待的数据,然后在 &pi->write 上睡眠,等待读进程从缓冲区中取出一些字节。sleep 释放 pi->lock,使调用 pipewrite 的进程睡眠。

现在 pi->lock 可用,piperead 管理占有 pi-lock,并进入它的临界区:发现 pi->nread != pi->nwritepipewrite 进入睡眠,因为 pi->nwrite == pi->nread+PIPESIZE),进入 for 循环,将数据拷贝出管道,拷贝出一个字节,则 nread\(1\)。现在又允许写入多个字节,所以 piperead 在返回之前调用 wakeup 唤醒睡眠的写进程。wakeup 找到一个睡眠在 $pi->nwrite 上的进程,这个进程之前运行 pipewrite,但是因为缓冲区满停止运行了。它标记进程为 RUNNNABLE

管道代码为读、写进程使用独立的 sleep channels(pi->nreadpi->nwrite);在不太常见的情况下如许多读写进程等待同一个管道,可能使系统更高效。管道代码在检查睡眠条件的循环中 sleep;如果有多个读进程或者写进程,除了第一个进程,其他被唤醒的进程看到的条件仍然是假的,它们会再次睡眠。

Code: Wait, exit, and kill

sleepwakeup 能用于多种等待。Chapter1 介绍的一个有趣的例子,子进程的 exit 和父进程的 wait 相互作用。子进程终止后,父进程可能正在 wait 上睡眠,也可能做其他事情;后一种情况,接下来调用 wait 一定要注意子进程的终止,可能在子进程调用 exit 之后很久才注意到。
xv6 直到 wait 注意到子进程因为 exit 将进程状态标记为 ZOMBIE,才认为子进程终结,改变子进程的状态为 UNUSED,复制子进程的 exit 状态,返回子进程的进程 ID 到父进程。如果父进程在子进程 exit 前 exit,则父进程将子进程给 init 进程,init 进程一直调用 wait;因此每个子进程都有一个父进程清理它。
一个挑战是避免竞争和死锁:父进程 wait 和子进程 exit 同时发生,以及 exitexit 同时发生。

wait 开始先占有 wait_lock。原因是 wait_lock 作为条件锁帮助确保父进程不会错过一个 exit 的子进程的 wakeup
wait 遍历进程表。如果发现一个子进程的状态是 ZOMBIE,它会释放子进程的资源和 proc 结构体,复制子进程的 exit 状态到 wait 提供的地址(如果不为 \(0\)),返回子进程的进程 ID。如果 wait 找到的子进程都没有 exit,就调用 sleep 等待一个子进程 exit,然后再次遍历进程表。wait 经常持有两个锁,wait_lock 和 一些进程的 np->lock;死锁避免顺序是先占有 wait_lock,后占有 np->lock

exit 记录 exit 状态,释放一些资源,调用 reparent 将子进程给 init 进程,唤醒正在 wait 的父进程,标记 exit 调用者为僵尸(zombie),永久的让出 CPU。在这个过程中,exit 持有 wait_lockp->lock

  • 持有 wait_lock 因为它是 wakeup(p->parent) 的条件锁,防止 wait 的父进程丢失 wakeup ;
  • exit 在这个过程中持有p->lock,防止 wait 的父进程在子进程最终调用 swtch 之前发现子进程的状态是 ZOMBIE
  • 为避免死锁,exit 占有这些锁的顺序和 wait 相同。

exit 在设置进程状态为 ZOMBIE 之前唤醒父进程看似不正确,实则很安全:尽管 wakeup 可能造成父进程运行,wait 的循环直到子进程的 p->lockscheduler 释放才检查子进程,所以直到 exit 已经设置状态为 ZOMBIE 之后才查找 exit 的进程。

exit 允许一个进程自我终止,而 kill 能让一个进程请求终止另一个进程。对于 kill 来说直接销毁受害者进程可能很复杂,因为受害者可能在另一个 CPU 上执行,可能在执行内核数据结构更新的敏感操作。因此 kill 做的工作很少:仅仅设置受害者进程的 p->killed,如果该进程正在睡眠,则唤醒它。最终,如果 p->killed 被设置,在 usertrap 中进入或者离开内核的那段代码将调用 exit。如果受害者进程正运行在用户空间,它将很快进入通过系统调用或者定时器中断(或其他设备中断)进入内核。

如果受害者进程在 sleepkill 调用 wakeup 将使得受害者进程从 sleep 返回。这是潜在的危险,因为等待的条件可能不对。然而,xv6 对 sleep 的调用总是被包装在 while 循环中,该循环在 sleep 返回后重新测试条件。一些 sleep 调用在循环中也测试 p->killed ,如果 p->killed 被设置,则放弃当前活动。只有这种放弃是正确的时才能这样做。例如,如果 killed 标志被设置,管道读、写代码返回;最终,代码将返回到 trap,会再次检查 p->killed 并且 exit。

一些 xv6 的 sleep 循环没有检查 p->killed,因为代码在一个应该被原子执行的多步系统调用的中间。 如 virtio driver: 它没有检查 p->killed ,因为一个硬盘操作可能是文件系统保持正确状态所需的一组写入操作的其中之一。当进程等待硬盘 I/O 时,kill 这个进程,则直到完成当前系统调用并且 usertrap 看到 killed 标志后才 exit。

Process Locking

和每个进程都相关联的锁 p->lock 是 xv6 中最复杂的锁。思考 p->lock 的一个简单方法是:当读、写 struct proc 的域:p->statep->chanp->killedp->xstatep->pid 时,必须占有 p->lock。这些域能被其他进程、其他核心上的调度线程使用,自然需要一个锁保护。

然而,大多数 p->lock 的使用是为了保护 xv6 的进程数据结构和算法的更高层级方面。以下是 p->lock 所作的全部工作:

  • 配合 p->state,防止为新进程分配 proc[] 插槽时竞争
  • 隐藏了一个进程的创建和销毁
  • 防止一个父进程的 wait 回收一个进程状态被设置为 ZOMBIE,但是没有让出 CPU 的子进程
  • 防止其他核心的调度线程在一个放弃 CPU 的进程设置进程状态为 RUNNABLE 但是没有完成 swtch 时运行这个进程
  • 确保只有一个核心的调度线程能运行一个 RUNNABLE 进程。
  • 防止定时器中断造成一个进程在 swtch 时让出 CPU。
  • 配合条件锁,防止 wakeup 忽略一个正在调用 sleep 但是没有让出 CPU 的进程。
  • 防止 kill 掉的受害者进程在 kill 检查 p->pid 和 设置 p->killed 之间 exit 和被重新分配(这会导致 kill 掉另一个进程)。
  • 使 kill 的检查和 p->state 的写入成为原子性操作。

p->parent 域被全局锁 wait_lock 而不是 p->lock 保护。只有一个进程的父进程能修改 p->parent,尽管 p->parent 能被进程自己和其他查询他们子进程的进程读取wait_lock 的目的是作为条件锁,wait 睡眠等待任意子进程 exit。在设置进程状态为 ZOMBIE、唤醒父进程、放弃 CPU 之前,一个终结的子进程要么占有 wait_lock 要么占有 p->lockwait_lock 也序列化父子进程的并发 exit ,所以 init 进程(接受子进程)确保从 wait 中被唤醒。wait_lock 是一个全局锁,而不是单个父进程的锁,因为,只有一个进程持有了这个锁,才能知道它的父进程是谁。

Real world

xv6 的调度器实现了一个简单的调度策略,依次运行每个进程。该策略被称为轮询\(round\ robin\))。真正的操作系统实现了更成熟的策略,例如,允许进程有优先级。思想是,对于就绪态进程,相对于低优先级的进程,调度器优先选择一个高优先级进程。这些策略可能很快变得很复杂,因为经常存在竞争的目标:如,操作系统想保证公平性(fairness)和高吞吐量(high throughput)。另外,复杂的策略可能导致意外的影响:如优先级反转(\(priority\ inversion\))和锁护航(\(convoys\))。优先级反转:一个低优先级进程和一个高优先级进程使用一个特殊的锁,锁被低优先级占有,阻碍了高优先级继续运行。等待进程的long convey:一个低优先级进程持有一个共享锁,很多需要持有这个锁的高优先级进程需要等待;护航(convey)一旦形成会持续很久。为了避免这类问题,在成熟的调度器中有必要设计额外的机制。

sleepwakeup 是一个简单和高效的同步方法,还有很多其他方法。第一个挑战是在本章开头所看的避免 lost wakeup 问题。最初 Unix 内核的 sleep 简单的关中断就足够了,因为 Unix 运行在一个单 CPU 系统。
因为 xv6 运行在一个多处理器上,它为 sleep 添加了一个显式锁。FreeBSD 的 msleep 采用了相同的方法。Plan 9 的 sleep 使用了一个回调函数,该函数在睡眠前持有调度锁;函数在最后时刻检查睡眠条件,避免 lost wakeups。Linux 内核的 sleep 使用一个显式的进程队列,称为等待队列(wait queue),而不是 wait channel;队列有自己的内部锁。

wakeup 中扫描整个进程集效率低。一个更好的方法是用一个数据结构,这个数据结构上有一列睡眠进程,如 Linux 的 wait queue,用这个数据结构来替换 sleepwakeup 中的 chan。Plan 9 的 sleepwakeup 调用组成了一个集合点(rendezvous point)或者 Rendez。许多线程库将这一结构作为条件变量;这种情况下,sleepwakeup 被称为 waitsignal。所有这些机制都有一个共同特点:睡眠条件在 sleep 期间被一些锁原子性的保护。

wakeup 的实现唤醒所有在特定 channel 上等待的进程,并且,它可能是许多进程在特定 channel 上等待的例子。操作系统将调度所有这些进程,并且它们将竞争检测 sleep condition。这种方式运行的进程有时称为 \(thundering\ herd\),最好避免。大多数条件变量有两个原语 wakeup: signal,它们唤醒一个进程,然后 broadcast 唤醒所有的等待进程。

信号量经常用来同步。计数值通常对应于管道缓冲区的可用字节数或者一个进程的僵尸子进程的数量。使用一个显式的计数作为抽象避免了 lost wakeup 问题:wakeup 发生的次数是一个显式的计数值。计数值避免了虚假的 wakeup 和 惊群效应(\(thundering\ herd\))问题

终止进程并清理它们会为 xv6 引入很多复杂性。大多数操作系统甚至更复杂,因为如,受害者进程可能在内核睡眠的深处,展开它的栈需要小心,因为调用栈上的每个函数都可能需要一些清理。一些语言通过提供 exception 机制帮助处理,但 C 语言没有。此外,即使等待的事件还没有发生,其他时间可能造成一个睡眠进程被唤醒。例如,当一个 Unix 进程睡眠时,另一个进程可能给它发送一个 signal。这种情况,进程将返回被中断的系统调用,返回值为 \(-1\),错误码设置为 EINTR。应用能检查这些值,并决定做什么。xv6 不支持 signal,没有增加复杂性。

xv6 对 kill 的支持不完全是令人满意的:有的 sleep 循环可能需要检查 p->killed 。一个相关的问题是,尽管 sleep 循环检查 p->killedsleepkill 之间有一个竞争;后者可能设置 p->killed,在受害者进程循环检查 p->killed 之后但在调用 sleep 之前尝试唤醒受害者进程。如果出现问题,则直到等待的条件出现,受害者进程才会注意到 p->killed。这可能要晚点出现,甚至永远不会出现(如果受害者进程等待 console 的输入,但用户没有键入任何字符)。

一个真正的操作系统在特定时间在一个显式的 free list 中找空闲的 proc 结构体,而不是在 allocproc 中线性查找;xv6 使用线性查找简化实现。

posted @ 2022-02-10 20:03  seaupnice  阅读(93)  评论(0编辑  收藏  举报