「研读笔记」MIT 6.S081 Chapter6 Scheduling

I. Chapter6 - Scheduling

在本章节开始之前,先要清楚一个现代 OS 的基本常识:OS 运行的进程数是远比 CPU 个数多的,书中原话,

Any operating system is likely to run with more processes than the computer has CPUs, so a plan is needed to time-share the CPUs among the processes.

想要这些进程同时运行,最常见的办法,就是时间片轮转策略。即,给每个进程都分配一个时间片,在此时间片内,该进程拥有 CPU ,可以进行运算工作;待时间片用完后,该进程让出 CPU ,给其他进程使用

所有进程,循环往复,这就是时间片轮转策略的核心,也是 xv6 Scheduling 机制的主要设想

6.1 - Multiplexing

其实,xv6 Scheduling 的复用机制,就是想给用户创造一种假象:每个进程都好像实际独占 CPU 一样。我们清楚,进程有很多个,而 CPU 只有几个。要想造出上述的假象,唯有如开章所言(时间片轮转策略),进行 CPU 复用。书中原话,

This multiplexing creates the illusion that each process has its own CPU, just as xv6 uses the memory allocator and hardware page tables to create the illusion that each process has its own memory.

实现 xv6 Scheduling 复用机制,需要明确几个要点,

  1. CPU 如何从进程 A 切换到进程 B ?
  2. 如何保证切换过程对于用户态是透明的?
  3. xv6 是有多个 CPU 的,如何使这些 CPU 有条不紊地运行和切换进程呢?
  4. 在进程结束时,如何确保进程顺利释放掉原有的内存?
  5. CPU 要记住它正在运行的进程
  6. sleep 和 weakup 是可以让进程主动放弃 CPU 的

针对第三点,我们提出了 lock 机制,在进程切换之际,使用 lock 锁定进程,确保切换过程不会被外界(其他 CPU 的 Scheduling or 设备中断)打断,其中 lock 的细节在之后将详细展开

在此,简单叙述一下 xv6 Scheduling 机制的大致流程,如下图,

CPU 要从进程 shell 切换到进程 cat ,期间要分四步走,只有这样,才能做到行如流水(对用户态透明)。具体如下,

  1. 进程 shell 先通过时钟中断进入 kernel ,切换为 shell 的 kernel 线程
  2. 然后 kernel 线程再执行 yield 让位操作。此时,CPU 的控制权被其特有的 Scheduler 掌控(通过 kernel/proc.c:sched() 中的 kernel/swtch.S 实现)
  3. Scheduler 按照时间片轮转策略,从众多进程中选择一个合适的,并将其扶上位(图中的 swtch to 进程 cat 步骤)
  4. 最后进程 cat 再通过 kernel/trap.c:usertrapret() 从 kernel 返回至用户态

这一套流程,完成了进程切换,也解决了上述的要点 1 & 2

其中,要点 2 提到的 Scheduler ,是每个 CPU 都有的一个特殊线程,专门处理调度问题。毫无疑问,Scheduler 是一个一直在运行的线程(里面其实是个死循环),姑且叫它守护线程

6.2 - Code: Context switching

谈到 Context switching ,也就是国内教科书在进程切换章节常讲到的,上下文切换。其实,没有什么花里胡哨的技巧。要实现 Context switching ,只有一条路:保存旧线程的 CPU 寄存器值,恢复(重载,重新装载)之前已保存的新线程寄存器值。书中原话,

Switching from one thread to another involves saving the old thread’s CPU registers, and restor- ing the previously-saved registers of the new thread

Context switching 过程中所用到的结构体在 kernel/proc.h 中有定义,

// Saved registers for kernel context switches.
struct context {
  uint64 ra;
  uint64 sp;

  // callee-saved
  uint64 s0;
 	...
  uint64 s11;
};

很简洁明了,struct context 保存了 ra 和 sp 及被调用者的通用寄存器

