6.S081 附加Lab3 线程切换——源代码实现(trap,yeild,context,Scheduler)

6.S081 附加Lab3 线程切换——源代码实现(trap,yield,context,Scheduler)

个人总结

线程调度的步骤(—— 能看懂这段话,后面的实验就不用看了,本篇文章就是在讲这个过程。)首先计时器通过触发中断,将CPU控制权交给内核(内核的trap程序,然后trap的处理是调用yield函数),yield函数调用的swtch在保存了即将换出的进程的context(包括栈指针)之后,将CPU控制权,交给了内核的调度程序scheduler,内核调度程序scheduler通过它调用的swtch,将CPU的控制权交给下一个即将调度进来的程序。

本实验衍生于 6.S081-9线程切换 - Thread Switching

线程调度的实现原理

在XV6和其他的操作系统中,线程调度是这么实现的:定时器中断会强制的将CPU控制权从用户进程给到内核,这里是pre-emptive scheduling,之后内核会代表用户进程(注,实际是内核中用户进程对应的内核线程会代表用户进程出让CPU),使用voluntary scheduling。

cpu需要做的事情👇

  • 加锁,因为RUNNABLE之后还没保存当前context并停止当前进程栈之前不想让别的cpu在sleep队列中看到并运行。

  • 我将进程的状态从RUNNING改成RUNABLE;

  • 将进程的寄存器保存在context对象中

  • 停止使用当前进程的栈。

  • 解锁。

线程切换的核心(时机 + 过程)

1. 线程切换——时机

  • 当前线程阻塞,比如需要访问IO,等待其他资源,并且需要等待的时候;

  • 计时器中断(抢占式)—— 因为中断优先级更高,因此会直接抢占CPU,进入trap处理中断,期间会调用yield,yield会加锁,改变进程状态,并调用swtch。

2. 线程切换——过程

  • 计时器中断(抢占式)—— 因为中断优先级更高,因此会直接抢占CPU,进入trap处理中断。
  • trap期间会调用yield()yield()会加锁,改变进程状态,并调用swtch()
  • swtch()函数,该函数将当前cpu的context(PC,SP,还有通用寄存器等)存到p->context(这里的p是即将被换出的进程),然后将即将被换入的进程的context中的内容复制到cpu的context中,因为这个过程中复制了ra(即ret之后的pc的内容),因此swtch返回的不是调用swtch的地址,反而是另一个swtch的地址(来自即将换入进程的scheduler中的swtch)。 —— 即trap之后通过swtch(),保存了当前进程的上下文,栈,并且将CPU的控制权转让给内核的scheduler线程(这里是主动转让,通过$ra(即PC寄存器)来实现)。

0.Base(示例代码 + 结构体)

0.1 进程结构体 proc

接下来从代码看进程切换👇

proc结构体👇,每个字段的内容都很清晰。

  • 首先是保存了用户空间线程寄存器的trapframe字段
  • 其次是保存了内核线程寄存器的context字段
  • 还有保存了当前进程的内核栈的kstack字段,这是进程在内核中执行时保存函数调用的位置
  • state字段保存了当前进程状态,要么是RUNNING,要么是RUNABLE,要么是SLEEPING等等
  • lock字段保护了很多数据,目前来说至少保护了对于state字段的更新。举个例子,因为有锁的保护,两个CPU的调度器线程不会同时拉取同一个RUNABLE进程并运行它
// Per-process state
struct proc {
  struct spinlock lock;

  // p->lock must be held when using these:
  enum procstate state;        // Process state
  struct proc *parent;         // Parent process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  int xstate;                  // Exit status to be returned to parent's wait
  int pid;                     // Process ID

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;               // Bottom of kernel stack for this process
  uint64 sz;                   // Size of process memory (bytes)
  pagetable_t pagetable;       // Page table
  struct trapframe *tf;        // data page for trampoline.S
  struct context context;      // swtch() here to run process
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  char name[16];               // Process name (debugging)
};

0.2 进程切换的用户程序源码 + 执行效果

编写一个程序LEVI_spin.c如下👇,它的任务是每隔1000000次循环,输出一个符号 /或者\ (取决于是哪个进程)。

