1.CH-7文档学习笔记

第七章 调度

进程数 > CPU核数,需要在进程之间共享cpu,理想情况下,这种共享对用户进程透明。

一种常见的方法是,将进程多路复用到CPU上,使每个进程都有独享CPU的错觉。

7.1 多路复用

Xv6会在两种情况下,切换正在运行的进程。

  1. 进程等待设备管道I/O完成、等待子进程退出,或在sleep系统调用中等待时,xv6会使用睡眠(sleep)和唤醒(wakeup)机制进行切换。

  2. xv6周期性地强制切换以处理长时间计算而不睡眠的进程。

多路复用的挑战:

  1. 如何切换进程?尽管上下文切换的思想很简单,但这块是xv6中最不透明的代码之一。

  2. 如何以对用户进程透明的方式进行强制切换?Xv6使用标准技术,通过定时器中断驱动上下文切换。

  3. 所有cpu共享同一进程集,在其中挑选进程进行切换,因此需要一个锁避免出错。

  4. 进程退出时,必须释放内存及其他资源,但它不能自己完成所有这些工作,因为(例如)它不能在使用自己内核栈(资源)的情况下释放自己的资源。

  5. 多核机器的每个核心必须记住它正在执行哪个进程,以便系统调用正确影响对应进程的内核状态。

  6. sleep允许一个进程放弃CPU,唤醒另一个进程。

需要注意,为避免竞争导致唤醒通知的丢失。Xv6试图尽可能简单地解决这些问题,但最终代码仍然很复杂。

7.2 上下文切换

image-20221026141040766

上图描述了进程切换的步骤:

  • 用户进程(shell),通过系统调用或中断
    -->旧进程的内核线程
    -->上下文切换到当前CPU的调度器线程(scheduler thread)
    -->上下文切换到新进程的内核线程`(kernel thread)
    -->从trap返回到cat用户进程`(cat)
    

每个CPU的调度器都是一个专用线程,完成保存寄存器和栈的工作,因为调度器在旧进程的内核栈上执行是不安全的:其他CPU可能会唤醒该进程并运行它,而进程同时运行在两个CPU上将是一场灾难。

线程切换时,需要先保存旧线程的寄存器,然后恢复新线程之前保存的寄存器状态;栈指针和程序计数器的保存和恢复就意味着发生了线程的切换。

swtch()

  • 作用:切换内核线程,保存和恢复上下文。

  • 功能:保存和恢复32个RISC-V寄存器,即上下文(contexts)

  • 流程:当某个进程要放弃CPU时,该进程的内核线程调用swtch保存自己的上下文并切换到调度线程的上下文。线程的上下文都保存在struct context(kernel/proc.h:2)中,这个结构体包含在struct cpu中。swtch接受两个参数:struct context *oldstruct context *new

  • 案例:在发生时钟中断时,usertrap将调用yield(trap.c:81)。依次地:yield-->sched-->swtchswtch将当前上下文保存在p->context中,并切换到先前保存在cpu->context(kernel/proc.c:517)中的调度线程上下文(scheduler context)

    swtch(kernel/swtch.S:3)只保存callee-saved registerscaller-saved registers会自动保存在栈中。swtch不保存pc寄存器。只保存ra寄存器,该寄存器存有调用swtch的下一条指令的地址。接下来,swtch恢复寄存器到新进程的上下文。然后返回到ra指定的指令,即新线程之前调用swtch时的下一条指令。此外,它还会切换到新进程的堆栈,因为被恢复的sp指向那里。

7.3 代码:scheduler()

本节介绍从一个进程的内核线程通过调度器切换到另一个进程的过程。

从内核切换到scheduler线程

  1. 先获得自己的进程锁p->lock,并释放自己持有的任何其他锁,更新自己的状态(p->state),然后调用sched。可以在yield (kernel/proc:505)sleepexit中看到这些操作。
  2. sched()会对这些条件进行二次检查(kernel/proc.c:489-496),并检查这些条件的隐含条件:因为进程锁被持有,中断应该被禁用。否则在持有锁时发生时钟中断导致进程切换将发生死锁。
  3. sched调用swtch将当前上下文保存在p->context中,并切换到cpu->scheduler中保存的调度器上下文。
  4. swtch返回到scheduler函数栈 (kernel/proc.c:466),就像是scheduler函数中的swtch()调用返回一样。之后scheduler继续for循环,找到要运行的进程并切换。

进程锁p->lock

