XV6学习(13)调度

几乎所有操作系统都会运行数量远多于CPU数量的进程,因此需要对CPU进行分时共享。理想情况下这种共享应该是对用户进程透明的。一个常用的方法是通过多路复用将进程分配到硬件CPU上,使每个进程有其自己的虚拟CPU。

多路复用

XV6在两种情况下会对CPU的进程进程切换从而实现复用:一种是XV6的sleepwakeup机制,当进程在等待设备或管道IO完成、等待子进程结束或在sleep系统调用中等待时会进行切换;另一种是XV6会周期性地强制长时间进行计算而不睡眠的进程进行切换。这种策略使每个进程有自己的CPU,就像XV6的页表使每个进程有自己是内存一样。

实现多路复用有很多挑战。首先如何从一个进程切换到另一个?尽管上下文切换的想法是很简单的,但是其实现也是XV6中最不透明的代码之一。第二,如何进行强制切换使其对用户进程透明?XV6使用的是定时器驱动的上下文切换的标准技术。第三,许多CPU可能并行地对进程进行切换,那么就需要用锁来避免争用。第四,一个进程的内存和其他资源必须在进程结束时进行释放,但是其不能独立完成所有工作,比如它不能释放自己的内核栈当仍在使用它的时候。第五,多核机器的每个核心必须记住它在执行哪一个进程,使系统调用能够正确影响进程的内核状态。最后,sleepwakeup运行进程放弃CPU并睡眠等待一个事件,允许其他进程将该进程唤醒。需要小心地避免争用,而争用可能会导致唤醒通知的丢失。XV6尝试尽可能简单地去处理这些问题,但是最后的代码仍是很棘手的。

代码:上下文切换

XV6的进程切换主要有以下几步:用户态转换到内核态(系统调用或中断)中的旧进程的内核线程,上下文切换到当前CPU的调度器线程,上下文切换到新进程的内核线程,返回用户进程。
XV6调度器在每个CPU上都有一个专用线程(保存的寄存器和栈),因为在旧进程的内核栈上执行调度器可能是不安全的:其他核心可能会唤醒该进程并运行它,而在两个不同核心上使用同一个栈是会带来灾难的。本节会介绍内核线程和调度器线程之间的切换机制。

线程间的切换涉及到保存旧线程的寄存器,恢复新线程之前保存的寄存器;sppc寄存器被保存和恢复意味着CPU会切换栈和执行的代码。

swtch函数完成内核进程切换的寄存器保存和恢复。swtch不直接知道线程;其只是保存和恢复寄存器集,即上下文。当一个进程放弃CPU,进程的内核线程就会调用swtch来保存其上下文并返回到调度器的上下文。每个上下文被保存在一个strcut context结构体中,而其自身是被保存在struct procstruct cpu中的。swtch需要两个参数,strcut context *oldstrcut context *new。其保存当前寄存器在old中,从new中恢复寄存器,最后返回。

让我们来跟踪一个进程通过swtch进入调度器。一个中断最后会在usertrap中调用yieldyield接着调用schedsched会调用swtch来保存当前上下文p->context并且切换到之前保存在cpu->scheduler中的调度器的上下文。

swtch只会保存被调用者保存寄存器,调用者保存寄存器如果需要就会被C函数调用来保存在栈中。swtch知道struct context中的每个寄存器域的偏移量。其不会保存程序计数器,而是保存ra寄存器,该寄存器中保存了调用swtch函数的返回地址。现在swtch会从新的上下文中恢复上一次swtch保存的上下文。当swtch返回时,其会返回到被恢复的ra寄存器所指向的指令,也就是新线程上一次调用swtch的指令。同时,它会返回到新线程的栈。

在这个例子中,sched调用swtch来切换到每个CPU的调度器上下文cpu->scheduler。这个上下文被调度器调用的swtch所保存。当我们追踪的swtch返回时,它不会返回到sched函数而是scheduler函数,并且栈指针被指向当前CPU的调度器栈。

代码:调度