//
// LEVI_spin.c
//

#include "kernel/types.h"
#include "user/user.h" 

int 
main(int argc, char* argv[]) {
    int pid;
    char c;

    pid = fork();
    if (pid == 0) {
        c = '/';
    } else {
        printf("parent id is %d, child id is %d\n", getpid(), pid);
        c = '\\';
    }

    for (int i = 0; ; ++i) {
        if ((i % 1000000) == 0) 
            write(2, &c, 1);
    }

    exit(0);
}

如果使用单cpu,执行上述程序,结果如下👇(因为只有一个cpu,所以有必要让两个进程之间能够相互切换)

wc@r740:~/OS_experiment/xv6-riscv-fall19$ make CPUS=1 qemu
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0

xv6 kernel is booting

virtio disk init 0
page table 0x0000000087f6e000
..0: pte 0x0000000021fda801 pa 0x0000000087f6a000
.. ..0: pte 0x0000000021fda401 pa 0x0000000087f69000
.. .. ..0: pte 0x0000000021fdac1f pa 0x0000000087f6b000
.. .. ..1: pte 0x0000000021fda00f pa 0x0000000087f68000
.. .. ..2: pte 0x0000000021fd9c1f pa 0x0000000087f67000
init: starting sh
$ LEVI_spin
parent id is 3, child id is 4
\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\\//\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\/\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\\//\\\\\\\\\/\\\\\\\\\\//\\

从上面运行结果可以看出,“/”输出了一会之后,定时器中断将CPU切换到另一个进程运行然后又输出“\”一会。所以在这里我们可以看到定时器中断在起作用。

1. 中断发生,trap处理

接下来,在trap.c的devintr函数中的224行设置一个断点,这一行会识别出当前是在响应定时器中断。

...  
	} else if(scause == 0x8000000000000001L){
    // software interrupt from a machine-mode timer interrupt,
    // forwarded by timervec in kernelvec.S.

    if(cpuid() == 0){
      clockintr();
    }
    
    // acknowledge the software interrupt by clearing
    // the SSIP bit in sip.
    w_sip(r_sip() & ~2);

    return 2;
  } else {
    return 0;
  }

之后在gdb中continue。立刻会停在中断的位置,因为定时器中断还是挺频繁的。现在我们可以确认我们在usertrap函数中,并且usertrap函数通过调用devintr函数来处理这里的中断。

(gdb) target remote localhost:26017
Remote debugging using localhost:26017
0x0000000000001000 in ?? ()
(gdb) b trap.c:224
Breakpoint 1 at 0x80002706: file kernel/trap.c, line 224.
(gdb) c
Continuing.

Breakpoint 1, devintr () at kernel/trap.c:224
224       } else if(scause == 0x8000000000000001L){
(gdb) 

因为devintr函数处理定时器中断的代码基本没有内容,接下来我在gdb中输入finish来从devintr函数中返回到usertrap函数。当我们返回到usertrap函数时,虽然我们刚刚从devintr函数中返回,但是我们期望运行到下面的yield函数。所以我们期望devintr函数返回2。

    uvmalloc(p->pagetable, PGROUNDDOWN(r_stval()), PGROUNDDOWN(r_stval()) + 4096);
  } else if((which_dev = devintr()) != 0) {
    // ok
  } else {
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    printf("page down:%d\n",PGROUNDDOWN(r_stval()));
    p->killed = 1;
  }

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

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

  usertrapret();
}

可以从gdb中看到devintr的确返回的是2。

(gdb) c
Continuing.