ra 全称 return address ,记录着切换之前,中断的指令地址。也可以理解成,扶上位之后(切换为该进程后),程序的继续执行点。有点像,哪里跌倒了,就从哪里站起来。和这句话是一样的道理

sp 全称 stack pointer ,指向堆栈的首地址。其重要程度,无需多言。程序能够顺利运行的关键,就是有堆栈作为其内存空间。一句话,程序要想运行,离不开堆栈。补充一个重要的 OS 常识,

不能在两个不同的 CPU 上运行同一进程,因为一个进程只有一个堆栈,如果两个 CPU 同时对其操作,将违反竞争原则

这两个寄存器是非常重要的,也是 Context switching 最在乎的。而 struct context 只保存 ra 而忽略 pc 的原因是,在线程切换时,我们更在意的,是从哪里被调用。当切换回该线程时,我们希望继续从调用点执行。简而言之,ra 就是再次执行的点;而 pc 的值是实时更新的,是永远指向下一条指令的,并不满足线程切换问题中的需求,所以无需保存

另外,我自己一开始也有一个疑惑:第四章节的 trap 引入了 struct trapframe ,它也是保存了包含 ra 和 sp 在内的常用寄存器。所以,为什么不接着利用 struct trapframe 进行 Context switching ?而是,选择了重新定义 struct context 呢?如此,不是显得有些多此一举了嘛?

后来,听了 Lec11: Thread Switching rtm 教授所讲,

struct context 其实是可以和 struct trapframe 合并的,但是为了让代码更加清晰明了,我们规定 struct trapframe 只包含进出内核时所需的数据;而 struct context 保存内核线程切换时所需的数据

最后,还有一个 kernel/swtch.S 要讲述清楚。其实 swtch(old, new) 做的事情很单纯,就是把 CPU 当前的寄存器值扒下来,保存到旧线程的 struct context 中。然后再将 Scheduler 选中的新线程寄存器值(之前已保存在其 struct context 中)抄到 CPU 寄存器中

因为要和寄存器直接打交道,所以 xv6 选择汇编来完成,而不是 C 。相比于 C ,汇编更适合操纵寄存器,

# Context switch
#
#   void swtch(struct context *old, struct context *new);
# 
# Save current registers in old. Load from new.	


.globl swtch
swtch:
        sd ra, 0(a0)
        sd sp, 8(a0)
        sd s0, 16(a0)
        ...
        sd s11, 104(a0)

        ld ra, 0(a1)
        ld sp, 8(a1)
        ld s0, 16(a1)
        ...
        ld s11, 104(a1)
        
        ret

6.3 - Code: Scheduling

终于,到了切换线程的最关键的环节。Scheduling 也是最为难懂的部分,特别是在此期间对线程上锁放锁问题

为什么在切换线程时需要对其进行上锁?如果是一个 CPU 对应多个进程,其实也无需上锁,因为不存在会有其他 CPU 打断该切换过程的可能。xv6 是支持多 CPU 的,那就势必要考虑到多 CPU 之间竞争的可能

试想,xv6 中有 2 个 CPU 和 2 个进程。在 xv6 刚开始运行时,根据 kernel/proc.c:scheduler() 的时间轮转策略, CPU1 选中了进程 A ,CPU2 也选中了进程 A

CPU1 通过 Context switching 将进程 A 扶上位,具体体现在 kernel/proc.c:scheduler() 中,

void
scheduler(void)
{
	...
  for(;;){
    // Avoid deadlock by ensuring that devices can interrupt.
    intr_on();
    
    int nproc = 0;
    for(p = proc; p < &proc[NPROC]; p++) {
      acquire(&p->lock);
      ...
      if(p->state == RUNNABLE) {
        // Switch to chosen process.  It is the process's job
        // to release its lock and then reacquire it
        // before jumping back to us.
        p->state = RUNNING;
        c->proc = p;
        swtch(&c->context, &p->context);

        // Process is done running for now.
        // It should have changed its p->state before coming back.
        c->proc = 0;
      }
      release(&p->lock);
    }
    ...
  }
}