上一节讲解了swtch的底层细节,现在,我们以swtch为例,研究从一个进程的内核线程通过调度器切换到另一个进程的过程。调度器以每个CPU的一个专用线程的形式存在,每个线程都运行scheduler函数。这个函数负责选择下一个将要运行的进程。如果一个进程要放弃CPU,就必须释放进程自己的锁p->lock,释放所有持有的锁,更新其状态p->state并调用schedyield函数遵循这个约定,sleepexit也是。sched会再次检查这些条件,这些条件的含义是:当锁被持有,就必须关中断。最后sched调用swtch保存当前上下文在p->context中并切换到调度器上下文。调度器会继续执行for循环,查找一个进程来运行,并切换到该进程,之后重复循环。

swtch调用期间XV6会持有p->lockswtch的调用者必须持有该锁,之后锁的控制权被传递到被切换到的代码。这种锁的约定并不常见,通常获取锁的线程也要负责释放锁,从而更容易保证代码正确性。对于上下文切换,必须要打破这种约定,因为p->lock保护进程statecontext域的不变性,而在执行swtch时这是不正确的。例如如果p->lock没有在swtch时被持有,另一个CPU就可能会决定运行这个进程并设置状态为RUNNABLE,但是之前的swtch会使其停止使用它自己的内核栈,这就导致两个CPU在同一个栈上运行,而这可能是不正确的。

一个内核线程总是在sched中放弃CPU,总是切换到调度器的相同位置,几乎总是切换到之前调用sched的某个内核线程。因此,如果打印出XV6切换线程的行号,将会观察到这些简单的模式:kernel/proc.c:475kernel/proc.c:509kernel/proc.c:475kernel/proc.c:509等。这种在两个线程之间进行样式化切换的过程有时候称为协程。在这个例子中,schedscheduler彼此是协程。

有一种情况调度器调用swtch不会在sched中结束。当一个新进程第一次被调度,它开始于forkretforkret需要释放p->lock,否则新进程可能开始于usertrapret

调度器运行一个简单循环:查找一个进程来运行,运行这个进程直到它让出,之后重复上述过程。调度器遍历进程表来查找一个可运行的进程,即p->state == RUNNABLE。当其找到了一个进程,就会设置CPU的当前进程变量c->proc,标记进程为RUNNING,调用swtch来运行它。

调度代码的结构可以看作它保证了每个进程的一系列不变量,当任何不变量不正确的时候都持有p->lock。一个不变量是当一个进程状态为RUNNING,定时器中断的yield必须能够安全地从这个进程切换出去;这意味着CPU寄存器必须持有进程的寄存器值(如swtch没有将其移动到context),并且c->proc必须指向该进程。另一个不变量是当进程为RUNNABLE,空闲的CPU的调度器必须能安全地运行它;这意味着p->context必须保存了进程的寄存器,没有CPU在当前进程的内核栈上执行,没有CPU的c->proc指向该进程。上面这些属性通常在持有锁时是不正确的。

维护上述的不变量是为什么XV6要在一个线程获取锁而在另一个线程释放锁的原因,如在yield中获取锁,在scheduler中释放锁。当yield开始修改一个运行中的进程的状态为RUNNABLE,锁必须一直被持有直到不变性被恢复:最早的正确释放点就是在scheduler清除了c->proc。类似地,当scheduler开始转换一个RUNNABLE的进程为RUNNING,锁不能被释放直到内核线程完全开始运行(在swtch之后,如yield中)。