Breakpoint 1, devintr () at kernel/trap.c:224
224       } else if(scause == 0x8000000000000001L){
(gdb) finish
Run till exit from #0  devintr () at kernel/trap.c:224
0x0000000080002900 in kerneltrap () at kernel/trap.c:174
174       if((which_dev = devintr()) == 0){
Value returned is $1 = 2
(gdb) 

在yield函数中,当前进程会出让CPU并让另一个进程运行。这个我们稍后再看。现在让我们看一下当定时器中断发生的时候,用户空间进程正在执行什么内容。我在gdb中输入print proc来打印名称为proc的变量。变量proc包含了当前进程的proc结构体。

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

我首先会打印proc->name来获取进程的名称(这里不知为啥我永远进不去,因此我改成了在trap.c的108行添加断点👇)

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

可以看到,打印出来的p->name 是我们当前运行的LEVI_spin。

(gdb) print p->name 
$1 = "LEVI_spin\000\000\000\000\000\000"

继续看一些p的成员👇(这里可以看出是父进程的id,如果进程切换后,pid应该会变成4)

(gdb) print p->state
$2 = RUNNING
(gdb) print p->pid
$3 = 3

继续看trapframe的spc寄存器(saved user program counter)

(gdb) print/x p->tf->epc
$4 = 0x62

我们可以查看spin.asm文件来确定对应地址的指令👇(可以看到定时器中断触发时,用户进程正在执行死循环的加1,这符合我们的预期。)

接下来,在trap.c的devintr函数中的224行设置一个断点,这一行会识别出当前是在响应定时器中断。

...  
	} else if(scause == 0x8000000000000001L){
    // software interrupt from a machine-mode timer interrupt,
    // forwarded by timervec in kernelvec.S.

    if(cpuid() == 0){
      clockintr();
    }
    
    // acknowledge the software interrupt by clearing
    // the SSIP bit in sip.
    w_sip(r_sip() & ~2);

    return 2;
  } else {
    return 0;
  }

之后在gdb中continue。立刻会停在中断的位置,因为定时器中断还是挺频繁的。现在我们可以确认我们在usertrap函数中,并且usertrap函数通过调用devintr函数来处理这里的中断。

(gdb) target remote localhost:26017
Remote debugging using localhost:26017
0x0000000000001000 in ?? ()
(gdb) b trap.c:224
Breakpoint 1 at 0x80002706: file kernel/trap.c, line 224.
(gdb) c
Continuing.

Breakpoint 1, devintr () at kernel/trap.c:224
224       } else if(scause == 0x8000000000000001L){
(gdb) 

因为devintr函数处理定时器中断的代码基本没有内容,接下来我在gdb中输入finish来从devintr函数中返回到usertrap函数。当我们返回到usertrap函数时,虽然我们刚刚从devintr函数中返回,但是我们期望运行到下面的yield函数。所以我们期望devintr函数返回2。

    uvmalloc(p->pagetable, PGROUNDDOWN(r_stval()), PGROUNDDOWN(r_stval()) + 4096);
  } else if((which_dev = devintr()) != 0) {
    // ok
  } else {
    printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
    printf("            sepc=%p stval=%p\n", r_sepc(), r_stval());
    printf("page down:%d\n",PGROUNDDOWN(r_stval()));
    p->killed = 1;
  }

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

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

  usertrapret();
}

可以从gdb中看到devintr的确返回的是2。

(gdb) c
Continuing.

