Loading

xv6 book risc-v 第七章 调度

任何操作系统都希望运行比计算机所拥有的CPU数量更多的进程,所以,我们需要一个在进程之间时分CPU的计划,理想状态下,这种共享对用户进程透明。给每一个进程提供它拥有自己的虚拟CPU的通用方式是在多个硬件CPU上多路复用进程。这一章解释了xv6如何实现多路复用。

7.1. 多路复用

xv6会在每个CPU上从一个进程切换到另一个进程来多路复用,这会在两种情况下发生。第一,在一个进程等待设备或pipe I/O完成时,或等待子进程退出时,又或是在sleep系统调用中等待时,xv6的sleepwakeup机制会执行切换;第二,xv6周期性的强制切换,以处理那种长时间计算又不睡眠的进程。这种多路复用创建了每一个进程拥有自己的CPU的假象,就好像xv6使用内存分配器和硬件页表来创建每一个进程拥有自己的内存的假象。

实现多路复用带来了一些挑战。第一,如何从一个进程切换到另一个?尽管上下文切换这个点子非常简单,但是它们却是xv6中最难以理解的代码;第二,如何以一种对用户进程透明的方式强制切换?xv6使用定时器中断这种驱动上下文切换的标准技术;第三,很多CPU可能并发的在进程间进行切换,为了避免竞争,我们必须要有一个锁计划;第四,一个进程内存以及其它资源必须在进程退出时得到释放,但是这不能完全由进程自己完成,因为(举个例子)它不能在使用内核栈的同时释放自己的内核栈;第五,多核机器的每一个核心必须记录它正在执行的进程,以让系统调用对正确的进程内核状态产生影响;最后,sleepwakeup允许一个进程放弃CPU,睡眠等待一个事件,并且允许其它进程唤醒第一个进程,需要注意避免会导致丢失唤醒通知的竞争。xv6尝试尽可能简单的解决这些问题,但是尽管这样,最终的代码还是很棘手。

7.2. 代码:上下文切换

img

图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有可能调用yieldyield随即调用schedsched调用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的调度器上下文。这个上下文被schedulerswtch调用保存(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),然后调用schedyield(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->lockswtch的调用者必须已经持有锁,然后该锁的控制权转移到了要切换到的代码中。这并不是一个常见的锁的用法。通常,获取锁的线程也有责任来释放锁,这让推理正确性变得简单。对于上下文切换,我们必须打破这个惯例,因为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),等等。以这种风格在两个线程之间发生切换有时被称为协程:在这个例子中,schedscheduler是对方的协程。

有一种情况下,调度器调用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也保护了其它东西:exitwait之间的交互,避免丢失唤醒的机制(在第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使用了被称作sleepwakeup的一个,它允许一个进程在一个事件上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增加了数字时才恢复。

下面是朝这个方向迈出的一步,但我们会看到这仍然是不够的。让我们想象一对sleepwakeup调用,它们像下面一样工作。sleep(chan)在任意值chan上睡眠,我们称这个值为等待通道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)
{
    // 下面是212行
    while(s->count == 0)
    sleep(s);
    acquire(&s->lock);
    s->count -= 1;
    release(&s->lock);
}

p现在用放弃CPU代替自旋,这很好。然而,这也揭示了设计这个sleepwakeup接口,并且让它们不会出现丢失唤醒问题,这并不容易。假设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在返回前需要重新加锁。我们新的sleepwakeup模式可以按照如下方式使用:

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尝试在Pc->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寻找一个正在给定通道上睡眠的进程并且将它标记为RUNNABLEsleep以及wakeup的调用者可以使用任何数字作为通道,xv6通常使用涉及等待的内核数据结构的地址。

sleep申请p->lock(kernel/proc.c:559),现在,待睡眠进程同时持有p->locklk。在调用者中持有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);
  }
}

有一个小问题:如果lkp->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能确保sleepwakeup不会相互错过。当wakeup找到一个在匹配的chan上的,具有SLEEPING状态的进程,它就将这个进程状态设置成RUNNABLE。下一次调度器运行时,它将会看到进程已经准备好运行了。