CPU1 通过 for 循环找到了进程 A 后,第一件事,就是给进程 A 上锁。上锁的奥秘就在这里!上锁之后,进入切换环节:变更进程 A 的 state 、切换 CPU1 的当前进程和调用 swtch() 。这非常重要,此时的 swtch() ,意味着扶进程 A 上位,CPU1 的 Scheduler 退位(将当前寄存器值存档到其 struct context 中)

做完 swtch() 其实已经完成线程切换了!因为 kernel/swtch.S 中的最后一条指令 ret ,就是明摆着告诉我们:pc 值会变成 ra 值。执行 ret 意味着返回调用它的地方。而调用它的地址存在哪里呢?存在了 ra 中。也就是 ra + ret = pc

进程刚初始化的时候设置的 ra 为 kernel/proc.c:forkret() 的地址,在 kernel/proc.c:allocproc() 中有所体现,

static struct proc*
allocproc(void)
{
	...

found:
  ...

  // Set up new context to start executing at forkret,
  // which returns to user space.
  memset(&p->context, 0, sizeof(p->context));
  p->context.ra = (uint64)forkret;
  p->context.sp = p->kstack + PGSIZE;
	...
}

xv6 会接着 pc 继续往下执行,也就是 kernel/proc.c:forkret() ,其所做的事,就是放锁 + 调用 kernel/trap.c:usertrapret() 返回用户层。至此,完成一整个进程切换流程

下面来解答一个非常重要的问题。为什么要上锁?上锁,就是为了保证后续的 swtch() 能顺利执行,主要是防止其他 CPU 的干扰。试想,如果不对进程 A 上锁,就进行切换流程,那么 CPU2 在此时就可以通过 for 循环扶进程 A 上位了。局面会演变成 CPU1 和 CPU2 同时运行着进程 A ,这是我们不想看到的结果。按理说,应该是同一时刻,只有一个 CPU 运行进程 A ,这样才对头

上锁后,即便 CPU2 通过 for 循环找到了进程 A ,那也不能将其扶上位。因为进程 A 上锁了,控制权在 CPU1 手里,CPU2 只能卡在那,干瞪眼。CPU2 只有在 CPU1 将进程 A 切换走之后(放锁),才能访问进程 A

上述的情况,是进程 A 第一次被调度(大姑娘上轿,头一回),所以被重点照顾了,主要体现在 kernel/proc.c:allocproc() 中设置了进程 A 的 ra 和 sp

在第一次被调度之后,进程 A 获得一个时间片的 CPU1 运行机会。当时间片用完后(时钟中断),就会主动让位。在 kernel/trap.c:usertrap() 有所体现,

void
usertrap(void)
{
  ...
  if(r_scause() == 8){
    ...
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
   ...
  }

  if(p->killed)
    exit(-1);

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}

进程 A 会调用 kernel/proc.c:yield() 。下面讲解另一个重要的话题,yield ,代码如下,

// Give up the CPU for one scheduling round.
void
yield(void)
{
  struct proc *p = myproc();
  acquire(&p->lock);
  p->state = RUNNABLE;
  sched();
  release(&p->lock);
}

做的活很简单,就是改状态 + 让位。调用 kernel/proc.c:sched() ,将 CPU1 的控制权从进程 A 切换到 Scheduler ,其代码也顺带贴一下,

// Switch to scheduler.
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() 之前,又做了几次检查,主要是看看进程 A 能不能切换。在切换过程中,要时时保持上锁的状态,这很重要(前面讲过);然后就是看看运行状态,如果还在运行,那就意味着还不能切换;最后,要确保中断关闭( intr_get() 查看中断使能),主要还是防干扰,确保切换是原子操作,不能被打断

顺带一提,acquire(lock) 中有关闭中断使能的指令,release(lock) 有打开中断使能的指令