Breakpoint 1, devintr () at kernel/trap.c:224
224       } else if(scause == 0x8000000000000001L){
(gdb) finish
Run till exit from #0  devintr () at kernel/trap.c:224
0x0000000080002900 in kerneltrap () at kernel/trap.c:174
174       if((which_dev = devintr()) == 0){
Value returned is $1 = 2
(gdb) 

在yield函数中,当前进程会出让CPU并让另一个进程运行。这个我们稍后再看。现在让我们看一下当定时器中断发生的时候,用户空间进程正在执行什么内容。我在gdb中输入print proc来打印名称为proc的变量。变量proc包含了当前进程的proc结构体。

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

我首先会打印proc->name来获取进程的名称(这里不知为啥我永远进不去,因此我改成了在trap.c的108行添加断点👇)

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

可以看到,打印出来的p->name 是我们当前运行的LEVI_spin。

(gdb) print p->name 
$1 = "LEVI_spin\000\000\000\000\000\000"

继续看一些p的成员👇(这里可以看出是父进程的id,如果进程切换后,pid应该会变成4)

(gdb) print p->state
$2 = RUNNING
(gdb) print p->pid
$3 = 3

继续看trapframe的spc寄存器(saved user program counter)

(gdb) print/x p->tf->epc
$4 = 0x62

我们可以查看spin.asm文件来确定对应地址的指令👇(可以看到定时器中断触发时,用户进程正在执行死循环的加1,这符合我们的预期。)

image-20220821144725808

回到devintr函数返回到usertrap函数中的位置。在gdb里面输入几次step走到yield函数的调用。

(gdb) print/x p->tf->epc
$4 = 0x62
(gdb) step
[Switching to Thread 1.3]

Thread 3 hit Breakpoint 1, usertrap () at kernel/trap.c:108
108       if(which_dev == 2)
(gdb) step

Thread 2 received signal SIGTRAP, Trace/breakpoint trap.
[Switching to Thread 1.2]
0x00000000800028ca in usertrap () at kernel/trap.c:109
109         yield();
(gdb) step
yield () at kernel/proc.c:576
576       struct proc *p = myproc();
(gdb) 

2. yield() —— 线程切换的第一步

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

yield做的事情:

  • 获取进程锁。
  • 将进程状态改为RUNNABLE(表示进程并没有在运行——但是这个进程实际上还是在运行,代码正在当前进程的内核线程中运行)
  • call sched() —— 做一些合法检查,然后调用swtch()
  • 释放锁。

所以这里加锁的目的之一就是:即使我们将进程的状态改为了RUNABLE,其他的CPU核的调度器线程也不可能看到进程的状态为RUNABLE并尝试运行它。否则的话,进程就会在两个CPU核上运行了,而一个进程只有一个栈,这意味着两个CPU核在同一个栈上运行代码(注,因为XV6中一个用户进程只有一个用户线程)。

当前进程的状态是RUNABLE意味着它还会再次运行,因为毕竟现在是一个定时器中断打断了当前正在运行的进程。

接下来看sched👇, 可以看出,sched函数基本没有干任何事情,只是做了一些合理性检查,如果发现异常就panic。我将跳过所有的检查,直接走到位于底部的swtch函数。

// Switch to scheduler.  Must hold only p->lock
// and have changed proc->state. Saves and restores
// intena because intena is a property of this
// kernel thread, not this CPU. It should
// be proc->intena and proc->noff, but that would
// break in the few places where a lock is held but
// there's no process.
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()->scheduler); // 函数的第一个变量存在a0,第二个变量就是a1
  mycpu()->intena = intena;
}

3. swtch() —— 线程切换的核心

swtch函数会将当前的内核进程寄存器保存到p->context(上下文)中(mycpu()->scheduler就是下一个将要切换进来的进程的上下文)。

因为我们接下来要调用swtch函数,让我们首先来看看swtch函数的内容。swtch函数位于switch.s文件中👇

# 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 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

首先,ra寄存器被保存在了a0寄存器指向的地址。a0寄存器对应了swtch函数的第一个参数,从前面可以看出这是当前线程的context对象地址 ;a1寄存器对应了swtch函数的第二个参数,从前面可以看出这是即将要切换到的调度器线程的context对象地址。

所以函数中上半部分是将当前的寄存器保存在当前线程对应的context对象中,函数的下半部分是将调度器线程的寄存器,也就是我们将要切换到的线程的寄存器恢复到CPU的寄存器中。之后函数就返回了。所以调度器线程的ra寄存器的内容才显得有趣,因为它指向的是swtch函数返回的地址,也就是scheduler函数。——即swtch函数返回的位置,并不是调用它的位置,而是scheduler函数中调用swtch的位置——首先计时器通过触发中断,将CPU控制权交给OS(的trap程序,然后trap的处理是调用yield函数),yield函数调用的swtch在保存了即将换出的进程的context(包括栈指针)之后,将CPU控制权,交给了内核的调度程序scheduler,内核调度程序scheduler通过它调用的swtch,将CPU的控制权交给下一个即将调度进来的程序。

接下来打印出cpu0的上下文(这里的cpus[0].scheduler其实是cpu[0].context —— 19年版本的xv6的命名好像不太好,20年只有改成context了)