xv6在调用swtch时持有p->lock,并且锁的控制权交给被切换的代码。这是不寻常的,通常锁由申请者进行释放。对于上下文切换,有必要打破这个惯例,因为需要p->lock保护进程statecontext字段中的值。如果没有持有p->lock,可能会出现问题:在yield将进程状态设置为RUNNABLE之后、swtch切换到scheduler之前,另一个CPU在scheduler中选出了这个进程并开始运行。结果将是两个CPU同时运行在这个进程栈上,从而导致混乱。

进程切换过程

void scheduler(void) {
  。。。
        c->proc = p;
        swtch(&c->context, &p->context);
        c->proc = 0;
	。。。
}

void sched(void){
	。。。
  intena = mycpu()->intena;
  swtch(&p->context, &mycpu()->context);
  mycpu()->intena = intena;
}

内核线程切换到调度线程就是从sched/swtch(&p->context,);切换到``scheduler/swtch(&c->context,);`这一行

而调度线程切换到内核线程就是相反的过程,这样一对相互协作完成线程切换的被称为协程

还有一种特例,就是当创建新进程时,allocproc会将新进程的ra指向forkret,这样第一次swtch 会跳到forkret()forkret的作用是释放p->lock

scheduler的函数逻辑很简单:死循环,找到可运行的进程,切换到这个进程。

7.4 代码:mycpu和myproc

xv6中,CPU信息都存储在proc.c/struct cpu cpus[NCPU]数组中

riscv中,每一个cpu都有一个编号,存储在mhartid寄存器中,但这个寄存器只允许在机器模式下读出,xv6将cpu编号存储在tp寄存器中,通过cpu编号获取正确结构体。

mycpu

cpuidmycpu的返回值很脆弱,如果发生定时器中断,导致线程切换,当再次唤醒时,以前的返回值将不再正确。为避免这个问题,xv6要求调用者禁用中断,在使用完返回的struct cpu后再重新启用。

myproc

函数myproc(kernel/proc.c:68)返回进程结构体指针。myproc先禁用中断,调用mycpu,取出进程指针(c->proc),然后重启中断。此时即使发生中断,myproc的返回值也可安全使用:因为即便进程移动到另一个CPU,其struct proc指针也不会改变。

7.5 sleep与wakeup

Xv6使用sleepwakeup,允许一个进程在等待事件时休眠,以及在事件发生后被唤醒。

考虑信号量机制,一个简单的信号量机制实现如下(注意,xv6中并没有信号量机制)

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

上述代码存在问题:如果V很少被调用,P将花费大量时间等待在while循环上。

解决方法:P在等待时释放CPU,只有在V增加计数后再恢复。

对代码进行修改:使用chan进行同步,添加一份调用:sleepwakeup

  • Sleep(chan):在chan上睡眠。Sleep将调用进程置于睡眠状态,释放CPU用于其他工作。
  • Wakeup(chan):唤醒所有在该chan上睡眠的进程(如果有),使其从sleep调用返回。如果chan上没有进程,则不执行任何操作。

修改后的代码如下

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

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

依然存在并发问题:

假设P发现s->count==0。当Pwhilesleep之间时,V在另一个CPU上运行:它将s->count更改为非零,并调用wakeup,此时P还没有进入休眠状态,因此不会执行任何操作。之后P继续执行:调用sleep进入睡眠。这会导致一个问题:P正在休眠,等待调用V,而V已经被调用。除非生产者再次呼叫V,否则消费者将永远等待,即使此时count非零。

尝试加锁解决临界区问题:

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

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

仍然存在问题:会导致死锁:P在睡眠时持有锁,因此V将永远阻塞。

进一步修复:

  • 更改sleep,调用方必须将(condition lock)传递给sleep

  • sleep会将进程标记为休眠状态,并在进程进入睡眠Chan后释放锁。

锁将强迫V等待,直到P完成休眠,此时wakeup将找到休眠的消费者并将其唤醒。

最终代码如下:

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);  // !pay attention
    s->count -= 1;
    release(&s->lock);
}

注意:sleep释放s->lock并使P进程进入睡眠状态的操作是原子的。

7.6 代码:sleep和wakeup

  • 两个函数的相关实现在sleep(kernel/proc:541)wakeup(kernel/proc:572)

  • 实现原理:

    • sleep:将当前进程标记为SLEEPING,然后调用sched释放CPU
    • wakeup:查找在给定等待通道上休眠的进程,将其标记为RUNNABLE
  • sleepwakeup的调用者可以使用任意通道。Xv6通常使用等待中涉及的内核数据地址作为通道。

  • 代码详细说明

    sleep首先获取p->lock(kernel/proc.c:559)。此时sleep同时持有p->lock和传入的lk

    lk的作用上一节已经介绍过:确保没有其他进程可以调用wakeup(chan)

    此时已持有p->lock,释放lk是安全的:其他进程可能会调用wakeup(chan),但是将等待获取p->lock,直到sleep成功将进程置于睡眠状态,以防止唤醒丢失。

还有一个小问题:如果lkp->lock是同一个锁,那么如果sleep()试图获取p->lock就会自身死锁。但是,如果调用sleep的进程已经持有p->lock,那么它不需要做更多的事情来避免错过并发的wakeup。当wait(kernel/proc.c:582)持有p->lock调用sleep时,就会出现这种情况。

  • wakeup介绍:

    当某个进程获取到锁,并调用wakeup(chan)wakeup将遍历进程表。尝试获取p->lock,这是因为可能需要修改进程状态,也因为p->lock确保sleepwakeup不会彼此错过。当wakeup发现一个SLEEPING的进程且chan匹配时,它会将进程状态更改为RUNNABLE。调度器下次运行时,就可以启动该进程。

有时,多个进程在同一个通道上睡眠;例如,多个进程读取同一个管道。一个单独的wakeup调用就能把他们全部唤醒。其中一个将首先运行并获取与sleep一同调用的锁,并且(在管道例子中)读取在管道中等待的任何数据。尽管被唤醒,其他进程将发现没有要读取的数据。从他们的角度来看,醒来是“虚假的”,他们必须再次睡眠。因此,在检查条件的循环中总是调用sleep

如果两次使用sleep/wakeup时选择了相同的通道,也不会造成任何伤害:它们将看到虚假的唤醒,但如上所述的循环将容忍此问题。sleep/wakeup的魅力在于它既轻量级(不需要创建特殊的数据结构来充当睡眠通道),又提供了一层抽象(调用者不需要知道他们正在与哪个特定进程进行交互)。

7.7 代码:Pipes

使用sleep和wakeup进行同步的另一个例子是xv6的管道实现。接下来看pipewritepiperead的实现。

管道的数据结构如下

#define PIPESIZE 512
struct pipe {
  struct spinlock lock;
  char data[PIPESIZE];
  uint nread;     // number of bytes read
  uint nwrite;    // number of bytes written
  int readopen;   // read fd is still open
  int writeopen;  // write fd is still open
};

data:数据缓冲区

  • 缓冲区环形使用

  • 缓冲区empty条件:nwrite==nread

  • 缓冲区满条件:nwrite==nread+PIPESIZE

    这个缓冲区使用时,不会对nwrite进行取余,而是不停地累加

  • 写入数据:data[nwrite++%PIPESIZE] = ch;

管道读写同步流程:

假设在不同的CPU上同时调用pipereadpipewrite

  1. pipewrite首先获取到获取管道锁

  2. piperead随后尝试获取锁失败,在acquire中旋转等待。

  3. pipewrite每次1字节将数据写入到pipe中。

  4. 在这个过程中,缓冲区可能会被填满。

    此时pipewrite将调用wakeup提醒所有处于睡眠状态的读进程

    然后在&pi->nwrite上睡眠

    之后sleep会释放pi->lock

  5. piperead成功获取到pi->lock,从管道中每次读出1字节,并增加nread

    之后,piperead调用wakeup唤醒休眠的写进程

  6. wakeup寻找在&pi->nwrite上休眠的进程,该进程标记为RUNNABLE

7.8 代码:wait, exit和kill

第一章有一个有趣的例子是子进程exit和父进程wait之间的交互。

子进程exit时,父进程可能已经在wait中休眠,或在做其他事情

  • 在第二种情况下,之后父进程调用wait,必须能观察到子进程已exit

    解决方案:让exit将调用方置为ZOMBIE状态,直到父进程调用wait,更改子进程状态为UNUSED,复制子进程的exit状态码,并将子进程ID返回给父进程

  • 如果父进程在子进程之前退出,则之后的工作将交给init进程,init不停地调用wait;因此,每个子进程退出后都有一个父进程进行清理。

实现的主要挑战是waitexit同时调用时,可能会发生竞争和死锁。

wait开始通过获取wait_lock(kernel/proc.c:391)来进行。原因是wait_lock充当条件锁,有助于确保父进程不会错过来自退出子进程的唤醒。然后,wait扫描进程表。如果发现一个处于ZOMBIE状态的子进程,它将释放该子进程的资源和其proc结构,将子进程的退出状态复制到wait提供的地址(如果不为0),并返回子进程的进程ID。如果wait发现有子进程但没有一个退出,它调用sleep等待它们中的任何一个退出(kernel/proc.c:433),然后再次扫描。wait通常持有两个锁,wait_lock和某个进程的pp->lock;避免死锁的顺序是先wait_lock,然后是pp->lock。

wait详解(proc.c/391):

  1. 尝试获取wait_lock。使用wait_lock做条件锁,确保父进程不会错过来自子进程的exit唤醒。
  2. 遍历进程表。如果发现某个进程是自己的子进程,且这个进程处于ZOMBIE状态,它将释放该子进程的资源及其proc结构体,将该子进程的退出状态码复制到提供给wait的地址(如果不是0),并返回该子进程的进程ID。
  3. 如果还没有任意一个子进程退出,将调用sleep以等待其中一个exit,然后再次扫描。
  4. wait通常持有两个锁,wait_lock和子进程proc->lock,为避免死锁,先释放proc->lock,再释放wait_lock

exit详解(proc.c:347)

记录退出状态,释放一些资源,调用reparent将其子进程交给init进程,唤醒父进程(如果它在等待中),将调用者标记为僵尸进程,并永久地让出CPU。

在此过程中,exit同时持有wait_lockp->lock。持有wait_lock是因为它是wakeup(p->parent)的条件锁,防止处于等待中的父进程丢失唤醒。exit在此序列中必须持有p->lock,以防止处于等待中的父进程在子进程最终调用swtch之前就看到子进程处于ZOMBIE状态。exit以与wait相同的顺序获取这些锁,以避免死锁。

exit在设置其状态为ZOMBIE之前唤醒父进程可能看起来不正确,但这是安全的:尽管wakeup可能导致父进程运行,但父进程wait中的循环需要先获取p->lock,才能检查子进程,因此只有当exit将子进程状态设置为ZOMBIE,然后释放进程锁之后,wait才能开始处理子进程。

kill详解(kernel/proc.c:586)

kill允许一个进程请求终止另一个进程。kill不会直接销毁此进程,因为既复杂又危险。因此kill只是设置进程状态为p->killed,并且如果它正在睡眠,则唤醒它。

如果如果受害者正在用户空间运行,它会因为之后的某次系统调用或计时器(或其他设备)中断而进入内核。在内核中,usertrap()将检查到线程状态为killed,然后调用exit()

如果受害进程正在sleep,kill将唤醒受害进程。这可能是危险的,因为唤醒条件实际并未满足。然而,xv6对sleep的调用总是包装在while中,在唤醒后重新测试条件。一些对sleep的调用会在循环中测试p->killed,从而终止进程。

一些xv6的sleep循环不检查p->killed,因为代码处于一个应该是原子的多步系统调用中间。virtio驱动程序(kernel/virtio_disk.c:285)就是一个例子:它不检查p->killed,因为磁盘操作可能是一组写操作中的一部分,所有这些写操作都是为了使文件系统处于正确的状态。如果中途退出,文件系统可能会出错。

7.9 Process Locking

进程锁p->lock是xv6中最复杂的锁。对p->lock的简单理解是,在读取或写入以下struct proc字段时必须持有它:p->state,p->chan,p->killed,p->xstate、p->pid

然而,p->lock的大多数用途是保护xv6进程数据结构和算法的高级方面。以下是p->lock所做的所有事情的完整集合:

  • p->state一起,防止为新进程分配proc[]槽时发生竞争。
  • 在进程被创建或销毁时,隐藏进程的视图。
  • 防止父进程的wait收集已将其状态设置为ZOMBIE但尚未让出CPU的进程。
  • 防止另一个核心的调度器在将其状态设置为RUNNABLE之后但在完成swtch之前决定运行一个让步进程。
  • 它确保只有一个核心的调度器决定运行一个RUNNABLE进程。
  • 防止计时器中断在进程处于swtch状态时导致其让步。
  • 与条件锁一起,它有助于防止wakeup忽视正在调用sleep但尚未完成让出CPU的进程。
  • 防止kill的受害进程在kill检查p->pid并设置p->killed之间退出并可能重新分配。
  • 它使kill对p->state的检查和写入成为原子操作。

p->parent字段由全局锁wait_lock保护,而非p->lock。只有进程的父进程才能修改p->parent,尽管该字段既由进程本身读取,也由其他进程搜索其子进程时读取。wait_lock的目的是在wait睡眠等待任何子进程退出时充当条件锁。正在退出的子进程持有wait_lock或p->lock直到它将其状态设置为ZOMBIE、唤醒其父进程并让出CPU。wait_lock还序列化父进程和子进程的并发退出,以便init进程(继承子进程)被保证从其等待中被唤醒。wait_lock是一个全局锁,而不是每个父进程中的每个进程锁,因为在进程获取它之前,它无法知道它的父进程是谁。

7.10 真实世界

xv6调度器实现了一个简单的调度策略:它依次运行每个进程。这一策略被称为轮询调度(round robin)。真实的操作系统实施更复杂的策略,例如,允许进程具有优先级。其思想是调度器将优先选择可运行的高优先级进程,而不是可运行的低优先级进程。这些策略可能变得很复杂,因为常常存在相互竞争的目标:例如,操作系统可能希望保证公平性和高吞吐量。此外,复杂的策略可能会导致意外的交互,例如优先级反转(priority inversion)和航队(convoys)。当低优先级进程和高优先级进程共享一个锁时,可能会发生优先级反转,当低优先级进程持有该锁时,可能会阻止高优先级进程前进。当许多高优先级进程正在等待一个获得共享锁的低优先级进程时,可能会形成一个长的等待进程航队;一旦航队形成,它可以持续很长时间。为了避免此类问题,在复杂的调度器中需要额外的机制。

睡眠和唤醒是一种简单有效的同步方法,但还有很多其他方法。所有这些问题中的第一个挑战是避免我们在本章开头看到的“丢失唤醒”问题。原始Unix内核的sleep只是禁用了中断,这就足够了,因为Unix运行在单CPU系统上。因为xv6在多处理器上运行,所以它为sleep添加了一个显式锁。FreeBSD的msleep采用了同样的方法。Plan 9的sleep使用一个回调函数,该函数在马上睡眠时获取调度锁,并在运行中持有;该函数用于在最后时刻检查睡眠条件,以避免丢失唤醒。Linux内核的sleep使用一个显式的进程队列,称为等待队列,而不是等待通道;队列有自己内部的锁。

wakeup中扫描整个进程列表以查找具有匹配chan的进程效率低下。一个更好的解决方案是用一个数据结构替换sleepwakeup中的chan,该数据结构包含在该结构上休眠的进程列表,例如Linux的等待队列。Plan 9的sleepwakeup将该结构称为集结点(rendezvous point)或Rendez。许多线程库引用与条件变量相同的结构;在这种情况下,sleepwakeup操作称为waitsignal。所有这些机制都有一个共同的特点:睡眠条件受到某种在睡眠过程中原子级释放的锁的保护。

wakeup的实现会唤醒在特定通道上等待的所有进程,可能有许多进程在等待该特定通道。操作系统将安排所有这些进程,它们将竞相检查睡眠条件。进程的这种行为有时被称为惊群效应(thundering herd),最好避免。大多数条件变量都有两个用于唤醒的原语:signal用于唤醒一个进程;broadcast用于唤醒所有等待进程。

信号量(Semaphores)通常用于同步。计数count通常对应于管道缓冲区中可用的字节数或进程具有的僵尸子进程数。使用显式计数作为抽象的一部分可以避免“丢失唤醒”问题:使用显式计数记录已经发生wakeup的次数。计数还避免了虚假唤醒和惊群效应问题。

终止进程并清理它们在xv6中引入了很多复杂性。在大多数操作系统中甚至更复杂,因为,例如,受害者进程可能在内核深处休眠,而展开其栈空间需要非常仔细的编程。许多操作系统使用显式异常处理机制(如longjmp)来展开栈。此外,还有其他事件可能导致睡眠进程被唤醒,即使它等待的事件尚未发生。例如,当一个Unix进程处于休眠状态时,另一个进程可能会向它发送一个signal。在这种情况下,进程将从中断的系统调用返回,返回值为-1,错误代码设置为EINTR。应用程序可以检查这些值并决定执行什么操作。Xv6不支持信号,因此不会出现这种复杂性。

Xv6对kill的支持并不完全令人满意:有一些sleep循环可能应该检查p->killed。一个相关的问题是,即使对于检查p->killedsleep循环,sleepkill之间也存在竞争;后者可能会设置p->killed,并试图在受害者的循环检查p->killed之后但在调用sleep之前尝试唤醒受害者。如果出现此问题,受害者将不会注意到p->killed,直到其等待的条件发生。这可能比正常情况要晚一点(例如,当virtio驱动程序返回受害者正在等待的磁盘块时)或永远不会发生(例如,如果受害者正在等待来自控制台的输入,但用户没有键入任何输入)。

一个实际的操作系统将在固定时间内使用空闲列表找到自由的proc结构体,而不是allocproc中的线性时间搜索;xv6使用线性扫描是为了简单起见。

posted @ 2024-04-21 00:15  INnoVation-V2  阅读(2)  评论(0编辑  收藏  举报