xv6 book risc-v 第七章 调度
任何操作系统都希望运行比计算机所拥有的CPU数量更多的进程,所以,我们需要一个在进程之间时分CPU的计划,理想状态下,这种共享对用户进程透明。给每一个进程提供它拥有自己的虚拟CPU的通用方式是在多个硬件CPU上多路复用进程。这一章解释了xv6如何实现多路复用。
7.1. 多路复用
xv6会在每个CPU上从一个进程切换到另一个进程来多路复用,这会在两种情况下发生。第一,在一个进程等待设备或pipe I/O完成时,或等待子进程退出时,又或是在sleep
系统调用中等待时,xv6的sleep
和wakeup
机制会执行切换;第二,xv6周期性的强制切换,以处理那种长时间计算又不睡眠的进程。这种多路复用创建了每一个进程拥有自己的CPU的假象,就好像xv6使用内存分配器和硬件页表来创建每一个进程拥有自己的内存的假象。
实现多路复用带来了一些挑战。第一,如何从一个进程切换到另一个?尽管上下文切换这个点子非常简单,但是它们却是xv6中最难以理解的代码;第二,如何以一种对用户进程透明的方式强制切换?xv6使用定时器中断这种驱动上下文切换的标准技术;第三,很多CPU可能并发的在进程间进行切换,为了避免竞争,我们必须要有一个锁计划;第四,一个进程内存以及其它资源必须在进程退出时得到释放,但是这不能完全由进程自己完成,因为(举个例子)它不能在使用内核栈的同时释放自己的内核栈;第五,多核机器的每一个核心必须记录它正在执行的进程,以让系统调用对正确的进程内核状态产生影响;最后,sleep
和wakeup
允许一个进程放弃CPU,睡眠等待一个事件,并且允许其它进程唤醒第一个进程,需要注意避免会导致丢失唤醒通知的竞争。xv6尝试尽可能简单的解决这些问题,但是尽管这样,最终的代码还是很棘手。
7.2. 代码:上下文切换
图7.1展示了从一个用户进程切换到另一个用户进程时涉及到的步骤:一个到老进程的内核线程的用户内核态转换(系统调用或中断);一个到当前CPU的调度器线程的上下文切换;一个到新进程内核线程的上下文切换;以及一个到用户级别进程的陷阱返回。xv6的调度器在每一个CPU上有一个专用线程(以及保存的寄存器和栈),因为对于调度器来说,执行在老进程的内核栈上是不安全的:一些其它的内核可能会唤醒进程并运行它,在两个不同的内核上使用相同的栈将会是一场灾难。在这一部分,我们将会介绍从内核线程和调度器线程之间的转换机制。
译者:虽然这里总是提到线程,比如内核线程、调度器线程,如果你看了公开课的话,甚至还有每个进程一个的用户线程,但实际上,xv6的代码中(至少是这一章涉及到的代码)中并没有线程的影子,这可能有点迷惑。实际上,所谓线程,不过就是一个独立的执行单元,它有自己的栈,独立运行在CPU上,并持有CPU状态(那些寄存器)。在xv6中,用户进程有自己的执行栈,它执行在CPU上,并且持有CPU的状态,当由于某些原因进入内核时(系统调用或中断),用户进程的CPU状态会得到保存(保存到进程的trapframe中),并切换到该进程的内核栈执行。到此为止,虽然xv6的代码中没有显式的写出线程的概念,但我们已经可以看作我们有了两个独立的线程,一个是进程的用户线程,一个是进程的内核线程。在内核由于某些原因决定切换进程时(当前进程等待IO或者发生时钟中断,此时一定在老进程的内核或者说内核线程中执行),此时需要将内核线程的CPU状态保存(保存到进程的context中),然后将当前执行栈由进程的内核栈切换到CPU调度程序的栈上。正是因为此,我们会说xv6中有三种线程,每个进程唯一的用户线程、每个进程唯一的内核线程以及每个CPU唯一的调度器线程。
由于很多东西还没解释,如果你没有看过公开课,那么上面所说的内容可能有点难以理解,反正,能理解多少理解多少,当你看到后面感觉自己被各种线程的概念弄得很迷糊,你就可以回来再看看这段话。
从一个线程切换到另一个线程需要涉及到保存老线程的CPU寄存器,以及恢复先前被保存的新线程的寄存器。栈指针以及程序计数器的保存和恢复意味着CPU将切换执行栈以及切换当前正在执行的代码。
对于一个内核线程的切换,swtch
函数执行保存和恢复。swtch
对线程一无所知,它只是保存并恢复寄存器集合,我们称之为上下文(context)。当一个进程已经到了要放弃CPU的时间,进程的内核线程调用swtch
来保存它自己的上下文,并返回到调度器的上下文。每一个上下文都被保存在struct context
(kernel/proc.h:2)中,而在一个进程中,context本身被保存在struct proc
中,或在一个CPU上,它被保存在struct cpu
中。swtch
接收两个参数,struct context *old
以及struct context *new
,它将当前寄存器保存到old
中,并从new
中加载寄存器并返回。
# 上下文切换
#
# void swtch(struct context *old, struct context *new);
#
# 将当前寄存器保存到old中,并从new中加载恢复寄存器
.globl swtch
swtch:
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
sd s2, 32(a0)
sd s3, 40(a0)
sd s4, 48(a0)
sd s5, 56(a0)
sd s6, 64(a0)
sd s7, 72(a0)
sd s8, 80(a0)
sd s9, 88(a0)
sd s10, 96(a0)
sd s11, 104(a0)
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
ld s1, 24(a1)
ld s2, 32(a1)
ld s3, 40(a1)
ld s4, 48(a1)
ld s5, 56(a1)
ld s6, 64(a1)
ld s7, 72(a1)
ld s8, 80(a1)
ld s9, 88(a1)
ld s10, 96(a1)
ld s11, 104(a1)
ret
译者:不同于
trampoline
是通过跳转指令直接跳过来的,swtch
是使用一次常规的函数调用进入的,所以swtch
函数中只保存了callee-saved寄存器,那些caller-saved寄存器会被调用者自己保存到自己的栈上,也会被它们自己恢复,swtch
无需关心这些。
// Saved registers for kernel context switches.
struct context {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
我们跟踪一个进程通过swtch
到达调度器的过程。在第四章,我们看到过在一个中断的末尾,usertrap
有可能调用yield
。yield
随即调用sched
,sched
调用swtch
来向p->context
中保存当前上下文,并切换到之前保存在cpu->scheduler
中的调度器上下文(kernel/proc.c:509)。
void
usertrap(void)
{
int which_dev = 0;
// ...
// 判断是否是设备中断
} else if((which_dev = devintr()) != 0){
// ok
}
// ...
// 如果是一次时钟中断,主动放弃cpu
if(which_dev == 2)
yield();
usertrapret();
}
// 让出CPU
void
yield(void)
{
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE; // 设置当前进程状态为就绪态
sched(); // 调用sched,该函数的目的是从内核线程进入到调度器线程
release(&p->lock);
}
void
sched(void)
{
int intena;
struct proc *p = myproc();
// 一些合法性校验
if(!holding(&p->lock))
panic("sched p->lock");
if(mycpu()->noff != 1)
panic("sched locks");
if(p->state == RUNNING)
panic("sched running");
if(intr_get())
panic("sched interruptible");
// 记录当前中断状态
intena = mycpu()->intena;
// 切换到调度器线程上下文执行
swtch(&p->context, &mycpu()->context);
// 恢复中断状态
mycpu()->intena = intena;
}
swtch
只保存了callee-saved寄存器:caller-saved寄存器会在栈上(如果需要的话)被正在发起调用的C代码保存。swtch
知道载struct context
中每一个寄存器属性的偏移量。它不保存程序计数器,取而代之的是,swtch
保存了ra
寄存器,它持有了swtch
被调用处的返回地址。现在swtch
从新上下文中恢复寄存器,新上下文中持有着被上一次调用时swtch
保存的寄存器值。当swtch
返回,它返回到被恢复的ra
寄存器指向的指令,也就是新线程之前调用swtch
处的指令,并且它返回到一个新的线程栈(通过恢复sp
寄存器)。
在我们的例子中,sched
调用swtch
来切换到cpu->scheduler
——也就是每一个CPU的调度器上下文。这个上下文被scheduler
的swtch
调用保存(kernel/proc.c:475)。当我们追踪的swtch
返回,它并不会返回到sched
,而是返回到scheduler
,它的栈指针指向到当前CPU的调度器栈。
// 每个CPU上的进程调度器
// 在设置完自己后,每一个CPU都调用scheduler()
// scheduler永不反悔,它循环做下面的事:
// - 选择一个要运行的进程
// - swtch以开始执行这个进程
// - 最终这个进程通过swtch将控制转移回调度器
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
intr_on();
int nproc = 0;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state != UNUSED) {
nproc++;
}
if(p->state == RUNNABLE) {
// 切换到选中的进程
// 该进程需要释放上面加的锁,并在跳回这里之前重新加锁
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);
c->proc = 0;
}
release(&p->lock);
}
if(nproc <= 2) {
intr_on();
asm volatile("wfi");
}
}
}
译者:如果没在公开课里跟老师用GDB走一遍整个线程切换的过程,可能很难理解上面的内容,我尝试用语言来解释一下,不涉及到代码。
swtch
在两个上下文间跳转,当内核感觉是时候从当前进程切换到另一个进程执行了(可能由于IO系统调用或定时器中断),它会调用swtch
,将自己的上下文(实际上就是寄存器信息)保存在p->context
中,ra
指向了从另一个进程返回时要返回到的指令位置,实际上就是本次调用swtch
的指令位置,sp
指向了当前进程的(内核)栈。swtch
从新的context
中加载保存的寄存器,也就是CPU调度器的上下文,加载它的ra
,这样当swtch
执行ret
返回时就会返回到CPU调度器上正确的指令位置,加载它的sp
,以在CPU调度器的栈上执行,CPU调度器的上下文会在上一次调度器想要运行某一个线程时被保存在cpu->context
中,同样也是通过swtch
保存的。当调度器想要调度一个进程时,和上面的思路也一样,通过swtch
保存自己的上下文到cpu->context
,最重要的是保存下次切换过来时的ra
,以及sp
,然后加载要调度的进程的上下文。如果这样说,那么无论是进程还是CPU调度器,都必须有最初始的一次
context
初始化,要不然切换的时候换到哪里?
实际上,在allocproc
中(kernel/proc.c:126)处做了进程的context
最初的初始化工作,将ra
设置为forkret
函数的位置,将sp
设置为内核栈,将其它寄存器全都设置成0,所以一个进程最初被调度器调度时会由swtch
返回到forkret
,但在代码中看不到任何实际的调用。对于CPU调度器的初始化,我也不知道在哪......
7.3. 代码:调度
在最后一部分我们将看到swtch
的底层细节,现在,我们假设swtch
已经给定,然后来研究从一个进程的内核线程通过调度器到另一个进程的切换过程。调度器在每一个CPU上以一个特殊线程的形式存在,每一个都运行scheduler
函数。这个函数负责选择接下来运行哪一个进程。一个想要放弃CPU的进程必须获取它自己的进程锁p->lock
,释放任何它持有的其它锁,更新它自己的状态(p->state
),然后调用sched
。yield
(kernel/proc.c:515)函数遵循了这个约定,我们稍后要介绍的sleep
以及exit
也是。sched
重新检测了这些条件(kernel/proc.c:499-504)以及一个在这些条件中隐含的条件:一旦锁被持有了,中断必须被禁用。最后,sched
调用swtch
来在p->context
中保存当前上下文,切换到在cpu->scheduler
中的调度器上下文。swtch
返回到调度器的栈上,就好像scheduler
中的swtch
真的已经返回了一样。调度器继续执行for
循环,找到一个进程来运行,循环往复。
我们刚刚看到了xv6在调用swtch
调用过程中持有了p->lock
:swtch
的调用者必须已经持有锁,然后该锁的控制权转移到了要切换到的代码中。这并不是一个常见的锁的用法。通常,获取锁的线程也有责任来释放锁,这让推理正确性变得简单。对于上下文切换,我们必须打破这个惯例,因为p->lock
保护了在进程的state
以及context
属性上的不变式,当在swtch
中执行时,这些不变式并不为真。如果在swtch
期间没有持有p->lock
,一个可能发生的问题的示例是:另一个CPU可能在yield
已经将它的状态设置成RUNNABLE
之后,但swtch
还没有让它停止使用自己的内核栈之前决定执行该进程,最后的结果就是两个CPU在一个栈上运行,这不正确。
一个内核线程总是在sched
中放弃它的CPU,并且总是切换到scheduler
的相同位置,而scheduler
(几乎)总是切换到之前调用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 exists to release the p->lock; otherwise, the new process could start at usertrapret.
scheduler
(kernel/proc.c:457)运行一个简单的循环:找到一个要运行的进程,运行直到它yield,重复这个过程。调度器循环遍历进程表来找到一个就绪进程,就是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
开始修改运行进程的状态到RUNNABLE
,这个锁必须保持持有,直到所有不变式都被恢复:最早的正确释放点是在scheduler
(运行在它自己的栈上)清除了c->proc
之后。相似的,一旦scheduler
开始将一个RUNNABLE
进程转换到RUNNING
,直到内核线程完全运行前都不能释放锁(在yield
的例子中是在swtch
之后)。
p->lock
也保护了其它东西:exit
和wait
之间的交互,避免丢失唤醒的机制(在第7.5章),以及在一个正在退出的进程以及另一个正在读取或写入它状态的进程之间避免竞争(比如exit
系统调用查看p->pid
并设置p->killed
(kernel/proc.c:611))。为了清晰或者为了性能起见,考虑是否为不同的功能拆分p->lock
是值得的。
7.4. 代码:mycpu和myproc
xv6通常需要一个指向当前进程的proc
结构体的指针。在但处理器中,可能有一个全局的变量指向当前的proc
,但这在多喝机器上不可行,因为每一个核心都执行一个不同的进程。解决这个问题的方式是利用每一个核心都有自己的寄存器集合的事实,我们可以使用这些寄存器中的一个来帮助我们找到每个核心的信息。
xv6为每个CPU(kernel/proc.h:22)维护了一个struct cpu
,它可以记录当前运行在CPU上的进程(如果有),记录为CPU调度线程保存的寄存器,以及嵌套的自旋锁的个数,我们需要使用它管理中断的禁用。mycpu
函数(kernel/proc.c:60)返回一个指向当前CPU的struct cpu
的指针。RISC-V为它的CPU编号,给每一个CPU一个hartid
。xv6确保在内核中时,每一个CPU的hartid存储在了这个CPU的tp
寄存器上。这允许mycpu
使用tp
来索引cpu
结构体的数组以找到正确的那个。
确保一个CPU的tp
总是持有着CPU的hartid有一点点复杂。在CPU的启动序列的早期,mstart
设置tp
寄存器,此时还在machine模式(kernel/start.c:46)。在trampoline页面,usertrapret
保存了tp
,因为用户进程可能修改tp
。最后,当从用户空间进入内核时(kernel/trampoline.S:70)uservec
恢复保存的tp
。编译器保证永远不会使用tp
寄存器。如果RISC-V允许xv6直接读取当前的hartid将会更加方便,但是这只在机器模式下被允许,在supervisor模式下则不行。
cpuid
以及mycpu
的返回值是脆弱的:如果定时器中断导致线程让步,然后移动到另一个不同的CPU,那么之前的返回值将不再是正确的。为了避免这个问题,xv6要求调用者禁用中断,并且只有在用完返回的struct cpu
后才能开启中断。
myproc
函数(kernel/proc.c:68)返回了运行在当前CPU上的进程的struct proc
指针。myproc
禁用了中断,调用mycpu
,从struct cpu
中获取当前的进程指针c->proc
,然后启用中断。myproc
的返回值即使在中断开启的情况下也是安全的:如果一个时钟中断移动调用进程到另一个CPU,struct proc
的指针将会保持一致。
7.5. sleep和wakeup
调度和锁帮助向另一个进程隐藏一个进程的存在,但是迄今为止我们还没有帮助进程主动交互的抽象。有很多已经被发明的技术可以解决这个问题,xv6使用了被称作sleep
和wakeup
的一个,它允许一个进程在一个事件上sleep等待,而另一个进程可以在事件发生时唤醒它。sleep和wakeup通常被称作序列协作(sequence coordination)或条件同步(conditional synchronization)机制。
为了讲解,我们思考一个被称作信号量的机制,它协调生产者和消费者。一个信号量维护一个数字,并提供两个操作。V
操作(生产者调用)增加数字,P
操作(消费者调用)等待直到数字非零,然后减小它并返回。如果只有一个生产者线程和一个消费者线程,并且它们运行在不同的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);
}
上面的实现太昂贵了,如果生产者几乎没有动作,消费者将花费大量时间在while
循环上自旋,以等待一个非零的数字。消费者的CPU可以找到比这种重复拉取s->count
的忙等待更加有用的工作。避免忙等待需要消费者让出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);
release(&s->lock);
}
void
P(struct semaphore *s)
{
// 下面是212行
while(s->count == 0)
sleep(s);
acquire(&s->lock);
s->count -= 1;
release(&s->lock);
}
p
现在用放弃CPU代替自旋,这很好。然而,这也揭示了设计这个sleep
和wakeup
接口,并且让它们不会出现丢失唤醒问题,这并不容易。假设P
在212行发现了s->count == 0
,此时P
在212和213行之间,同时V
运行在另一个CPU上,它将s->count
修改成非零值,并调用wakeup
,他将找不到任何线程正在睡眠,所以什么都不做。现在P
继续执行在第213行上,它调用了sleep
并且开始睡眠,这将导致一个问题,P
在睡眠中等待一个已经发生过的V
调用。除非我们很幸运,生产者再次调用了V
,否则消费者将会永远等待,即使count已经不是0了。
问题的根源是P
仅在s->count==0
时才会睡眠的不变式被在错误的时间运行的V
违反了。一个正确的保护不变式的方法是移动在P
中的获取操作让它原子的检测count并调用sleep
。
void
P(struct semaphore *s)
{
// 下面是312行
acquire(&s->lock);
while(s->count == 0)
sleep(s);
s->count -= 1;
release(&s->lock);
}
你可能希望这个版本的P
能够避免丢失唤醒因为锁阻止了V
在313和314行之间执行。它确实可以,但是这里有了死锁问题。P
在它睡眠时持有锁,所以V
将会永远阻塞等待这个锁。
我们会通过修改sleep
接口来修改之前的方案:调用者必须传入条件锁到sleep
中,以让它可以在调用进程被标记为睡眠中时释放锁,并在睡眠通道上等待。锁将强制一个并发的V
等待直到P
已经让自己进入睡眠状态了,所以wakeup
将能够找到睡眠的消费者并唤醒它。一旦消费者被再次唤醒,sleep
在返回前需要重新加锁。我们新的sleep
和wakeup
模式可以按照如下方式使用:
400 void
401 V(struct semaphore *s)
402 {
403 acquire(&s->lock);
404 s->count += 1;
405 wakeup(s);
406 release(&s->lock);
407 }
408
409 void
410 P(struct semaphore *s)
411 {
412 acquire(&s->lock);
413 while(s->count == 0)
414 sleep(s, &s->lock);
415 s->count -= 1;
416 release(&s->lock);
417 }
P
持有s->lock
的事实阻止了V
尝试在P
的c->count
检查以及对sleep的调用过程中wakeup。注意,我们需要在sleep
中原子执行释放s->lock
和将消费进程睡眠的过程。
7.6. 代码:sleep和wakeup
我们看看sleep
(kernel/proc.c:548)以及wakeup
(kernel/proc.c:582)的实现。基本思路就是sleep
标记当前进程为SLEEPING
,然后调用sched
释放CPU。wakeup
寻找一个正在给定通道上睡眠的进程并且将它标记为RUNNABLE
。sleep
以及wakeup
的调用者可以使用任何数字作为通道,xv6通常使用涉及等待的内核数据结构的地址。
sleep
申请p->lock
(kernel/proc.c:559),现在,待睡眠进程同时持有p->lock
和lk
。在调用者中持有lk
是必须的(比如P
):它保证了没有其它进程(比如一个正在运行的V
)能够开始一个wakeup(chan)
的调用。现在sleep
持有了p->lock
,释放lk
已经是安全的了:一些其它进程可能会开启一个wakeup(chan)
调用,但是wakeup
将会申请p->lock
,因此,它会等待直到sleep
已经将进程睡眠,以防止wakeup
错过sleep
。
// 自动释放锁并在chan上睡眠
// 当被唤醒时重新获取锁
void
sleep(void *chan, struct spinlock *lk)
{
struct proc *p = myproc();
// 为了修改`p->state`,必须申请`p->lock`,然后调用sched
// 一旦我们获取了`p->lock`,我们就可以保证我们不会丢失任何wakeup
// (因为wakeup也锁定p->lock)
// 所以,现在释放`lk`是安全的
if(lk != &p->lock){ //DOC: sleeplock0
acquire(&p->lock); //DOC: sleeplock1
release(lk);
}
// 进入睡眠
p->chan = chan;
p->state = SLEEPING;
sched();
// 清理
p->chan = 0;
// 重新获取之前的锁
if(lk != &p->lock){
release(&p->lock);
acquire(lk);
}
}
// 唤醒所有在chan上睡眠的进程
// 被调用时必须没有`p->lock`
void
wakeup(void *chan)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
// 获取进程锁以确保这里的检测后修改操作
// 以及sleep中的设置操作都是原子的,以维持不变式
acquire(&p->lock);
if(p->state == SLEEPING && p->chan == chan) {
p->state = RUNNABLE;
}
release(&p->lock);
}
}
有一个小问题:如果lk
和p->lock
是同一把锁,那么当sleep
尝试获得p->lock
时将会发生死锁。但是如果调用sleep
的进程已经持有p->lock
,它已经不用为了避免丢失一个并发wakeup
而做任何事了。在wait
使用p->lock
调用sleep
时会发生这种情况。
译者:
sleep
的代码里已经判断了,如果lk == p->lock
,什么都不做,因为这时候根本不会发生丢失wakeup
。
现在,sleep
持有p->lock
锁,并且没有其它锁,已经可以通过记录睡眠通道,修改进程状态为SLEEPING
将进程睡眠了,然后调用sched
(kernel/proc.c:564-567)。马上我们就会知道为什么p->lock
直到进程被标记为SLEEPING
之后才被(被scheduler
)释放是至关重要的。
在某些时候,一个进程将会申请持有条件锁,设置睡眠者正在等待的条件,然后调用wakeup(chan)
。wakeup
在持有条件锁的情况下被调用是很重要的。wakeup
循环遍历进程表(kernel/proc.c:582),它获取它检查的每一个进程的p->lock
,这既是因为它将操作进程的状态,也是因为p->lock
能确保sleep
和wakeup
不会相互错过。当wakeup
找到一个在匹配的chan
上的,具有SLEEPING
状态的进程,它就将这个进程状态设置成RUNNABLE
。下一次调度器运行时,它将会看到进程已经准备好运行了。
为什么sleep
和wakeup
的锁定规则确保一个睡眠中的进程不会丢失一个wakeup?睡眠的进程要么持有条件锁,要么持有它自己的p->lock
,要么在它检查条件之前的某一点到它已经标记进程为SLEEPING
之后的某一点同时持有两个锁。调用wakeup
的进程在wakeup
循环中同时持有两把锁,因此唤醒者要么就在消费者线程检查条件之前将条件设置成true,要么就在睡眠线程被标记为SLEEPING
后严格检查该线程。然后wakeup
将看到睡眠中的进程,并将它唤醒(除非其它什么东西先将它唤醒了)。
有时会发生多个进程在一个通道上睡眠的情况;比如多余一个进程从pipe中读取,一个单独的wakeup
调用会将它们全部唤醒。它们其中的一个将会首先运行并获取调用sleep
时使用的锁,并且(在pipe的例子中)读取在管道中等待的任何数据。其它进程虽然被唤醒了,但它们会发现没有数据可读。从它们的视角来看,唤醒是“错误的”,然后它们必须再次睡眠。因为这个原因,sleep
总是在检查循环条件时被调用。
如果两种用途的sleep/wakeup
偶然的选择了相同的通道,也不会发生什么害处:它们将会看到“错误的”唤醒,但是上面介绍的循环可以解决这个问题。sleep/wakeup的大部分魅力在于,它既是轻量的(无需创建特殊数据结构来扮演睡眠通道),又提供一个间接层(调用者无需知道它们正在与什么特定线程交互)。
7.7. 代码:管道
一个更复杂的使用sleep
和wakeup
的例子来同步生产者和消费者的例子是xv6的管道实现。我们在第一章中看到过管道接口:写入管道的一端的字节将被复制到一个内核缓冲区中,然后它可以被从管道另一端读取。未来的章节中,我们将会研究文件描述符对管道的支持,但是现在,我们来看看pipewrite
和piperead
的实现。
每一个pipe由struct pipe
代表,其中包含一个lock
,一个data
缓冲区。nread
和nwrite
属性记录了从缓冲区读以及写到缓冲区的字节总数。缓冲区是环形的:在buf[PIPESIZE - 1]
后面被写入的字节是buf[0]
,那两个计数器不是环形的。这个规约可以让实现区分满缓冲区(nwrite == nread+PIPESIZE
)以及空缓冲区(nwrite == nread
),但是也意味着必须使用buf[nread % PIPESIZE]
代替buf[nread]
来在缓冲区中索引(对于nwrite
也一样)。
我们假设piperead
和pipewrite
的调用在不同的两个CPU上同时发生,pipewrite
(kernel/pipe.c:77)从获取管道锁开始,它保护了计数器,data
以及与它们相关联的不变式。piperead
(kernel/pipe.c:103)随后也尝试获取lock
,但是它无法获取,它在acquire
(kernel/spinlock.c:22)上自旋等待锁。在piperead
等待时,pipewrite
遍历将被写入的字节(addr[0..n-1]
),将每一个依次加入到管道中(kernel/pipe.c:95)。在循环期间,有可能发生缓冲区被填满(kernel/pipe.c:85),在这种情况下,pipewrite
调用wakeup
来提醒任何睡眠中的读取者现在已经有数据在缓冲区中等待的事实,然后在&pi->nwrite
上等待读取者拿走了缓冲区上的一些字节。sleep
释放pi->lock
,这也是将pipewrite
进程睡眠的一部分。
现在pi->lock
已经可用,piperead
将会获取它,进入临界区:它发现pi->nread != pi->nwrite
(kernel/pipe.c:110),所以它穿过for
循环,从管道中将数据拷贝出,增加nread
。现在,已经有一些字节可以用于写入了,所以piperead
在它返回之前调用wakeup
(kernel/pipe.c:124)来唤醒任何睡眠的写入者。wakeup
将找到一个在&pi->nwrite
上睡眠的进程,这个进程曾运行pipewrite
,但因为缓冲区满了而睡眠,它将这个进程标记为RUNNABLE
。
int
pipewrite(struct pipe *pi, uint64 addr, int n)
{
int i;
char ch;
struct proc *pr = myproc();
acquire(&pi->lock); // 获取管道锁,避免管道中数据错乱
// 遍历每一个要写入buffer的字节
for(i = 0; i < n; i++){
// 在缓冲区满了这个条件上循环
while(pi->nwrite == pi->nread + PIPESIZE){ //DOC: pipewrite-full
// 如果进程已经关闭或者管道读端已经关闭,释放管道锁,写入失败
if(pi->readopen == 0 || pr->killed){
release(&pi->lock);
return -1;
}
// 唤醒在nread上睡眠的进程,让它们从管道中取数据
wakeup(&pi->nread);
// 在nwrite上睡眠
sleep(&pi->nwrite, &pi->lock);
}
// 实际复制数据到data缓冲区
if(copyin(pr->pagetable, &ch, addr + i, 1) == -1)
break;
pi->data[pi->nwrite++ % PIPESIZE] = ch;
}
// 写入完成,唤醒在nread上睡眠的进程,释放管道锁
wakeup(&pi->nread);
release(&pi->lock);
return i;
}
int
piperead(struct pipe *pi, uint64 addr, int n)
{
int i;
struct proc *pr = myproc();
char ch;
acquire(&pi->lock); // 获取管道锁以避免读写混乱
// 在管道为空(并且写入端是开启的)这个条件上循环
while(pi->nread == pi->nwrite && pi->writeopen){ //DOC: pipe-empty
// 如果进程已经被杀死,释放管道锁,读取宣告失败
if(pr->killed){
release(&pi->lock);
return -1;
}
// 如果管道为空且写入端开启(可能有人会写入数据),在nread上睡眠
sleep(&pi->nread, &pi->lock); //DOC: piperead-sleep
}
// 实际读取
for(i = 0; i < n; i++){ //DOC: piperead-copy
if(pi->nread == pi->nwrite)
break;
ch = pi->data[pi->nread++ % PIPESIZE];
if(copyout(pr->pagetable, addr + i, &ch, 1) == -1)
break;
}
// 因为已经读了数据,所以管道中可能已经有一些空间了,唤醒在nwrite上睡眠的写进程
wakeup(&pi->nwrite); //DOC: piperead-wakeup
// 释放管道锁
release(&pi->lock);
return i;
}
管道代码为读者和写者使用单独的睡眠通道(pi->nwrite
以及pi->nread
);这在很多读者和写者在同一个管道上等待时可能会更高效。管道代码在一个会检查睡眠条件的循环中睡眠,如果有多个读者或写者,除了第一个进程外,所有被唤醒的进程都看到条件仍然是假的,然后再次睡眠。
7.8. 代码:wait、exit以及kill
sleep
和wakeup
可以被用在很多类型的等待上,一个曾在第一章中引入过的有趣的例子是在子进程的exit
和它父进程的wait
之间的交互。在子进程死亡时,父进程可能已经在wait
中等待了,或者在做一些其它什么事,在后一种情况下,后续的wait
调用必须观察到子进程的死亡,或许是在它调用exit
很久之后。xv6记录子进程的死亡的方式是让exit
将调用者放入ZOMBIE
状态,直到wait
观察到它,将子进程的状态改为UNUSED
,复制子进程的退出状态并将子进程的id返回给父进程。如果父进程在子进程之前退出,父进程将子进程给到init
进程,它会持续调用wait
,因此每一个子进程都有一个父进程负责清理。主要的实现挑战是在父子进程的wait
和exit
之间的竞争和死锁的可能性,在exit
和exit
之间也一样。
wait
使用调用进程的p->lock
作为条件锁来避免丢失唤醒,它在开始处获取锁(kernel/proc.c:398)。然后,它扫描进程表,如果找到一个在ZOMBIE
状态的孩子,他就会释放孩子的资源以及它的proc
结构,复制子进程的退出状态到提供给wait
的地址上(如果它不是0),并返回子进程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
是真,那么除非当前进程修改它之外,它的值不会改变。
// 等待一个子进程退出并返回它的pid
// 当进程没有子进程时返回-1
int
wait(uint64 addr)
{
struct proc *np;
int havekids, pid;
struct proc *p = myproc();
// 在整个过程中持有p->lock以避免丢失wakeup
acquire(&p->lock);
for(;;){
// 扫描进程表查找已退出子进程
havekids = 0;
for(np = proc; np < &proc[NPROC]; np++){
// 这个代码在不持有np->lock的情况下使用np->parent
// 先获取锁可能会造成死锁,因为np可能是p的祖先,并且我们已经持有了p->lock
if(np->parent == p){
// np->parent不可能在检查和acquire之间修改
// 因为只有父进程可以修改它,我们就是父进程
acquire(&np->lock);
havekids = 1;
// 找到一个ZOMBIE子进程
if(np->state == ZOMBIE){
pid = np->pid;
if(addr != 0 && copyout(p->pagetable, addr, (char *)&np->xstate,
sizeof(np->xstate)) < 0) {
release(&np->lock);
release(&p->lock);
return -1;
}
// 释放子进程
freeproc(np);
// 释放子进程锁
release(&np->lock);
// 释放父进程锁
release(&p->lock);
return pid;
}
release(&np->lock);
}
}
// 如果没有子进程或者当前进程已经死了,没必要等待,直接返回-1
if(!havekids || p->killed){
release(&p->lock);
return -1;
}
// 等待一个子进程退出
sleep(p, &p->lock); //DOC: wait-sleep
}
}
exit
(kernel/proc.c:333)记录退出状态,释放一些资源,将所有子进程给到init
进程,唤醒在wait
中的父进程,标记调用者为一个zombie,最终让出CPU。最后的代码有点复杂。推出的进程必须在它设置它的状态为ZOMBIE
并且唤醒父进程时持有父进程的锁,因为父进程的锁是防止在wait
中丢失唤醒的条件锁。子进程必须同时持有它自己的p->lock
,因为不这样的话,父进程可能会看到它在ZOMBIE
状态,并且在它仍在运行时释放它。锁申请顺序对于避免死锁非常重要:因为wait
在获取子进程锁之前获取父进程锁,exit
必须也使用相同的顺序。
exit
调用一个特殊的wakeup函数,wakeup1
,它仅唤醒它的父进程,并且只在它在wait
中睡眠时唤醒(kernel/proc.c:598)。对于子进程来说,在设置它的状态为ZOMBIE
之前唤醒父进程可能看起来不太正确,但是这是安全的:尽管wakeup1
可能会导致父进程运行,在wait
中的循环也无法检查子进程,直到子进程的p->lock
被scheduler
释放,所以,在exit
已经将进程状态设置成ZOMBIE
之前,wait
不能查看推出的进程。
While exit allows a process to terminate itself, kill (kernel/proc.c:611) lets one process request that another terminate. It would be too complex for kill to directly destroy the victim process, since the victim might be executing on another CPU, perhaps in the middle of a sensitive sequence of updates to kernel data structures. Thus kill does very little: it just sets the victim’s p->killed and, if it is sleeping, wakes it up. Eventually the victim will enter or leave the kernel,
at which point code in usertrap will call exit if p->killed is set. If the victim is running in user space, it will soon enter the kernel by making a system call or because the timer (or some other device) interrupts.
If the victim process is in sleep, kill’s call to wakeup will cause the victim to return from sleep. This is potentially dangerous because the condition being waiting for may not be true. However, xv6 calls to sleep are always wrapped in a while loop that re-tests the condition after sleep returns. Some calls to sleep also test p->killed in the loop, and abandon the current activity if it is set. This is only done when such abandonment would be correct. For example, the pipe read and write code returns if the killed flag is set; eventually the code will return back to trap, which will again check the flag and exit.
Some xv6 sleep loops do not check p->killed because the code is in the middle of a multi-step system call that should be atomic. The virtio driver (kernel/virtio_disk.c:242) is an example: it does not check p->killed because a disk operation may be one of a set of writes that are all needed in order for the file system to be left in a correct state. A process that is killed while waiting for disk I/O won’t exit until it completes the current system call and usertrap sees the killed flag.
7.9. 真实世界
xv6的调度器实现了一个简单的调度策略,依次运行每个进程。这种策略被称作轮询(round robin)。真实的操作系统会实现更加精妙的策略,比如允许进程拥有优先级。一个可运行的高优先级的进程会比一个可运行的低优先级的进程更受调度器偏爱。这些策略可能很快就会变得复杂,因为经常会有竞争目标:比如,一个操作系统可能会想要保证公平性和吞吐量。另外,复杂的策略可能导致意想不到的交互,比如优先权倒置和护航效果(convoy effect)。优先权倒置会发生在当一个低优先级和高优先级的进程共享锁的时候,当锁被低优先级进程获取,浙江能够阻止高优先级进程取得进度。等待进程的长车队(long convoy)可能在很多高优先级进程等待一个低优先级进程以获取共享锁的时候构成,一旦一个车队构成了,它可能会保持很长一段时间。为了避免这些问题,在精妙的调度器中一些额外的机制是必要的。
sleep
和wakeup
是简单高效的同步方法,但是也有很多其它的。它们中的第一个挑战就是我们在本章开始时见到的丢失唤醒问题。原始Unix内核的sleep
简单的关闭了中断,因为Unix运行在一个单CPU系统上。因为xv6运行在多处理器上,它在sleep
上添加了一个显式的锁。FreeBSD的msleep
选择了同样的方式。Plan 9的sleep
使用一个回调函数,该函数在进入睡眠之前持有调度锁