在执行到 swtch() 的时候,Context switching ,将进程 A 的 ra 、sp 和通用寄存器扒下来,存档到进程 A 的 struct context 中。随后将之前保存的 CPU1 Scheduler 的 ra 等重载至寄存器中

这种情况下,进程 A 的 ra 不再是 kernel/proc.c:forkret() 了,而是 kernel/proc.c:sched()swtch() 的下一条指令,mycpu()->intena = intena !这个要细品哦,下一次返回的地址

同样,CPU1 Scheduler 的 ra 也不是 kernel/proc.c:scheduler() 了,而是该函数里的 swtch() 的下一条指令,即 c->pro = 0

也可以理解成,kernel/swtch.S 自带修改 ra 的功能,将 ra 设置成 swtch() 的下一条指令

在完成 yield 之后,顺利切换到 CPU1 Scheduler ,接着 c->proc = 0 往下执行,for 循环走了一圈。在下一圈中,它根据时间片轮转策略,选择进程 B ,将其扶上位。然后就卡住了!CPU1 的控制权到了进程 B 手中

进程 B 第一次运行,所以 context 的 ra 指向 kernel/proc.c:forkret() 。但下一次,ra 就变了(之前提到过)

以此,循环往复,xv6 Scheduling 机制具体就是这样

下面,我将通过 demo 走一趟 Scheduling 流程。好,假设此时 xv6 有1个 CPU 和 2 个进程 AB

xv6 在启动时的入口是 kernel/main.c:main() (和一般的 Application 启动流程是相同的)。在此为了方便梳理,贴一下 kernel/main.c:main() 的基本流程代码,

// start() jumps here in supervisor mode on all CPUs.
void
main()
{
  if(cpuid() == 0){
    consoleinit();
#if defined(LAB_PGTBL) || defined(LAB_LOCK)
    statsinit();
#endif
    ...
    printf("xv6 kernel is booting\n");
    ...
#ifdef LAB_NET
    pci_init();
    sockinit();
#endif    
    userinit();      // first user process
    ...
  } else {
    ...
  }

  scheduler();        
}

我们注意到,main 在初始化好一系列设置之后,调用 scheduler() ,意味着 xv6 开始工作,将控制权交给 CPU 的 Scheduler 。以后的运作都是 CPU Scheduler 的事,我们跳到 kernel/proc.c:scheduler() 中,看看 xv6 是如何在 CPU Scheduler 的带领下,从零开始,调度进程的(查看 kernel/proc.c:scheduler() 的代码,之前贴过)

kernel/main.c:main() 调用 kernel/proc.c:scheduler() 时,xv6 并没有运行进程,还处于刚起床的状态。进入到 scheduler() 中,会通过循环找到合适的进程(进程 A ),并将其扶上位(对应 swtch() ),当 kernel/swtch.S 执行完,又因为进程 A 是第一次被调度,所以 pc 指向 kernel/proc.c:forkret()

还记得 kernel/proc.c:allocproc() 中的最后两句嘛?就是指定新进程的 ra 和 sp ,其中 ra 指向了 kernel/proc.c:forkret()

为什么 pc 指向 kernel/proc.c:forkret() ?是因为 kernel/swtch.S 恢复了 ra 的值,并在函数最后通过 ret 指令返回,前面说过 ra + ret = pc ,意思就是 ret 指令一旦执行,那么 pc 的值将会变成 ra 的值

这个问题解答清楚后,我们继续。目光转向 kernel/proc.c:forkret() ,

void
forkret(void)
{
  static int first = 1;

  // Still holding p->lock from scheduler.
  release(&myproc()->lock);
  ...
  usertrapret();
}

其中做的事,就是放锁 + 返回用户层(之前提过)。为什么要放锁呢?因为进程 A 已经彻底撤离了 CPU1 ,此时如果还有 CPU2 的话,那么 CPU2 完全可以合法地调度进程 A(之前已讲过为什么要上锁了)