(gdb) step
109         yield();
(gdb) step

Breakpoint 2, yield () at kernel/proc.c:575
575     {
(gdb) next
576       struct proc *p = myproc();
(gdb) next
577       acquire(&p->lock);
(gdb) print/x cpus[0].scheduler
$1 = {ra = 0x80001f42, sp = 0x8000b790, s0 = 0x8000b7e0, s1 = 0x80012fe0, s2 = 0x2, s3 = 0x80018900, s4 = 0x800128e8, s5 = 0x0, s6 = 0x80012908, s7 = 0x1, 
  s8 = 0x3, s9 = 0x0, s10 = 0x0, s11 = 0x0}

这里看到的就是之前保存的当前CPU核的调度器线程的寄存器。在这些寄存器中,最有趣的就是ra(Return Address)寄存器,因为ra寄存器保存的是当前函数的返回地址,所以调度器线程中的代码会返回到ra寄存器中的地址。通过查看kernel.asm,我们可以知道这个地址的内容是什么。也可以在gdb中输入x/i 0x80001f42进行查看。

(gdb) x/i 0x80001f42
   0x80001f42 <scheduler+138>:  sd      zero,24(s4)
(gdb) 

输出中包含了地址中的指令和指令所在的函数名。所以我们将要返回到scheduler函数中。

(gdb) x/i 0x80001f42
   0x80001f42 <scheduler+138>:  sd      zero,24(s4)
(gdb) tbreak swtch
Temporary breakpoint 3 at 0x80002566
(gdb) c
Continuing.

Temporary breakpoint 3, 0x0000000080002566 in swtch ()

此时打印$ra寄存器,可以看到返回的地址是(调用我们的swtch的地址是sched)

(gdb) print $ra
$2 = (void (*)()) 0x8000200c <sched+124>

然后可以看一下stack pointer寄存器,如👇,说明是在内核栈(因为地址很大)

(gdb) print $sp
$3 = (void *) 0x3fffff9f90

然后我们跳过swtch的内容,继续打印$sp👇, 可以看出sp寄存器的值现在在内存中的stack0区域中。这个区域实际上是在启动顺序中非常非常早的一个位置,start.s在这个区域创建了栈,这样才可以调用第一个C函数。所以调度器线程运行在CPU对应的bootstack上。(还记得嘛,我们的启动地址就是0x8000000

(gdb) print $sp
$3 = (void *) 0x3fffff9f90
(gdb) stepi 28
0x00000000800025ce in swtch ()
(gdb) print $sp
$4 = (void *) 0x8000b790 <stack0+3984>

现在指向了scheduler函数**(scheduler是OS的main函数执行的最后一个函数调用,并且永远不返回——它是负责进程调度的线程)**,因为我们恢复了调度器线程的context对象中的内容。我们其实已经在调度器线程中了,这里寄存器的值与上次打印的已经完全不一样了。虽然我们还在swtch函数中,但是现在我们实际上位于调度器线程调用的swtch函数中。调度器线程在启动过程中调用的也是swtch函数。接下来通过执行ret指令,我们就可以返回到调度器线程中。

4. scheduler() —— 内核的调度线程

然后我们来看scheduler的代码👇

// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns.  It loops, doing:
//  - choose a process to run.
//  - swtch to start running that process.
//  - eventually that process transfers control
//    via swtch back to the scheduler.
void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();
  
  c->proc = 0;
  for(;;){
    // Avoid deadlock by ensuring that devices can interrupt.
    intr_on();

    int found = 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->scheduler, &p->context);

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

        found = 1;
      }
      release(&p->lock);
    }
    if(found == 0){
      intr_on();
      asm volatile("wfi");
    }
  }
}

现在我们正运行在CPU拥有的调度器线程中,并且我们正好在之前调用swtch函数的返回状态。之前调度器线程调用switch是因为想要运行pid为3的进程,也就是刚刚被中断的spin程序。

虽然pid为3的spin进程也调用了swtch函数,但是那个switch并不是当前返回的这个switch。spin进程调用的swtch函数还没有返回,而是保存在了pid为3的栈和context对象中。现在返回的是之前调度器线程对于swtch函数的调用。

