XV6学习(13)调度
几乎所有操作系统都会运行数量远多于CPU数量的进程,因此需要对CPU进行分时共享。理想情况下这种共享应该是对用户进程透明的。一个常用的方法是通过多路复用将进程分配到硬件CPU上,使每个进程有其自己的虚拟CPU。
多路复用
XV6在两种情况下会对CPU的进程进程切换从而实现复用:一种是XV6的sleep
和wakeup
机制,当进程在等待设备或管道IO完成、等待子进程结束或在sleep
系统调用中等待时会进行切换;另一种是XV6会周期性地强制长时间进行计算而不睡眠的进程进行切换。这种策略使每个进程有自己的CPU,就像XV6的页表使每个进程有自己是内存一样。
实现多路复用有很多挑战。首先如何从一个进程切换到另一个?尽管上下文切换的想法是很简单的,但是其实现也是XV6中最不透明的代码之一。第二,如何进行强制切换使其对用户进程透明?XV6使用的是定时器驱动的上下文切换的标准技术。第三,许多CPU可能并行地对进程进行切换,那么就需要用锁来避免争用。第四,一个进程的内存和其他资源必须在进程结束时进行释放,但是其不能独立完成所有工作,比如它不能释放自己的内核栈当仍在使用它的时候。第五,多核机器的每个核心必须记住它在执行哪一个进程,使系统调用能够正确影响进程的内核状态。最后,sleep
和wakeup
运行进程放弃CPU并睡眠等待一个事件,允许其他进程将该进程唤醒。需要小心地避免争用,而争用可能会导致唤醒通知的丢失。XV6尝试尽可能简单地去处理这些问题,但是最后的代码仍是很棘手的。
代码:上下文切换
XV6的进程切换主要有以下几步:用户态转换到内核态(系统调用或中断)中的旧进程的内核线程,上下文切换到当前CPU的调度器线程,上下文切换到新进程的内核线程,返回用户进程。
XV6调度器在每个CPU上都有一个专用线程(保存的寄存器和栈),因为在旧进程的内核栈上执行调度器可能是不安全的:其他核心可能会唤醒该进程并运行它,而在两个不同核心上使用同一个栈是会带来灾难的。本节会介绍内核线程和调度器线程之间的切换机制。
线程间的切换涉及到保存旧线程的寄存器,恢复新线程之前保存的寄存器;sp
和pc
寄存器被保存和恢复意味着CPU会切换栈和执行的代码。
swtch
函数完成内核进程切换的寄存器保存和恢复。swtch
不直接知道线程;其只是保存和恢复寄存器集,即上下文。当一个进程放弃CPU,进程的内核线程就会调用swtch
来保存其上下文并返回到调度器的上下文。每个上下文被保存在一个strcut context
结构体中,而其自身是被保存在struct proc
或struct cpu
中的。swtch
需要两个参数,strcut context *old
和strcut context *new
。其保存当前寄存器在old
中,从new
中恢复寄存器,最后返回。
让我们来跟踪一个进程通过swtch
进入调度器。一个中断最后会在usertrap
中调用yield
。yield
接着调用sched
,sched
会调用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
并调用sched
。yield
函数遵循这个约定,sleep
和exit
也是。sched
会再次检查这些条件,这些条件的含义是:当锁被持有,就必须关中断。最后sched
调用swtch
保存当前上下文在p->context
中并切换到调度器上下文。调度器会继续执行for
循环,查找一个进程来运行,并切换到该进程,之后重复循环。
在swtch
调用期间XV6会持有p->lock
:swtch
的调用者必须持有该锁,之后锁的控制权被传递到被切换到的代码。这种锁的约定并不常见,通常获取锁的线程也要负责释放锁,从而更容易保证代码正确性。对于上下文切换,必须要打破这种约定,因为p->lock
保护进程state
和context
域的不变性,而在执行swtch
时这是不正确的。例如如果p->lock
没有在swtch
时被持有,另一个CPU就可能会决定运行这个进程并设置状态为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
。forkret
需要释放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
也保护了其他的东西:exit
和wait
之间的相互作用,避免唤醒丢失的机制,以及避免一个进程退出时其他进程读或写其状态时的冒险(如exit
系统调用查看p->pid
并设置p->killed
。
代码:mycpu
和myproc
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
的话就会方便很多,但是这只在机器模式中被允许而不在监管者模式中。
cpuid
和mycpu
的返回值是脆弱的:如果一个定时器中断,导致线程让出并被移动到另一个不同的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增加了计数器时才恢复。
这是朝这个方向迈出的一步,尽管我们将看到这还不够。让我们想想一对调用sleep
和wakeup
。sleep(chan)
在任意的chan
值上睡眠。sleep
将调用的进程睡眠,释放CPU给其他进程工作。wakeup(chan)
唤醒所有在chan
上睡眠的进程,使它们的sleep
调用返回。如果没有进程在chan
上睡眠,wakeup
就不做任何事。我们可以用sleep
和wakeup
来实现信号量:
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
并将消费者进程睡眠。
代码:sleep
和wakeup
让我们来看sleep
和wakeup
的实现。最基本的想法就是sleep
标记当前进程为SLEEPING
,调用sched
来释放CPU;wakeup
在提供的睡眠通道上的找到一个进程并将其标记为RUNNABLE
。sleep
和wakeup
的调用者可以用任何相互方便的随着来作为通道。XV6通常用涉及等待的内核数据结构的地址。
sleep
获取p->lock
。当进程睡眠时同时持有p->lock
和lk
。持有lk
是在调用者中必要的:这保证没有其他进程可以开始调用wakeup
。当sleep
持有p->lock
时,就可以安全地释放lk
了:其他进程可能会尝试调用wakeup
,但是wakeup
将会等待p->lock
,因此将会等待sleep
将进程睡眠,避免了唤醒丢失。
这里有一个小的问题:如果lk
和p->lock
是一样的,sleep
如果尝试获取p->lock
就会死锁。但是如果进程调用sleep
时已经持有p->lock
了,就不需要多做任何事来避免并行wakeup
的丢失了。这种情况在wait
使用p->lock
调用sleep
时发生。
现在sleep
只持有p->lock
,它可以将进程睡眠并记录睡眠通道,改变进程状态为SLEEPING
,调用sched
。后面会讲到为什么当进程被标记为睡眠后,p->lock
必须由调度器释放。
在有些时候,一个进程会获取条件锁,设置睡眠者正在等待的条件,并调用wakeup
。当wakeup
被调用时,条件锁必须被持有。wakeup
遍历进程表,获取每个进程的p->lock
,这既是因为它会操作进程状态,也因为要保证sleep
和wakeup
不会发生丢失。当wakeup
发现一个SLEEPING
状态的进程并且chan
也是对应的,它就会修改进程状态为RUNNABLE
。下一次调度器运行时,它就会发现该进程已经可以运行。
为什么sleep
和wakeup
的锁规则保证了睡眠进程不会丢失唤醒?正在睡眠的进程从它检查条件之前到它标记SLEEPING
之后要么持有条件锁,要么持有自己的p->lock
或者持有两者。而调用wakeup
的进程在其循环中持有这些锁。因此唤醒者要么在消费者检查条件之前设置条件为真,要么检查者的wakeup
在进程被标记为SLEEPING
之后再检查睡眠线程,那么wakeup
就会看到睡眠的线程并唤醒它。
有些时候多个进程可能在同一个通道上睡眠;例如多于一个进程读取管道。一次单独的wakeup
将会唤醒所有。它们其中的一个将会先运行并获取到锁,并从管道中读取任何正在等待的数据。而其他进程就会发现尽管被唤醒了但却没有任何数据可以读,从它们看来这种唤醒是“虚假的”,因此它们需要再次睡眠。这也就是为什么sleep
要在一个检查条件的循环中被调用。
如果两个睡眠/唤醒意外地选择了相同的通道也是没有问题的:它们会看见虚假唤醒,上面讲的循环能够容忍这种问题。睡眠/唤醒的魅力在于,它既轻巧(不需要创建特殊的数据结构来充当睡眠通道),又提供了一个间接层(调用者无需知道与之交互的具体进程)。
代码:管道
一个更加复杂的使用sleep
和wakeup
进行生产者消费者同步的例子就是XV的管道实现。写入到一个管道尾部的字节被拷贝到内核缓冲区并且可以从管道的另一端读取。下一章会介绍文件描述符对管道的支持,现在我们先看pipewrite
和piperead
的实现。
每个管道表现为一个struct pipe
,其中包含一个锁和一个数据缓冲区。nread
和nwrite
域统计读取和写入缓冲区的字节数。缓冲区是一个环:buf[PIPESIZE-1]
的下一个字节是buf[0]
。但是计数器不会重置。这种约定使得可以区分满缓冲区(nwrite == nread + PIPESIZE
)和空缓冲区(nwrite == nread
),但是这也意味着缓冲区的下标必须是buf[nread % PIPESIZE]
而不是buf[nread]
(nwrite
也是类似的)。
假设piperead
和pipewrite
的调用同时发生在两个不同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
exit
和kill
sleep
和wakeup
可以用在很多种等待中。一个有趣的例子就是子进程的exit
和父进程wait
之间的交互。当子进程死亡,父进程可能已经在wait
中睡眠,也可能正在做其他事情;在后面这种情况下,接下来的wait
调用必须能够观察到子进程的死亡,可能在子进程exit
之后很久。XV6通过将exit
的调用者状态设置为ZOMBIE
来记录子进程的消亡,进程会保持这种状态直到父进程的wait
观察到,然后将进程状态改变为UNUSED
,拷贝子进程的退出状态,返回子进程的pid给父进程。如果父进程在子进程之前退出,子进程的父进程就会给init
进程,该进程会不停调用wait
;因此每个子进程都会有父进程来对其进行清理。实现的最大挑战就是父子进程的wait
和exit
之间的竞争和死锁,exit
和exit
之间也是。
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)。优先级倒置发生在一个低优先级进程和高优先级进程共享一个锁,那么低优先级进程获取锁会阻止高优先级进程运行。当许多高优先级的进程正在等待获得共享锁的低优先级的进程时,可能会形成一长串的等待进程。一旦队列形成,它可以持续很长时间。为了避免这类问题,复杂的调度程序中还需要其他机制。
sleep
和wakeup
是简单而高效的同步方法,但是也有其他的方法。所有这些方法的第一个挑战就是避免唤醒丢失。原始UNIX内核的sleep
简单地关闭中断,这就足够了因为UNIX运行在单CPU系统上。因为XV6运行在多处理器上,其添加了一个显式锁来sleep
。FreeBSD的msleep
使用相似的方法。Plan 9的sleep
使用回调函数在即将进入睡眠时调用。Linux内核的sleep
使用一个称作等待队列的显式进程队列来替代等待通道;队列有其自己内部的锁。
在wakeup
中扫描整个进程列表来查找匹配的chan
是低效的。一个更好的方法是将sleep
和wakeup
中的chan
替换为一个数据结构,在其中保持睡眠进程的列表,就像Linux的等待队列。许多线程库使用相同的数据结构作为条件变量;在这种情况下,sleep
和wakeup
被叫做wait
和signal
。所有这些机制都具有相同的机制:通过在睡眠过程中自动断开某种锁来保护睡眠条件。
wakeup
的实现唤醒通道上所有等待的进程,而可能有很多进程在该通道上等待,操作系统会调度所有的这些进程而它们竞争地去检查睡眠条件。这种方式的过程有时候被叫做雷群(thundering herd),而这最好被避免。大部分条件变量有两种wakeup
:signal
唤醒一个进程,broadcast
唤醒所有等待进程。
信号量通常被用于同步。计数通常和一些东西相对应,如管道缓冲区的可用字节数,进程的僵尸进程数。使用显式计数作为抽象的一部分来避免唤醒丢失问题:这里有显式的发生了的唤醒计数。计数同样避免了虚假唤醒和雷群问题。
XV6中的终止和清理进程引入了很多复杂性。在大部分操作系统中这是更加复杂的,因为例如牺牲进程可能在内核深处休眠,而展开它的栈需要很小心的编程。许多操作系统使用显式异常处理机制来展开栈,例如longjmp
。此外,其他事件也可以使睡眠进程被唤醒,尽管正在等待的事件还没有发生。例如当一个UNIX进程在睡眠,其他进程可能发送一个signal
信号。在这种情况下,进程会从被中断的系统调用返回-1并将错误码设置为EINTR。应用可以检查错误码并决定如何处理。XV6不支持信号因此这种复杂性不会发生。
XV6对kill
的支持并不完全令人满意:睡眠循环可能需要检查p->killed
。一个相关的问题就是,即使对于检查了p->killed
的睡眠循环,sleep
和kill
之间也会有竞争;之后可能会设置p->killed
并尝试唤醒牺牲进程就在牺牲进程的循环检测完p->killed
而在调用sleep
之前。如果这个问题发生了,牺牲进程不会注意到p->killed
直到等待的条件发生。而这可能会有一点迟或者不发生。
一个真实操作系统会以常数时间复杂度在显式空闲链表中查找空闲proc
结构体而不是线性时间复杂度,XV6为了简单而使用线性扫描。