放锁也很重要,如果光是上了锁,却不放锁,这样会造成死锁。放锁的地点和时机很讲究,在进程 A 第一次被调度后,是在 kernel/proc.c:scheduler() 中对其进行上锁,而却在 kernel/proc.c:forkret() 中对其放锁。这个需要细细体会,因为在执行完 swtch() 之后,pc 将跳到 kernel/proc.c:forkret() 中继续执行,而不是顺着 kernel/proc.c:scheduler() 往下执行

虽然在 scheduler() 中有放锁的操作,但并不是与刚开始的上锁操作相配套。与刚开始的上锁操作相配套的是执行 swtch() 之后跳转到 地方,即,进程第一次运行,地方就是 kernel/proc.c:forkret() ;进程常规 yield ,地方就是 kernel/proc.c:yield()yield() 中也有放锁操作

好,理解跳转问题之后,我们继续。此时进程 A 执行完 kernel/proc.c:forkeret() 之后,返回至用户层。运行一个时间片之后,xv6 发生时间中断,进程 A 主动 yield ,给自己上锁,然后执行让位操作,调用 kernel/proc.c:sched() 将控制权交给 CPU Scheduler 。CPU Scheduler 得到控制权后,接着上次的断点( kernel/proc.c:scheduler() 中的 swtch() )往下继续执行,下一步的操作就是空置 CPU ,放锁进程 A 操作,对应代码就是,

// in kernel/proc.c:scheduler()
for(p = proc; p < &proc[NPROC]; p++) {
  acquire(&p->lock);
  if(p->state != UNUSED) {
    nproc++;
  }
  if(p->state == RUNNABLE) {
    // Switch to chosen process.  It is the process's job
    // to release its lock and then reacquire it
    // before jumping back to us.
    p->state = RUNNING;
    c->proc = p;
    swtch(&c->context, &p->context);

    // Process is done running for now.
    // It should have changed its p->state before coming back.
    c->proc = 0;
  }
  release(&p->lock);
}

然后进行下一轮循环,根据轮转规则选中进程 B ,对其上锁,然后将其扶上位(进程 B 也是第一次被调度,所以不再赘述)

当进程 B 运行一个时间片之后,xv6 发生时间中断,进程 B 又主动 yield ,给自己上锁 。控制权交给 CPU Scheduler ,Scheduler 将接着上次的断点继续执行(空置 CPU 和放锁进程 B )。随后 CPU Scheduler 选中进程 A ,对其上锁并将其扶上位。在将进程 A 扶上位之后(执行完 kernel/proc.c:scheduler()swtch() 之后)执行上次的断点( kernel/proc.c:yield() 中的 swtch() 之后的语句),下一步的主要操作就是 kernel/proc.c:sched() 中的变量替换和 kernel/proc.c:yield() 中的放锁操作,

// in kernel/proc.c
void
sched(void)
{
  ...
  swtch(&p->context, &mycpu()->context);
  mycpu()->intena = intena;
}

// Give up the CPU for one scheduling round.
void
yield(void)
{
  ...
  sched();
  release(&p->lock);
}

至此,跟完了一整个进程切换流程

6.4 - Code: mycpu and myproc

本小节,其实没讲啥

要记住 CPU 也是有编号的,值写在寄存器 tp 中;每个 CPU 有一个 proc 指针,指向正在该 CPU 上运行的进程

6.5 - Sleep and wakeup

前面章节提到的 Scheduling 和 Locks 机制都是为了服务于进程切换这一主旨,其第一要义,就是进程切换要有序进行。比如进程 A 因为要执行 I/O 操作,所以它应该主动放弃 CPU ,然后去等待队列中 Sleep ,待满足 I/O 需求之后,再将进程 A 唤醒。这是再正常不过的想法了

Sleep 和 Wakeup 也被称为序列协调( sequence coordination ) or 条件同步机制( conditional synchronization ),本小节就是围绕着这个机制展开。我们将这种同步机制称为信号量( semaphore ),是用来协调生产者( producer )和消费者( consumer )的

一个信号量需要维护一个计数量,