p->lock也保护了其他的东西:exitwait之间的相互作用,避免唤醒丢失的机制,以及避免一个进程退出时其他进程读或写其状态时的冒险(如exit系统调用查看p->pid并设置p->killed

代码:mycpumyproc

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

XV6为每个CPU维护了一个struct cpu,记录了当前运行的进程,调度器线程的上下文以及spinlock的嵌套层数。mycpu函数返回一个指向当前CPU的结构体的指针。RISC-V对每个CPU编号,给每个CPU一个hartid。XV6保证在内核中时每个CPU的hartid被保存在其tp寄存器中。这就使得mycpu可以用tp来从cpu结构体数组中查找当前CPU所对应的。

保证CPU的tp总是保存CPU的hartid是有一点复杂的。CPU启动流程中的mstart设置tp寄存器,此时仍处于机器模式。usertrapret保存tp寄存器在trampoline,因为用户进程可能会修改tp。最后当从用户态进入内核态时uservec恢复保存的tp寄存器。编译器保证不会使用tp寄存器。如果RISC-V允许直接读取hartid的话就会方便很多,但是这只在机器模式中被允许而不在监管者模式中。

cpuidmycpu的返回值是脆弱的:如果一个定时器中断,导致线程让出并被移动到另一个不同的CPU上,那么之前的返回值就不再正确了。为了避免这个问题,XV6要求调用者关中断,只有当使用完struct cpu时才能开中断。

myproc函数返回指向当前CPU运行进程的struct proc的指针。myproc关闭中断,调用mycpu,从struct cpu中获取当前进程指针,之后再开中断。myproc的返回值是安全的即使中断被允许:如果定时器中断将进程移动到另一个CPU,proc指针仍然是当前进程。

睡眠和唤醒

调度和锁帮助了进程之间隐藏对方的存在,但是我们还没有如何帮助进程互相交互的概念。很多机制用来解决这个问题。XV6使用一种叫睡眠和唤醒的机制,这允许一个进程睡眠并等待一个事件,当事件发生时另一个进程来唤醒它。睡眠和唤醒通常称为序列协调或条件同步机制。

为了说明这一点,让我们考虑一种称为信号量的协调生产者和消费者的同步机制。信号量维护一个计数器并提供两种操作。V操作(对应生产者)增加计数器。P操作(对应消费者)等待直到计数器非0,并减少计数器最后返回。如果只有一个生产者和一个消费者线程在不同CPU上运行,并且编译器没有优化的太激进,那么下列实现是可行的:

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);
}

上述实现的代价是很高的。如果一个生产者很少执行操作,那么消费者就要耗费很多时间在自旋等待计数器变为非0。消费者的CPU可以找到比忙等更有效率的工作来执行。而避免忙等就需要消费者让出CPU并只有当V增加了计数器时才恢复。

这是朝这个方向迈出的一步,尽管我们将看到这还不够。让我们想想一对调用sleepwakeupsleep(chan)在任意的chan值上睡眠。sleep将调用的进程睡眠,释放CPU给其他进程工作。wakeup(chan)唤醒所有在chan上睡眠的进程,使它们的sleep调用返回。如果没有进程在chan上睡眠,wakeup就不做任何事。我们可以用sleepwakeup来实现信号量:

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

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

现在P放弃CPU而不是自旋。但是,事实证明,使用该接口设计睡眠和唤醒而不产生睡眠丢失问题并不是一件容易的事。假设P发现s->count==0,在调用sleep之前,另一个CPU上的V返回了:它修改了s->count为非0值并调用wakeup,发现没有进程在睡眠因此不做任何事情。现在P继续执行,调用sleep并进入睡眠。这就导致一个问题:P在睡眠等待已经发生了的V调用。除非我们很幸运程序又调用了一次V,否则消费者就会一直睡眠即使计数器为非0。

这个问题的根源就是P只在s->count==0时睡眠的不变量被正在运行的V给破坏了。一个不正确的方法是移动P中锁的获取来维护不变量,使其检查计数器和调用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);      // change this
    while (s->count == 0)
        sleep(s);
    s->count -= 1;
    release(&s->lock);
}

这个版本的P可以避免唤醒丢失,因为锁阻止了V的执行。但如果这样做,就会导致死锁:P持有锁并睡眠,因此V会被一直阻塞等待锁。

我们要通过修改sleep接口来修复上述方案:调用者必须传递一个条件锁给sleep,使得其可以在睡眠调用的进程并在睡眠通道上等待时释放锁。锁会强制并行的V等待直到P将它自己睡眠,因此wakeup会找到一个正在睡眠的消费者并唤醒它。一旦消费者被唤醒,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->lock); // change this
    s->count -= 1;
    release(&s->lock);
}

P持有s->lock来阻止V在P检查s->count和调用sleep之间尝试唤醒。注意我们需要sleep原子地释放s->lock并将消费者进程睡眠。

代码:sleepwakeup

让我们来看sleepwakeup的实现。最基本的想法就是sleep标记当前进程为SLEEPING,调用sched来释放CPU;wakeup在提供的睡眠通道上的找到一个进程并将其标记为RUNNABLEsleepwakeup的调用者可以用任何相互方便的随着来作为通道。XV6通常用涉及等待的内核数据结构的地址。

sleep获取p->lock。当进程睡眠时同时持有p->locklk。持有lk是在调用者中必要的:这保证没有其他进程可以开始调用wakeup。当sleep持有p->lock时,就可以安全地释放lk了:其他进程可能会尝试调用wakeup,但是wakeup将会等待p->lock,因此将会等待sleep将进程睡眠,避免了唤醒丢失。

