任何操作系统都有可能运行比CPU数更多的进程,所以需要对如何在进程之间分享CPU时间进行妥善安排。理想的共享应该对用户进程透明。常用的方法是在硬件CPU上复用进程,给每个进程都呈现一种假象,它拥有它自己的虚拟CPU。这一章阐述了xv6怎样实现这样的复用。
likely illusion
xv6中,有两种情况下,会将CPU从一个进程切换到另一个进程,从而实现复用。第一种,当进程等待设备或管道I/O完成,或等待子进程退出,或者在sleep系统调用中等待时,xv6的sleep与wakeup机制会进行切换。第二种,xv6周期性地强制切换,来处理长时间计算而不进行睡眠的进程。这种复用制造了每个进程有自己CPU的假象,正如xv6使用内存分配器和硬件页表来制造每个进程有自己的内存的假象一样。
实现复用提出了一些挑战。首先,如何从一个进程切换到另一个进程?虽然切换上下文的想法看起来很简单,但它的实现是xv6最为晦涩难懂的代码之意。其次,怎样用一种对用户进程透明的方法来强制切换?xv6使用标准技术,使用计时器中断来驱动上下文切换。第三,很多CPU可能同时在进程间切换,因此有必要制定一个锁方案来避免竞争。第四,进程的内存和其他资源必须在进程退出时释放,但进程无法独立完成所有,(例如)它不能释放自己还在被使用的内核栈。第五,多核机器的每个核必须记住正在运行的进程,这样系统调用才能操作正确进程的内核状态。最后,sleep和wakeup允许进程在等待某个事件时放弃CPU,进入睡眠,也允许别的进程唤醒之前的进程。需要注意避免竞争,不然会导致唤醒通知的丢失。xv6试着尽可能简单地解决这些问题,然而最后的代码却充满了小技巧。
opaque nevertheless
图7.1描绘了从一个用户进程切换到另一个所包含的步骤:一个用户-内核转换(系统调用或中断)到老进程的内核线程,上下文切换到当前CPU的调度线程,一个上下文切换到新进程的内核线程,一个trap返回用户层进程。xv6调度器在每个CPU上有一个专用的线程(保存的寄存器和栈),因为调度器在老进程的内核栈上运行并不安全。别的CPU核可能唤醒这个线程并运行它,在两个不同的CPU上使用同一个栈可能是灾难性的。在这个部分中,我们将研究内核线程和调度线程之间的切换机制。
从一个线程切换到另一个线程包括保存旧线程的CPU寄存器,恢复之前保存的新线程的寄存器;栈指针和程序计数器都要被保存和恢复,这说明CPU将会切换栈,并切换正在执行的代码。
swtch函数执行了内核线程切换中的保存和恢复。swtch不直接了解线程;它只是保存和恢复寄存器集合,又叫上下文。当进程需要让出CPU时,进程的内核线程调用swtch来保存自己的上下文,返回到调度器的上下文中。每个上下文的包含在context结构体中(kernel/proc.h:2),context结构体本身又被包含在进程的proc结构体或者CPU的cpu结构体中。swtch有两个参数:struct context *old
与struct context *new
。它把当前的寄存器保存在old中,从new中加载寄存器,然后返回。
我们来跟踪一个进程切换到调度器的过程。在第4章中我们看到一种可能性,在中断的最后,usertrap会调用yield。yield然后调用了sched,sched会调用swtch来将当前的上下文保存在p->context中,然后切换到之前保存在cpu->scheduler(kernel/proc.c:509)中的调度器上下文。
swtch(kernel/swtch.S:3)仅保存了被调者保存的寄存器;调用者保存的寄存器通过调用C代码保存在栈上(如果需要)。swtch知道context结构体中每个寄存器域的偏移。它不保存程序计数器,而是保存ra寄存器,ra记录了swtch被调用位置的返回地址。现在swtch开始从新上下文中恢复寄存器,其中的寄存器值是上一个swtch所保存的。当swtch返回时,它返回到恢复的ra寄存器所指向的指令。补充一下,它返回到新线程的栈上。
在我们的例子中,sched调用swtch来向cpu->scheduler切换,这是每个CPU的调度器上下文。这个上下文在调度器调用swtch时被保存(kernel/proc.c:475)。当我们追踪的swtch返回时,它不是返回到sched而是返回到scheduler中,而且它的栈指针指向当前CPU的调度器栈。
dedicated
上个部分研究了swtch的底层细节;现在我们将swtch视为已知,研究从一个进程的内核线程通过角度器切换到另一个进程的过程。调度器在每个CPU上以一种特殊线程的形式存在,每个都在执行scheduler函数。这个函数负责挑选下一个运行的进程。想要让出CPU的进程必须获取到自己的进程锁p->lock,释放持有的其他锁,更新自己的状态(p->state),然后调用sched。yield(kernel/proc.c:515)遵循这个习惯,与sleep和exit一样,这两个函数我们会在后面讲到。sched两次检查这些条件,然后是一个这些条件隐含的条件:因为持有锁,中断一定是被禁止的。最后,sched调用swtch来将当前的上下文保存在p->context中,切换到cpu->scheduler中的调度器上下文。swtch返回到调度器的栈,类似调度器的swtch之前的返回。调度器继续for循环,找到一个要运行的进程,切换过去,不断重复这个周期。
我们刚刚看到,xv6在调用swtch过程中持有p->lock:swtch的调用者必须已经持有这把锁,而且锁的控制权传递给了切换到的代码。这个惯例对所来说并不常见;通常获取锁的线程也要负责释放锁,这样更容易判断是否正确。在上下文切换中必须打破这个惯例,因为p->lock所保护的进程中的state和context上的不变量在swtch执行过程中是不正确的。有一个例子可以说明swtch过程中未持有p->lock会引发的问题:另一个CPU可能决定在yield后运行这个进程,将他的state设置为RUNNABLE,但在之前,swtch已经让进程停止使用自己的内核栈。结果是,两个CPU运行在同一个栈上,这是不可能正确的。
一个内核线程通常在sched中让出CPU,而且通常会切换到调度器中的同一个位置,调度器几乎总是切换到之前调用了sched的内核线程中。因此,如果在xv6切换线程的地方打印行号,将会看到一直是同一种简单的模式:(kernel/proc.c:475),(kernel/proc.c:509),(kernel/proc.c:475),(kernel/proc.c:509),等等等。在两个线程之间发生这种模式化的切换的过程有时叫做协程;在本例中,sched与scheduler彼此互为协程。
有一种情况,调度器调用的swtch不会在sched结束。当新进程开始被调度,它从forkret(kernel/proc.c:527)开始。forkret存在是为了释放p->lock;不然新的进程将以usertrapret开始。
scheduler(kernel/proc.c:457)运行一个简单的循环:找到要运行的进程,运行它直到它让出CPU,不断重复。调度器在进程表中查找可运行的进程,也就是p->state == RUNNABLE的进程。找到后,它设置每个CPU的当前进程变量c->proc,将进程标记为RUNNING,然后调用swtch来运行它(kernel/proc.c:470-475)。
有一种考虑调度代码结构的方法是这样:它强制保证每个进程的一组不变量,在不变量不正确时持有p->lock。一个不变量是如果一个进程状态为RUNNING,计时器中断yield必须能够安全地从这个进程切出去;就是说CPU寄存器必须持有进程寄存器的值(即swtch没有将它们移到context中),而且c->proc必须指向本进程。两一个不变量是如果一个进程是RUNNABLE,空闲CPU的scheduler运行它的时候必须是安全的;就是说p->context必须持有进程的寄存器(即它们当前不在真实寄存器中),没有CPU在这个进程的内核栈上运行,而且没有CPU的c->proc指向这个进程。可以看到,当p->lock被持有时,这些不变量通常是错误的。
为了保持上面这些不变量,xv6总是在一个线程获取p->lock而在另一个线程释放它,比方说在yield中获取,在scheduler中释放。一旦yield开始把正在执行的线程的state修改为RUNNABLE,锁必须保持持有状态,直到不变量恢复:最早的正确释放点在scheduler(在它自己的栈上运行)清除c->proc之后。类似的,一旦scheduler开始将进程由RUNNABLE切换到RUNNING,锁就不能被释放,知道内核线程完全运行起来(swtch之后,例如在yield中)。
p->lock也保护了其他东西:exit与wait之间的相互作用,避免唤醒丢失的流程,避免进程退出时有其他进程正在读或写它的状态所造成的竞争(例如,exit系统调用查看p->pid并设置p->killed(kernel/proc.c:611))。p->lock的不同函数是否应该分开,这是一个值得思考的问题,不管为了简单还是为了性能。
in charge of as though convention makes it easier to reason about correctness coroutines co-routines machinery interplay clarity split up
xv6进程需要一个指向当前进程proc结构体的指针。在单处理器上,可以有一个全局变量执行当前的proc。在多核机器上不能这样,因为每个核执行一个不同的进程。解决这个问题的方法是利用每个核所拥有的一组寄存器;我们可以使用这些寄存器中的一个帮我们找到每个核的信息。
xv6为每个CPU维护了一个cpu结构体(kernel/proc.h:22),其中记录了当前运行在该CPU上的进程(如果有),CPU的调度器线程保存的寄存器,以及管理中断禁用需要的嵌套的自旋锁的数量。函数mycpu(kernel/proc.c:60)返回指向当前CPU的cpu结构体的指针。RISC-V会给CPU编号,每个CPU都有一个hartid。xv6保证,当运行在内核时,每个CPU的hartid都保存在其tp寄存器内。这使得mycpu可以使用tp在cpu结构体数组中找到正确的一个。
保证CPU的tp总是持有自己的hartid有点难。mstart在CPU的启动过程早期,仍在机器模式下时,设置tp寄存器(kernel/start.c:46)。usertrapret将tp保存在跳板页中,因为用户进程可能会修改tp。最后,uservec在从用户空间进入内核时恢复保存的tp(kernel/trampolie.S:70)。编译器保证从不使用tp寄存器。如果RISC-V能让xv6直接读取当前hartid的话会很方便,但这只在机器模式下允许,管理者模式不行。
cpuid与mycpu的返回值非常脆弱:如果计时器将要中断,而且引起了进程出让CPU,然后跳到另一个CPU上,那之前返回的值就不正确了。为了避免这个问题,xv6需要调用者关闭中断,知道使用完返回的cpu结构体时才能开启。
函数myproc(kernel/proc.c:68)返回当前CPU上正在运行的进程的proc结构体指针。myproc关掉中断,调用mycpu,从cpu结构体中取出当前的进程指针(c->proc),然后开启中断。myproc的返回值即使在中断开启的情况下也可以安全使用:如果计时器中断将调用进程移动到另一个CPU,这个CPU的proc结构体也还是相同的。
involved fragile
调度与锁将进程的存在在别的进程那里隐藏起来,但到现在我们还没有什么抽象手段,能让进程进行交互。为了解决交互问题发明了很多机制。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应该做更多生产性的工作,而不是忙等待于重复轮询s->count。避免忙等待需要一种方法,让消费者能够出让CPU,知道V操作增加count的时候再恢复。
下面来讲往这个方向努力的第一步,尽管我们会发现这还不够。假设有一对调用,sleep与wakeup,以下面的方式运行。sleep(chan)在任意值chan上睡眠,chan被称为等待频道。sleep将调用进程置为sleep状态,释放CPU给其他工作。wakup(chan)唤醒在chan上睡眠的所有进程(如果有),引起它们的sleep调用返回。如果chan上没有进程在等待,wakeup什么也不做。我们可以在semaphore的实现中使用sleep与wakup:
200 void
201 V(struct semaphore *s)
202 {
203 acquire(&s->lock);
204 s->count += 1;
205 wakeup(s);
206 release(&s->lock);
207 }
208
209 void
210 P(struct semaphore *s)
211 {
212 while(s->count == 0)
213 sleep(s);
214 acquire(&s->lock);
215 s->count -= 1;
216 release(&s->lock);
217 }
P现在放弃CPU而不是自旋,这已经前进了一步。然而,事实证明,我们不能直接用这样的接口设计sleep与wakeup,众所周知的lost wake-up问题还是无法避免。假设P在212行发现s->count == 0。当P在212行与213行之间时,V在另一个CPU上运行:它将s->count改为非0,然后调用wakeup,wakeup会发现没有正在睡眠的进程,因而什么都没做。现在P继续执行213行:它调用了sleep开始睡眠。这就造成了一个问题,醒着的P在等待一个已经发生的V。除非我们走运,生产者再次调用了V,不然消费者将永远等待下去,即使cout已经是非0数。
这个问题的根源来自于V正好在错误的时间运行,违反了P仅仅在s->count == 0睡眠这个不变性。有一个错误的保护这个不变性的方法是将锁获取放在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);
}
可能有人会认为这个版本的P能够避免唤醒丢失问题,因为锁的保护,V不能在第313与314行之间执行。这点确实做到了,但它带来了死锁:当P睡眠的时候持有锁,所以V将会永远阻塞在锁等待中。
我们将通过修改sleep接口来修复前面的方案:调用者必须将条件锁传给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);
s->count -= 1;
release(&s->lock);
}
P持有的s->lock锁能够防止V在P执行检查c->count与调用sleep之间尝试唤醒P。然而,请注意需要sleep自动释放s->lock然后将消费者进程设置为睡眠。
conceal arbitrary
我们现在来看一下sleep(kernel/proc.c:548)和wakeup(kernel/proc.c:582)。基本思想是sleep需要将当前进程标记为SLEEPING,然后调用sched来释放CPU;wakeup在给定的等待频道寻找一个正在睡眠的进程,然后将其标记为RUNNABLE。sleep与wakup的调用者可以使用任何方便实用的数字作为频道号。xv6通常使用等待中包含的数据结构的地址。
sleep获取到p->lock(kernel/proc.c:559)。现在将要睡眠的进程持有p->lock与lk两把锁。调用者(本例中的P)必须持有lk:它保证了没有其它进程(本例中是指一个运行V的进程)能够发起对wakeup(chan)的调用。现在sleep持有p->lock锁,它能够安全地释放lk了:有些其他进程可能开始调用wakeup(chan),但wakeup将会等待获取p->lock,因此将会等到sleep将进程设置为睡眠之后,以防wakeup找不到这个sleep。
这里有一个小小的难点:如果lk与p->lock是通一把锁,sleep在尝试获取p->lock时,将与自己发生死锁。但如果调用sleep的进程已经持有了p->lock,它就没有必要为避免丢失并行的wakeup做更多的事。当wait(kernel/proc.c:582)调用有p->lock的sleep时会发生这种情况。
现在,sleep只持有p->lock这一把锁,它能够记录睡眠频道,将进程state改为SLEEPING,并调用sched(kernel/proc.c:564-567),从而将进程设置为睡眠。后面我们会明白,为什么需要这么严格,必须在进程被标记为SLEEPING之后p->lock才能被释放。
有时候,进程需要获取条件锁,设置睡眠者正在等待的条件,然后调用wakeup(chan)。wakeup在持有条件锁的情况下被调用非常重要。wakeup在进程表(kernel/proc.c:582)上运行。它获取它所检查进程的p->lock,一方面是因为它可能要操作进程的state,另一方面p->lock保证了sleep与wakeup不会相互丢失。当wakeup找到一个chan匹配的SLEEPING状态的进程时,它将其state设置为RUNNABLE。调度器下次运行时,将看到这个进程准备好运行了。
为什么sleep和wakeup的锁规则保证了睡眠的进程不会错过唤醒?睡眠的进程在检查条件之前到它被标记为SLEEPING之后的时间段内,持有条件锁和它自己的p->lock中的一把或两把。调用wakeup的进程在wakeup的循环中同时持有这两把锁。因此,唤醒者要么在消费线程检查条件之前将条件设为true,要么在它被标记为SLEEPING之后设置。wakeup将看到正在水面的进程并唤醒它(除非别的东西已经在之前将它唤醒)。
有时会出现多个进程在同一个频道睡眠的情况;举例来说,多于一个进程正在从管道读取数据。单个wakeup将把它们都唤醒。其中一个会首先运行并获取到sleep被调用时所用的那把锁,然后(在管道的例子中)读取管道中正在等待的数据。其他的进程将发现,尽管被唤醒了,但并没什么数据可以读。在它们看来这是“虚伪的”,然后它们必须继续睡眠。因为这个原因,sleep通常在一个检查条件的循环中被调用。
如果两个地方使用在同一个频道使用了睡眠/唤醒也不会造成什么危害:它们会看到伪唤醒,但上面所说的循环会容忍这个问题。睡眠/唤醒机制的魅力主要在于轻量化(不需要创建特殊的数据结构来扮演睡眠频道的角色)和间接层的提供(调用者不需要知道与它交互的进程是谁)。
mutually inspect spurious charm
一个更复杂的使用sleep和wakeup来同步生产者和消费者的例子是xv6中管道的实现。我们在第1章中看到了管道接口:在管道一段写入的数据被拷贝到一个内核缓冲区,然后就可以在管道的另一端读取。后面的章节将解释外围支撑管道实现的文件描述符,但我们现在先来看一下pipewrite和piperead的实现。
每个管道由一个pipe结构体表示,其中包含一把锁和一个数据缓冲区。nread域和nwrite域记录了从管道读取和写入管道的总字节数。缓冲区是环形:buf[PIPESIZE - 1]后面写入的字节是buf[0]。计数不会归零。这种约定让管道的实现能够区分缓冲区是满的(nwrite == ntead + PIPESIZE)还是空的(nwrite == nread),但这也意味着缓冲区索引必须使用buf[nread % PIPESIZE]而不仅仅是buf[nread](nwrite也类似)。
假设对pipread和pipewrite的调用同时发生在两个不同的CPU上。pipewrite(kernel/pipe.c:77)以获取管道的锁开始,这个锁保护计数,数据和与它们相关的不变性。然后piperead(kernel/pipe.c:103)也试着获取锁,但获取不到。它在acquire(kernel/spinlock.c:22)中自旋,等待锁。当piperead等待时,pepiwrite在要写入的数据上循环(addr[0..n-1]),将每个字节按顺序加到管道中(kernel/pepe.c:95)。在这个循环中,可能发生缓冲区填满的情况(kernel/pipe.c:85)。这时,pipewrite调用wakeup来通知任何一个睡眠中的读取者们,缓冲区有数据等待读取。然后在&pi->nwrite睡眠,等待一个读取者将数据从缓冲区取出。sleep释放pi->lock,作为使pipewrite的进程进入睡眠的一部分。
现在pi->lock可以获取,pipread设法获取它,然后进入到它的临界区:它发现pi->nread != pi->nwrite(kernel/pipe.c:110)(pipe因为pi->nwrite == pi->read+PIPESIZE(kernel/pipe.c:85)而进入睡眠),所以它往下走来到for循环中,从pipe中向外拷贝数据(kernel/pipe.c:117),并根据拷贝的字节数增加nread。现在有那么多字节可以写了,所以piperead调用wakeup(kernel/pipe.c:124),在它返回之前唤醒任何一个睡眠中的写入者。wakeup找到一个在&pi->nwrite上睡眠的进程,这个进程之前正在运行pipewrite,但因为缓冲区满而停止。它将这个进程设置为RUNNABLE。
管道代码为读取者和写入者分别使用睡眠频道(pi->nread与pi->nwrite);这可能使系统更高效,在不太可能发生的有很多读取者和写入者同时等待同一个管道的场景中。管道代码在检查睡眠条件的循环中睡眠;如果有多个读取者或写入者,除了第一个唤醒的进程外,所有进程都会看到条件仍为错误并再次睡眠。
睡眠唤醒可以用在很多种等待中。一个有趣的例子是第一章中介绍过的,子进程的exit与父进程的wait之间的交互。在子进程死亡时,父进程可能已经在wait中睡眠,或者可能在做一些别的事;在后一种情况中,之后调用的wait必须能够获知子进程的死亡,可能已经在调用exit之后很久了。xv6会记录子进程的死亡,直到wait来查看,记录方法是让exit将调用进程设置为ZOMBIE状态,一直保持到父进程的wait注意到它,并将它的状态设置为UNUSED,然后拷贝子进程的退出状态,将子进程的进程ID返回给父进程。如果父进程在子进程之前退出,就会把子进程给init进程,init进程会不断地调用wait;因此每个子进程都有一个父进程进行清理工作。这个实现的主要挑战在于父进程与子进程wait与exit之间以及exit与exit之间出现竞争和死锁的可能性。
wait使用调用进程的p->lock作为条件锁来避免唤醒丢失,它在最开始获取到这把锁(kernel/proc.c:398)。然后它扫描进程表。如果找到一个ZOMBIE状态的进程,它就将子进程的资源和proc结构体释放,将子进程的退出状态(如果不是0的话)拷贝到提供给wait的地址,并返回子进程的进程ID。如果wait扫描后发现没有一个子进程退出,它就会调用sleep等待子进程的退出(kernel/proc.c:445),然后再次扫描。在这里,sleep中释放的条件锁是等待进程的p->lock,特殊情况在上面提到过。请注意wait通常持有两把锁;它在尝试获取任何子进程的锁之前会获取它自己的锁;因此为了避免死锁,xv6必须遵守同样的加锁顺序(先父后子)。
wait查看每个进程的np->parent来查找它的子进程。它使用np->parent时并未持有np->lock,这违背了共享变量必须用锁保护的惯用规则。有可能np是当前进程的祖先,这种情况下获取np->lock会导致死锁,因为违反了上面提到的锁获取顺序。不加锁查看np->parent在这种情况下似乎是安全的;进程的parent域只会被它的父进程修改,所以如果np->parent == p是对的,它的值就不会改变,除非当前的进程修改它。
exit(kernel/proc.c:333)记录退出状态,释放一些资源,将任何一个子进程交给init进程,唤醒正在等待的父进程,将调用者标记为僵尸进程,然后永久让出CPU。最终的顺序包含了一点技巧。当退出的进程将自己的状态设置为ZOMBIE然后唤醒父进程时,必须持有父进程的锁,因为父进程的锁是wait中用来避免唤醒丢失的条件锁。子进程也必须持有它自己的p->lock,否则父进程可能在它还在运行时,看到它已经是ZOMBIE状态并释放它。锁获取顺序对避免死锁来说非常重要:因为wait先获取父进程的锁,后获取子进程的,所以exit必须使用相同的顺序。
exit调用了一个特殊的wakeup函数,wakeup1,这个函数只能唤醒父进程,而且只能在父进程在wait(kernel/proc.c:598)中睡眠时唤醒。子进程在将自己的状态设置为ZOMBIE之前唤醒父进程看起来不太对,但这是安全的:尽管wakeup1可能引起父进程运行,但wait中的循环无法在子进程的p->lock被scheduler释放前查看子进程,所以wait只能在exit将子进程的状态被设置为ZOMBIE(kernel/proc.c:386)之后才能查看退出的进程。
exit允许进程终止自己,kill(kernel/proc.c:611)则让进程可以终止别的进程。想让kill直接摧毁目标进程非常难,因为目标进程可能在另一个CPU上执行,也可能在正在执行更新内核数据结构的敏感操作。因此,kill只做了很少的事:它设置目标的p->killed,如果目标正在睡眠,将其唤醒。最终目标进程将进入或离开内核,那时如果p->killed已经设置,usertrap中的代码将调用exit。如果被杀的进程在用户空间运行,它很快就会通过系统调用或者计时器中断的方式进入内核。
如果被杀进程正在运行sleep,kill调用的wakeup会导致被杀进程从sleep中返回。这可能是危险的,因为正在等待的条件可能并未正确。然而,xv6调用sleep通常都放在一个while循环中,在sleep返回后还会循环检查条件。有些sleep调用也会在循环中检查p->killed,染过被设置的话,就停止当前的活动。仅仅当这种停止是正如饿的时候才能这么做。举例来说,如果killed标志位被设置,管道读写代码会返回;最终代码会回到trap中,再次检查标志位并退出。
有些xv6的sleep循环不会检查p->killed,因为代码正在一个应该是原子操作的多步系统调用中运行。virtio驱动(kernel/virtio_disk.c:242)是一个例子:它不检查p->killed,因为硬盘操作可能是一组写操作中的一个,而这组所有的写操作都需要按顺序进行后,文件系统才能是一个正确的状态。当等待硬盘I/O时被杀的进程要等到完成当前系统调用,而且usertrap看到killed标志时才能退出。
demise perpetually virtio
xv6调度器实现了一个简单的调度策略,按顺序执行每个进程。这个策略叫做round robin。真实地操作系统实现更复杂的策略,比方说,允许进程有优先级。就是说,调度器会优先调度优先级更高的进程。这种策略会迅速增加复杂度,因为目标常常会有冲突:比方说,操作系统既想保证公平又想保证高性能。此外复杂的策略会带来意外的交互如优先级反转和convoys。优先级反转可能在低优先级和高优先级进程共享同一个锁时发生,当低优先级进程获取到锁时,高优先级进程无法继续执行。当很多高优先级进程等待一个已经获得共享锁的低优先级进程时,会形成一个长时间的convoy;convoy一旦形成,就会维持很长时间。为了防止这类问题,必须在复杂的调度其中增加额外的机制。
sleep和wakeup是简单有效的同步方法,但还有很多其他方法。所有这些方法的首要挑战都在于避免本章开头看到的“唤醒丢失”问题。最初的Unix内核中的sleep简单地关掉了中断,这在当时已经足够,因为那时Unix运行在一个单CPU系统上。因为xv6运行在多处理器上,所以给sleep增加了一个显式的锁。FreeBSD的msleep使用相同的方法。Plan9的sleep使用回调函数,在睡眠之前持有调度锁时调用;函数作为睡眠条件的最后一分钟检查,来防止唤醒丢失。Linux内核的sleep使用了一个显式的进程队列,叫做等待队列而不是等待频道;队列有它自己的内部锁。
wakeup在完整的进程列表中搜索chan匹配的进程效率较低。更好的解决方式是将sleep和wakeup中的chan用一个数据结构来代替,这个数据结构包含了正在睡眠的进程列表,类似Linux中的等待队列。Plan9的sleep和wakeup调用将这个结构叫作一个rendezvois站或者Rendez。很多线程库将这种结构叫作条件变量;在这种上下文中,sleep与wakeup操作称为wait和signal。这些所有的机制都有着相同的特点:睡眠条件被某种锁保护起来,在睡眠时会自动释放掉。
wakeup的实现唤醒了在特定频道上睡眠的所有进程,有可能遇到很多进程在那个特定频道上等待的情况。操作系统会调度所有这些进程,然后它们会竞争检查睡眠条件。进程的这种行为有时被称为“惊群效应”,最好避免。很多条件变量有两种唤醒原语:signal,唤醒一个进程,broadcast,唤醒所有等待的进程。
信号量通常用于同步。其中的计数一般表示一些东西,比方说管道缓冲区中的字节数或者进程拥有的僵尸子进程数。使用显式的count作为抽象的一部分避免了“唤醒丢失”问题:有一个显式的count来记录已经发生的唤醒的数量。count也避免了伪唤醒和惊群问题。
终止进程并清理它们在xv6中引入了很大的复杂度。在大部分操作系统中这件事甚至更加复杂,因为,举例来说,要杀死的进程可能深入在内核睡眠当中,释放它的栈需要更多仔细的编程。很多操作系统在异常处理中使用显式的机制释放栈,例如longjmp。此外还有其他的事件能唤醒睡眠中的进程,即使等待的事件没有发生。比方说,当Unix进程在睡眠时,另一个进程可能发送一个signel给它。这种情况下,进程会从被打断的系统调用中返回,返回值为-1,在EINTR中设置错误码。应用可以查看这些值然后决定怎么做。xv6不支持信号因此没有这种麻烦。
xv6对kill的支持没有完全满足:需要有睡眠循环来检查p->killed。相关的一个问题是,即使对检查p->killed的sleep循环来说,在sleep与kill之间仍然有竞争;kill可能会在被杀进程检查p->killed之后,调用sleep之前,设置p->killed并尝试唤醒被杀进程。如果这种问题出现,被杀进程就无法发现p->killed,直到它等待的条件发生。这可能会有点晚(例如virtio驱动返回被杀进程所等待的硬盘块时)或者永不发生(例如,如果被杀进程在等待控制台输入,而用户没有输入任何东西时)。
真实的操作系统需要在常数时间内从显式的空闲链表中找到空闲的proc结构体,而不是allocproc中的线性时间查找;xv6为了简单使用了线性查找。
unwind sufficed thundering herd