struct semaphore {
  struct spinlock lock;
  int count;
};

和提供两个操作 PV ,V 操作是对于生产者而言的,

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

很容易理解,生产者每产生一份新的数据就会调用 V 操作,累加计数量,标明有数据可供消费者使用。P 操作是对于消费者而言的,

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

消费者一直在等待,通过 while 循环,检查是否还有数据可供使用。若有,则计数量消减

若此时系统中只有一个生产者线程和一个消费者线程,且两个线程运行在不同 CPU 上,那么上述的代码是正确的

虽然正确,但是性能不高!试想,生产者的活动不是很频繁发生的,那么消费者势必就需要通过一直 while 循环的手段来判断自己是否能够拿到数据。这就是忙等 or 空转,消费者的这种行为完全是在浪费 CPU 资源

有一种好的做法,就是消费者主动休眠( Sleep ),让出 CPU 控制权,交给其他更需要运行的进程。待有生产者执行 V 操作后(有新数据生成)将其唤醒( Wakeup )

我们尝试引入 Sleep 和 Wakeup 手段来解决 CPU 空转的问题,Sleep 很容易理解,就是将调用进程丢进等待队列( wait channel )里,然后强迫进程让出 CPU 控制权;同样,Wakeup 就是唤醒等待队列里的所有休眠进程,有一种情况需要注意,如果等待队列中没有休眠进程,则 Wakeup 什么事都不做。对应的代码如下,

1 void
2 V(struct semaphore *s)
3 {
4   acquire(&s->lock);
5   s->count += 1;
6   wakeup(s);
7   release(&s->lock);
8 }

9  void
10 P(struct semaphore *s)
11 {
12   while(s->count == 0)
13	 	 sleep(s);
14   acquire(&s->lock);
15   s->count -= 1;
16   release(&s->lock);
17 }

现在如愿以偿,CPU 再也不会空转浪费资源了。但是,这样的代码还有一个很大的问题,会发生漏唤醒( lost wake-up )的情况。试想,消费者线程在 P 操作时,通过第12行检查 s->count == 0 。此时,有一个生产者线程在另一个 CPU 上执行 V 操作,生成了一份新数据后,执行 wakeup 唤醒操作,但此时等待队列中并没有休眠进程(因为消费者还未执行第13行进入休眠状态)

待生产者执行完 V 操作后,消费者继续执行第13行进入休眠状态操作,消费者线程不按预期地进入了休眠状态。这种情况是不对的,它违背了我们的本意:我们希望消费者进入休眠状态是在生产者执行 wakeup 之前发生的。这样,才不会使得生产者的 wakeup 漏唤醒消费者

上述问题的表象,就是缓冲区内有数据,但消费者迟迟没被唤醒,也没有这机会被唤醒,除非生产者再一次执行 V 操作。本质,就是

没有有序地执行多线程的代码,导致不该先一步执行的线程过早执行了,而应该先执行的线程却迟迟不行动

这种涉及到多线程有序运行的问题,没有好的办法。唯有,上锁!

通过上锁实现操作的原子性,保证顺序执行。代码如下,

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 操作中,对计数量检查环节上了锁),但实际有大问题!执行 P 操作时,消费者一旦进入休眠状态(执行 sleep() ),就会发生死锁( deadlock )。s 的 lock 被带到等待队列里去了,没有被释放。后续想执行 V 操作,也无能为力,因为生产者拿不到 s 的 lock

所以留给我们的问题,就是在 sleep() 中顺带把 s 的 lock 放了就行,这样,就一通百通了。我们修改一下 sleep() 的接口,PV 操作代码如下,

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

6.6 - Code: Sleep and wakeup

sleep 的基本设想,就是把当前进程设置为 SLEEPING 状态,然后再调用 kernel/proc.c:sched() 让出 CPU 控制权。代码如下,