为什么sleepwakeup的锁定规则确保一个睡眠中的进程不会丢失一个wakeup?睡眠的进程要么持有条件锁,要么持有它自己的p->lock,要么在它检查条件之前的某一点到它已经标记进程为SLEEPING之后的某一点同时持有两个锁。调用wakeup的进程在wakeup循环中同时持有两把锁,因此唤醒者要么就在消费者线程检查条件之前将条件设置成true,要么就在睡眠线程被标记为SLEEPING后严格检查该线程。然后wakeup将看到睡眠中的进程,并将它唤醒(除非其它什么东西先将它唤醒了)。

有时会发生多个进程在一个通道上睡眠的情况;比如多余一个进程从pipe中读取,一个单独的wakeup调用会将它们全部唤醒。它们其中的一个将会首先运行并获取调用sleep时使用的锁,并且(在pipe的例子中)读取在管道中等待的任何数据。其它进程虽然被唤醒了,但它们会发现没有数据可读。从它们的视角来看,唤醒是“错误的”,然后它们必须再次睡眠。因为这个原因,sleep总是在检查循环条件时被调用。

如果两种用途的sleep/wakeup偶然的选择了相同的通道,也不会发生什么害处:它们将会看到“错误的”唤醒,但是上面介绍的循环可以解决这个问题。sleep/wakeup的大部分魅力在于,它既是轻量的(无需创建特殊数据结构来扮演睡眠通道),又提供一个间接层(调用者无需知道它们正在与什么特定线程交互)。

7.7. 代码:管道

一个更复杂的使用sleepwakeup的例子来同步生产者和消费者的例子是xv6的管道实现。我们在第一章中看到过管道接口:写入管道的一端的字节将被复制到一个内核缓冲区中,然后它可以被从管道另一端读取。未来的章节中,我们将会研究文件描述符对管道的支持,但是现在,我们来看看pipewritepiperead的实现。

每一个pipe由struct pipe代表,其中包含一个lock,一个data缓冲区。nreadnwrite属性记录了从缓冲区读以及写到缓冲区的字节总数。缓冲区是环形的:在buf[PIPESIZE - 1]后面被写入的字节是buf[0],那两个计数器不是环形的。这个规约可以让实现区分满缓冲区(nwrite == nread+PIPESIZE)以及空缓冲区(nwrite == nread),但是也意味着必须使用buf[nread % PIPESIZE]代替buf[nread]来在缓冲区中索引(对于nwrite也一样)。

我们假设pipereadpipewrite的调用在不同的两个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

sleepwakeup可以被用在很多类型的等待上,一个曾在第一章中引入过的有趣的例子是在子进程的exit和它父进程的wait之间的交互。在子进程死亡时,父进程可能已经在wait中等待了,或者在做一些其它什么事,在后一种情况下,后续的wait调用必须观察到子进程的死亡,或许是在它调用exit很久之后。xv6记录子进程的死亡的方式是让exit将调用者放入ZOMBIE状态,直到wait观察到它,将子进程的状态改为UNUSED,复制子进程的退出状态并将子进程的id返回给父进程。如果父进程在子进程之前退出,父进程将子进程给到init进程,它会持续调用wait,因此每一个子进程都有一个父进程负责清理。主要的实现挑战是在父子进程的waitexit之间的竞争和死锁的可能性,在exitexit之间也一样。

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->lockscheduler释放,所以,在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)可能在很多高优先级进程等待一个低优先级进程以获取共享锁的时候构成,一旦一个车队构成了,它可能会保持很长一段时间。为了避免这些问题,在精妙的调度器中一些额外的机制是必要的。

sleepwakeup是简单高效的同步方法,但是也有很多其它的。它们中的第一个挑战就是我们在本章开始时见到的丢失唤醒问题。原始Unix内核的sleep简单的关闭了中断,因为Unix运行在一个单CPU系统上。因为xv6运行在多处理器上,它在sleep上添加了一个显式的锁。FreeBSD的msleep选择了同样的方式。Plan 9的sleep使用一个回调函数,该函数在进入睡眠之前持有调度锁

posted @ 2023-03-30 10:15  yudoge  阅读(624)  评论(0编辑  收藏  举报