在scheduler函数中,因为我们已经停止了spin进程的运行,所以我们需要抹去对于spin进程的记录。我们接下来将c->proc设置为0(c->proc = 0;)。因为我们现在并没有在这个CPU核上运行这个进程,为了不让任何人感到困惑,我们这里将CPU核运行的进程对象设置为0。

之前在yield函数中获取了进程的锁,因为yield不想进程完全进入到Sleep状态之前,任何其他的CPU核的调度器线程看到这个进程并运行它。而现在我们完成了从spin进程切换走,所以现在可以释放锁了。这就是release(&p->lock)的意义。现在,我们仍然在scheduler函数中,但是其他的CPU核可以找到spin进程,并且因为spin进程是RUNABLE状态,其他的CPU可以运行它。这没有问题,因为我们已经完整的保存了spin进程的寄存器,并且我们不在spin进程的栈上运行程序,而是在当前CPU核的调度器线程栈上运行程序,所以其他的CPU核运行spin程序并没有问题。但是因为启动QEMU时我们只指定了一个核,所以在我们现在的演示中并没有其他的CPU核来运行spin程序。

总结,这个过程中CPU需要做的事情👇

  • 加锁,因为RUNNABLE之后还没保存当前context并停止当前进程栈之前不想让别的cpu在sleep队列中看到并运行。

  • 我将进程的状态从RUNNING改成RUNABLE;

  • 将进程的寄存器保存在context对象中

  • 停止使用当前进程的栈。

  • 解锁。

现在我们在scheduler函数的循环中,代码会检查所有的进程并找到一个来运行。现在我们知道还有另一个进程,因为我们之前fork了另一个spin进程。这里我跳过进程检查,直接在找到RUNABLE进程的位置设置一个断点👇

image-20220820170738845

(gdb) stepi 28
0x00000000800025ce in swtch ()
(gdb) print $sp
$4 = (void *) 0x8000b790 <stack0+3984>
(gdb) b proc.c:526
Breakpoint 4 at 0x80001f2c: file kernel/proc.c, line 526.
(gdb) c
Continuing.

Breakpoint 4, scheduler () at kernel/proc.c:526
526             p->state = RUNNING;
(gdb) print p->name
$5 = "LEVI_spin\000\000\000\000\000\000"
(gdb) print p->pid
$6 = 4

可以看到,进程名称没有变化,但是进程id已经发生改变,即进程已经切换成功了。继续打印context👇

(gdb) print/x p->context
$7 = {ra = 0x8000200c, sp = 0x3fffff7f90, s0 = 0x3fffff7fc0, s1 = 0x80013150, s2 = 0x800128e8, s3 = 0x0, s4 = 0x13fb, s5 = 0x1380, s6 = 0x505050505050505, 
  s7 = 0x505050505050505, s8 = 0x505050505050505, s9 = 0x505050505050505, s10 = 0x505050505050505, s11 = 0x505050505050505}

其中ra寄存器的内容就是我们要切换到的目标线程的代码位置。

虽然我们在代码528行调用的是swtch函数,但是我们前面已经看过了swtch函数会返回到即将恢复的ra寄存器地址,所以我们真正关心的就是ra指向的地址。

(gdb) x/i 0x8000200c
   0x8000200c <sched+124>:      mv      a5,tp

通过打印这个地址的内容,可以看到swtch函数会返回到sched函数中。这完全在意料之中,因为可以预期的是,将要切换到的进程之前是被定时器中断通过sched函数挂起的,并且之前在sched函数中又调用了swtch函数。

在swtch函数的最开始,我们仍然在调度器线程中,但是这一次是从调度器线程切换到目标进程的内核线程。所以从swtch函数内部将会返回到目标进程的内核线程的sched函数,通过打印backtrace,

(gdb) tbreak swtch
Temporary breakpoint 5 at 0x80002566
(gdb) c
Continuing.