这里有一个小的问题:如果lkp->lock是一样的,sleep如果尝试获取p->lock就会死锁。但是如果进程调用sleep时已经持有p->lock了,就不需要多做任何事来避免并行wakeup的丢失了。这种情况在wait使用p->lock调用sleep时发生。

现在sleep只持有p->lock,它可以将进程睡眠并记录睡眠通道,改变进程状态为SLEEPING,调用sched。后面会讲到为什么当进程被标记为睡眠后,p->lock必须由调度器释放。

在有些时候,一个进程会获取条件锁,设置睡眠者正在等待的条件,并调用wakeup。当wakeup被调用时,条件锁必须被持有。wakeup遍历进程表,获取每个进程的p->lock,这既是因为它会操作进程状态,也因为要保证sleepwakeup不会发生丢失。当wakeup发现一个SLEEPING状态的进程并且chan也是对应的,它就会修改进程状态为RUNNABLE。下一次调度器运行时,它就会发现该进程已经可以运行。

为什么sleepwakeup的锁规则保证了睡眠进程不会丢失唤醒?正在睡眠的进程从它检查条件之前到它标记SLEEPING之后要么持有条件锁,要么持有自己的p->lock或者持有两者。而调用wakeup的进程在其循环中持有这些锁。因此唤醒者要么在消费者检查条件之前设置条件为真,要么检查者的wakeup在进程被标记为SLEEPING之后再检查睡眠线程,那么wakeup就会看到睡眠的线程并唤醒它。

有些时候多个进程可能在同一个通道上睡眠;例如多于一个进程读取管道。一次单独的wakeup将会唤醒所有。它们其中的一个将会先运行并获取到锁,并从管道中读取任何正在等待的数据。而其他进程就会发现尽管被唤醒了但却没有任何数据可以读,从它们看来这种唤醒是“虚假的”,因此它们需要再次睡眠。这也就是为什么sleep要在一个检查条件的循环中被调用。

如果两个睡眠/唤醒意外地选择了相同的通道也是没有问题的:它们会看见虚假唤醒,上面讲的循环能够容忍这种问题。睡眠/唤醒的魅力在于,它既轻巧(不需要创建特殊的数据结构来充当睡眠通道),又提供了一个间接层(调用者无需知道与之交互的具体进程)。

代码:管道

一个更加复杂的使用sleepwakeup进行生产者消费者同步的例子就是XV的管道实现。写入到一个管道尾部的字节被拷贝到内核缓冲区并且可以从管道的另一端读取。下一章会介绍文件描述符对管道的支持,现在我们先看pipewritepiperead的实现。

每个管道表现为一个struct pipe,其中包含一个锁和一个数据缓冲区。nreadnwrite域统计读取和写入缓冲区的字节数。缓冲区是一个环:buf[PIPESIZE-1]的下一个字节是buf[0]。但是计数器不会重置。这种约定使得可以区分满缓冲区(nwrite == nread + PIPESIZE)和空缓冲区(nwrite == nread),但是这也意味着缓冲区的下标必须是buf[nread % PIPESIZE]而不是buf[nread]nwrite也是类似的)。

假设pipereadpipewrite的调用同时发生在两个不同CPU上。pipewrite开始获取管道的锁,该锁保护了计数器,数据和它们的不变量。piperead接着尝试获取锁,但是无法获取到。它开始在acquire中自旋等待锁。当piperead等待时,pipewrite开始循环遍历要写入的字节,每次循环将字节加入管道。在循环时缓冲区可能满,这种情况pipewrite就会调用wakeup来提醒任何正在睡眠等待读取的进程,之后在&pi->nwrite上睡眠等待读者从缓冲区中读出字符。sleep会释放pi->lock

现在pi->lock是可获取的,piperead就会获取该锁并进入临界区:它发现nread != nwrite,因此会进入for循环,从缓冲区中拷贝数据出去,增加nread计数器。现在就有一些字节可以被写入,因此在piperead返回前就会调用wakeup来唤醒任何正在睡眠的写者。wakeup找到在&pi->nwrite上睡眠的进程,这个进程正在运行pipewrite但是因为缓冲区满而停止。找到后将进程标记为RUNNABLE