// Atomically release lock and sleep on chan.
// Reacquires lock when awakened.
void
sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();
  
  // Must acquire p->lock in order to
  // change p->state and then call sched.
  // Once we hold p->lock, we can be
  // guaranteed that we won't miss any wakeup
  // (wakeup locks p->lock),
  // so it's okay to release lk.
  if(lk != &p->lock){  //DOC: sleeplock0
    acquire(&p->lock);  //DOC: sleeplock1
    release(lk);
  }

  // Go to sleep.
  p->chan = chan;
  p->state = SLEEPING;

  sched();

  // Tidy up.
  p->chan = 0;

  // Reacquire original lock.
  if(lk != &p->lock){
    release(&p->lock);
    acquire(lk);
  }
}

在改变进程状态之前,必须拿到进程 p 的 lock!这无需解释!需要注意的是,sleep() 从开始执行,一直执行到 sched()sched() 中又会调用 kernel/swtch.S ,所以只有当进程 p 被 CPU 再次选中调用时,才会继续往下执行,断点就在 sched() 。还是需要 acquire(lk) 的,因为只有在 sleep() 中再次把 lock 捡起来,才能在 P 操作中顺理成章地释放 lock(与 P 操作中最后一句话 release(&s->lock) 相配套)

sleep() 刚开始确实是要确认,是否已经获取了进程 p 的 lock ,这是为了避免发生重复上锁的情况,放锁 release(lk) 也很重要,记得嘛?如果进入休眠状态不放锁的话,会导致死锁( [6.5 - Sleep and wakeup](#6.5 - Sleep and wakeup) 讲过)

同样,wakeup() 在开始时也需要上锁,这样可以避免发生漏唤醒的情况。试想,sleep 已经对进程 p 上锁了,即使 wakeup 想去唤醒进程 p ,也无能为力,因为抢不到进程 p 的 lock 。看一下代码,

// Wake up all processes sleeping on chan.
// Must be called without any p->lock.
void
wakeup(void *chan)
{
  struct proc *p;

  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);
    if(p->state == SLEEPING && p->chan == chan) {
      p->state = RUNNABLE;
    }
    release(&p->lock);
  }
}

业务逻辑很简单,就是去找等待队列中的进程,并尝试唤醒。其中的 acquire(&p->lock)release(&p->lock) 是相配套的

6.7 - Code: Pipes

kernel/pipe.c:pipewrite()kernel/pipe.c:piperead() 是 sleep 和 wakeup 的实例,其中前者是生产者的角色,后者是消费者的角色

pipewrite() 做的事,就是从用户层读数据到 kernel ,对应其中的 copyin() ,将新数据暂时保存到 ch 中,然后再写入到 pipe 的对应位置( pipe 可以理解成环形队列)

读完 n 个数据之后,唤醒等待数据的进程;如果缓冲区满( pi->nwrite == pi->nread+PIPESIZE 意味着缓冲区容量为 PIPESIZE ),则唤醒等待数据的进程,然后进入休眠状态,不再,也不能读取新数据。代码如下,

int
pipewrite(struct pipe *pi, uint64 addr, int n)
{
  int i;
  char ch;
  struct proc *pr = myproc();

  acquire(&pi->lock);
  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;
      }
      wakeup(&pi->nread);
      sleep(&pi->nwrite, &pi->lock);
    }
    if(copyin(pr->pagetable, &ch, addr + i, 1) == -1)
      break;
    pi->data[pi->nwrite++ % PIPESIZE] = ch;
  }
  wakeup(&pi->nread);
  release(&pi->lock);
  return i;
}

piperead() 做类似的事情,无非把写换成读而已,就不在此展开了

6.8 - Code: Wait, exit, and kill

wait 和 exit 也是 sleep 和 wakeup 的实例,wait() 就是等待子进程 exit() 之后结束运行,父进程等待的过程,就是调用 sleep() 实现。子进程完成运算之后,调用 exit() 通过 wakeup() 唤醒正在休眠的父进程

II. Source

  1. MIT - 6.S081 xv6 book
  2. B站 - MIT-6.S081 Lec11: Thread switching
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值