Temporary breakpoint 5, 0x0000000080002566 in swtch ()
(gdb) where
#0  0x0000000080002566 in swtch ()
#1  0x0000000080001f42 in sched () at kernel/proc.c:510
#2  0x0000000080000212c in yeild () at ker/proc.c:521
#3  0x0000000080002824 in usertrap () at kernel/trap.c:81
#4  0x0000000000000062 in ?? () 
(gdb) 

我们可以看到,之前有一个usertrap的调用,这必然是之前因为定时器中断而出现的调用。之后在中断处理函数中还调用了yield和sched函数,正如我们之前看到的一样。但是,这里调用yield和sched函数是在pid为4的进程调用的,而不是我们刚刚看的pid为3的进程。

这里有件事情需要注意,调度器线程调用了swtch函数,但是我们从swtch函数返回时,实际上是返回到了对于switch的另一个调用,而不是调度器线程中的调用。我们返回到的是pid为4的进程在很久之前对于switch的调用。这里可能会有点让人困惑,但是这就是线程切换的核心。

5. 线程切换时的限制(不能持有除p->lock以外的任何锁)

进程在调用switch函数的过程中,必须要持有p->lock(注,也就是进程对应的proc结构体中的锁),但是同时又不能持有任何其他的锁。这也是包含了Sleep在内的很多设计的限制条件之一。如果你是一个XV6的程序员,你需要遵循这条规则。接下来让我解释一下背后的原因,首先构建一个不满足这个限制条件的场景:

我们有进程P1,P1的内核线程持有了p->lock以外的其他锁,这些锁可能是在使用磁盘,UART,console过程中持有的。之后内核线程在持有锁的时候,通过调用switch/yield/sched函数出让CPU,这会导致进程P1持有了锁,但是进程P1又不在运行。
假设我们在一个只有一个CPU核的机器上,进程P1调用了switch函数将CPU控制转给了调度器线程,调度器线程发现还有一个进程P2的内核线程正在等待被运行,所以调度器线程会切换到运行进程P2。假设P2也想使用磁盘,UART或者console,它会对P1持有的锁调用acquire,这是对于同一个锁的第二个acquire调用。当然这个锁现在已经被P1持有了,所以这里的acquire并不能获取锁。假设这里是spinlock,那么进程P2会在一个循环里不停的“旋转”并等待锁被释放。但是很明显进程P2的acquire不会返回,所以即使进程P2稍后愿意出让CPU,P2也没机会这么做。之所以没机会是因为P2对于锁的acquire调用在直到锁释放之前都不会返回,而唯一锁能被释放的方式就是进程P1恢复执行并在稍后release锁,但是这一步又还没有发生,因为进程P1通过调用switch函数切换到了P2,而P2又在不停的“旋转”并等待锁被释放。这是一种死锁,它会导致系统停止运行。

所以,我们在XV6中禁止在调用switch时持有除进程自身锁(注,也就是p->lock)以外的其他锁。

学生提问:难道定时器中断不会将CPU控制切换回进程P1从而解决死锁的问题吗?
Robert教授:首先,所有的进程切换过程都发生在内核中,所有的acquire,switch,release都发生在内核代码而不是用户代码。实际上XV6允许在执行内核代码时触发中断,如果你查看trap.c中的代码你可以发现,如果XV6正在执行内核代码时发生了定时器中断,中断处理程序会调用yield函数并出让CPU。
但是在之前的课程中我们讲过acquire函数在等待锁之前会关闭中断,否则的话可能会引起死锁,所以我们不能在等待锁的时候处理中断。所以如果你查看XV6中的acquire函数,你可以发现函数中第一件事情就是关闭中断,之后再“自旋”等待锁释放。你或许会想,为什么不能先“自旋”等待锁释放,再关闭中断?因为这样会有一个短暂的时间段锁被持有了但是中断没有关闭,在这个时间段内的设备的中断处理程序可能会引起死锁。
所以不幸的是,当我们在自旋等待锁释放时会关闭中断,进而阻止了定时器中断并且阻止了进程P2将CPU出让回给进程P1。嗯,这是个好问题。

学生提问:能重复一下死锁是如何避免的吗?
Robert教授:在XV6中,死锁是通过禁止在线程切换的时候加锁来避免的。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值