管道的代码中对读者和写者使用不同的睡眠通道,这可以使多个读者写者同时在管道上等待时的系统更加高效。管道的睡眠是在一个检查睡眠条件的循环内的,如果有多个读者写者,除了第一个运行的进程外,其他所有进程都会唤醒但是发现条件仍然是false,因此重新睡眠。

代码:wait exitkill

sleepwakeup可以用在很多种等待中。一个有趣的例子就是子进程的exit和父进程wait之间的交互。当子进程死亡,父进程可能已经在wait中睡眠,也可能正在做其他事情;在后面这种情况下,接下来的wait调用必须能够观察到子进程的死亡,可能在子进程exit之后很久。XV6通过将exit的调用者状态设置为ZOMBIE来记录子进程的消亡,进程会保持这种状态直到父进程的wait观察到,然后将进程状态改变为UNUSED,拷贝子进程的退出状态,返回子进程的pid给父进程。如果父进程在子进程之前退出,子进程的父进程就会给init进程,该进程会不停调用wait;因此每个子进程都会有父进程来对其进行清理。实现的最大挑战就是父子进程的waitexit之间的竞争和死锁,exitexit之间也是。

wait使用调用进程的p->lock作为条件锁来避免唤醒丢失,并且在开始时就会获取这个锁。之后遍历进程表。如果找到一个子进程的状态为ZOMBIE,就会释放子进程的资源和proc结构体,拷贝子进程的退出状态到wait的参数(如果不是0),返回子进程的pid。如果wait找到子进程但是没有退出的,就对调用sleep来等待子进程退出,之后再次扫描。在sleep中释放的条件锁是等待进程的p->lock,也就是之前提到的特殊情况。注意wait通常持有两个锁;因此它要在获取子进程锁之前获取当前进程的锁;也就是所有XV6必须遵守相同锁顺序(父进程,之后子进程)以避免死锁。

wait查看每个进程的np->parent来查找子进程。在使用np->parent时并没有持有锁,这违背了常见的共享变量必须被锁保护的规则。因为np可能是当前进程的祖先,而这种情况下获取np->lock就会违背上述的规则从而可能导致死锁。不加锁判断np->parent在这种情况下是安全的:一个进程的parent域只会被其父进程修改,因此如果np->parent==p,这个值是不会被改变的直到当前进程改变它。

exit记录退出状态,释放一些资源,将所有子进程交给init进程,唤醒在wait的父进程,标记调用者为ZOMBIE,并且永久地让出CPU。最后的流程有一点复杂。当退出进程其设置状态为ZOMBIE并唤醒父进程时必须持有父进程的锁,因为父进程的锁是用来避免wait中唤醒丢失的。子进程也必须持有它自己的p->lock,因为父进程可能会看见其状态ZOMBIE从而在其仍在运行时就释放该进程。锁获取对应避免死锁是很重要的:因为wait先申请父进程的锁,所有exit也必须用相同顺序。

exit调用了特殊的唤醒函数wakeup1,这只会唤醒在wait中睡眠的父进程。子进程在设置自己为ZOMBIE之间就唤醒父进程看起来是不对的,但这是安全的:wakeup1可能会使父进程开始运行,但是wait中的循环不能对子进程进行判断直到其子进程的锁被scheduler释放,因此wait不能查看正在退出的进程直到exit设置其状态为ZOMBIE

exit运行一个进程结束自己,而kill允许一个进程结束其他进程。kill直接结束要终止的进程的话会很复杂,因为这个进程可能正在其他CPU上执行,可能正在对内核数据结构进行更新。因此kill只做很少的工作:它仅仅设置进程的p->killed,并且如果它在睡眠就唤醒它。最终牺牲进程会进入或者离开内核,而usertrap会调用exit如果p->killed被设置了。如果牺牲进程运行在内核态,其稍后通过系统调用或者定时器(或其他设备)中断进入内核。

如果牺牲进程在睡眠,kill就会调用wakeup来使进程从sleep中返回。而这可能是危险的因为等待的条件可能不是真。然而,XV6的sleep总是被包含在while循环中。一些对sleep的调用在循环中同样测试p->killed,并且如果被设置了放弃当前活动。仅当这种放弃是正确的时候才这样做。例如管道读写代码会在killed被设置时返回,而代码最后会返回到trap,这会再次判断标志并退出。

一些XV6的sleep循环并不会检查p->killed,因为代码在多步系统调用中,而这应该是原子性的。Virtio驱动就是一个例子:它不会检查p->killed因为磁盘操作可能是一系列写当中的一个,而这些操作全部需要顺序完成进行来保证文件系统的正确性。在等待磁盘IO的进程不会被杀死直到它完成了整个系统调用,之后usertrap会检查标志。

真实操作系统

XV6调度器使用非常简单的调度策略,循环运行每个进程。这种策略被称为轮询(round robin)。真实操作系统会实现更多复杂的策略,例如允许进程有优先级。高优先级的进程会比低优先级的进程优先调度。这些策略会很快变得复杂因为经常存在相互竞争的目标:例如操作系统可能想要同时获得公平和高吞吐量。另外,复杂策略可能导致意外的交互,如优先级倒置和护航(Convoy)。优先级倒置发生在一个低优先级进程和高优先级进程共享一个锁,那么低优先级进程获取锁会阻止高优先级进程运行。当许多高优先级的进程正在等待获得共享锁的低优先级的进程时,可能会形成一长串的等待进程。一旦队列形成,它可以持续很长时间。为了避免这类问题,复杂的调度程序中还需要其他机制。

sleepwakeup是简单而高效的同步方法,但是也有其他的方法。所有这些方法的第一个挑战就是避免唤醒丢失。原始UNIX内核的sleep简单地关闭中断,这就足够了因为UNIX运行在单CPU系统上。因为XV6运行在多处理器上,其添加了一个显式锁来sleep。FreeBSD的msleep使用相似的方法。Plan 9的sleep使用回调函数在即将进入睡眠时调用。Linux内核的sleep使用一个称作等待队列的显式进程队列来替代等待通道;队列有其自己内部的锁。

wakeup中扫描整个进程列表来查找匹配的chan是低效的。一个更好的方法是将sleepwakeup中的chan替换为一个数据结构,在其中保持睡眠进程的列表,就像Linux的等待队列。许多线程库使用相同的数据结构作为条件变量;在这种情况下,sleepwakeup被叫做waitsignal。所有这些机制都具有相同的机制:通过在睡眠过程中自动断开某种锁来保护睡眠条件。

wakeup的实现唤醒通道上所有等待的进程,而可能有很多进程在该通道上等待,操作系统会调度所有的这些进程而它们竞争地去检查睡眠条件。这种方式的过程有时候被叫做雷群(thundering herd),而这最好被避免。大部分条件变量有两种wakeupsignal唤醒一个进程,broadcast唤醒所有等待进程。

信号量通常被用于同步。计数通常和一些东西相对应,如管道缓冲区的可用字节数,进程的僵尸进程数。使用显式计数作为抽象的一部分来避免唤醒丢失问题:这里有显式的发生了的唤醒计数。计数同样避免了虚假唤醒和雷群问题。

XV6中的终止和清理进程引入了很多复杂性。在大部分操作系统中这是更加复杂的,因为例如牺牲进程可能在内核深处休眠,而展开它的栈需要很小心的编程。许多操作系统使用显式异常处理机制来展开栈,例如longjmp。此外,其他事件也可以使睡眠进程被唤醒,尽管正在等待的事件还没有发生。例如当一个UNIX进程在睡眠,其他进程可能发送一个signal信号。在这种情况下,进程会从被中断的系统调用返回-1并将错误码设置为EINTR。应用可以检查错误码并决定如何处理。XV6不支持信号因此这种复杂性不会发生。

XV6对kill的支持并不完全令人满意:睡眠循环可能需要检查p->killed。一个相关的问题就是,即使对于检查了p->killed的睡眠循环,sleepkill之间也会有竞争;之后可能会设置p->killed并尝试唤醒牺牲进程就在牺牲进程的循环检测完p->killed而在调用sleep之前。如果这个问题发生了,牺牲进程不会注意到p->killed直到等待的条件发生。而这可能会有一点迟或者不发生。

一个真实操作系统会以常数时间复杂度在显式空闲链表中查找空闲proc结构体而不是线性时间复杂度,XV6为了简单而使用线性扫描。

posted @ 2021-02-08 20:35  星見遥  阅读(968)  评论(0编辑  收藏  举报