6.S081-9线程切换 - Thread Switching

6.S081-9线程切换 - Thread Switching

0. 简单总结:

关于线程切换部分,建议不看本篇文章,而是看6.S081 附加Lab3 线程切换——源代码实现(trap,yeild,context,Scheduler)

1. 一个线程可以认为是串行执行代码的单元

2. 内核会在两个场景下出让CPU:

  • 当定时器中断触发了,内核总是会让当前进程出让CPU,因为我们需要在定时器中断间隔的时间点上交织执行所有想要运行的进程。

  • 当前进程阻塞了:另一种场景就是任何时候一个进程调用了系统调用并等待I/O,例如等待你敲入下一个按键,在你还没有按下按键时,等待I/O的机制会触发出让CPU。

3. 线程切换——过程

  • 计时器中断(抢占式)—— 因为中断优先级更高,因此会直接抢占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寄存器)来实现)。

1. 什么是{线程,多线线程,线程调度}

1.1 为什么使用多线程?

一个线程可以认为是串行执行代码的单元。

  • 人们希望他们的计算机在同一时间不是只执行一个任务。

  • 让程序的结构变得简单。(比如Lab1的prime)

  • 并行运算更快的速度。

1.2 xv6 内核线程

  • XV6内核共享了内存,并且XV6支持内核线程的概念对于每个用户进程都有一个内核线程来执行来自用户进程的系统调用。所有的内核线程都共享了内核内存,所以XV6的内核线程的确会共享内存。

  • 另一方面,XV6还有另外一种线程。每一个用户进程都有独立的内存地址空间,并且包含了一个线程,**这个线程控制了用户进程代码指令的执行。**所以XV6中的用户线程之间没有共享内存,你可以有多个用户进程,但是每个用户进程都是拥有一个线程的独立地址空间。XV6中的进程不会共享内存。

1.3 线程调度

实现内核中的线程系统存在以下挑战:

  • 如何实现线程间的切换(调度) —— scheduler
  • 线程切换的时候保存寄存器和栈的内容
  • 如何处理运算密集型线程(这样的线程并不能自愿的出让CPU给其他的线程运行)—— 定时器中断解决。

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

如何处理运算密集型线程。这里的具体实现你们之前或许已经知道了,就是利用定时器中断。在每个CPU核上,都存在一个硬件设备,它会定时产生中断。 XV6与其他所有的操作系统一样,将这个中断传输到了内核中。所以即使我们正在用户空间计算π的前100万位,定时器中断仍然能在例如每隔10ms的某个时间触发,并将程序运行的控制权从用户空间代码切换到内核中的中断处理程序(注,因为中断处理程序优先级更高)。哪怕这些用户空间进程并不配合工作(注,也就是用户空间进程一直占用CPU),内核也可以从用户空间进程获取CPU控制权。

位于内核的定时器中断处理程序,会自愿的将CPU出让(yield)给线程调度器,并告诉线程调度器说,你可以让一些其他的线程运行了。这里的出让其实也是一种线程切换,它会保存当前线程的状态,并在稍后恢复。

线程的三种状态:

  • RUNNING,线程当前正在某个CPU上运行

  • RUNABLE,线程还没有在某个CPU上运行,但是一旦有空闲的CPU就可以运行

  • SLEEPING,这节课我们不会介绍,下节课会重点介绍,这个状态意味着线程在等待一些I/O事件,它只会在I/O事件发生了之后运行

2. 线程(进程)切换——有代码示例

关于这部分可以看我的6.S081 附加Lab3 线程切换——源代码实现(trap,yeild,context,Scheduler)
更加清晰,模块化。
—— 建议看附加lab,而不是接下来的内容。

总结:cpu需要做的事情👇

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

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

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

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

  • 解锁。

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

1. 线程切换——时机

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

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

2. 线程切换——过程

  • 计时器中断(抢占式)—— 因为中断优先级更高,因此会直接抢占CPU,进入trap处理中断。

  • trap期间会调用yeild()yeild()会加锁,改变进程状态,并调用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寄存器)来实现)。

在XV6中,任何时候都需要经历:

  1. 从一个用户进程切换到另一个用户进程,都需要从第一个用户进程接入到内核中,保存用户进程的状态并运行第一个用户进程的内核线程。
  2. 再从第一个用户进程的内核线程切换到第二个用户进程的内核线程。
  3. 之后,第二个用户进程的内核线程暂停自己,并恢复第二个用户进程的用户寄存器。
  4. 最后返回到第二个用户进程继续执行。

内核会在两个场景下出让CPU:

  • 当定时器中断触发了,内核总是会让当前进程出让CPU,因为我们需要在定时器中断间隔的时间点上交织执行所有想要运行的进程。

  • 当前进程阻塞了:另一种场景就是任何时候一个进程调用了系统调用并等待I/O,例如等待你敲入下一个按键,在你还没有按下按键时,等待I/O的机制会触发出让CPU。

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

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

编写一个程序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切换到另一个进程运行然后又输出“\”一会。所以在这里我们可以看到定时器中断在起作用。

接下来,在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-20220820161522330

学生提问:当一个线程结束执行了,比如说在用户空间通过exit系统调用结束线程,同时也会关闭进程的内核线程。那么线程结束之后和下一个定时器中断之间这段时间,CPU仍然会被这个线程占有吗?还是说我们在结束线程的时候会启动一个新的线程?

Robert教授:exit系统调用会出让CPU。尽管我们这节课主要是基于定时器中断来讨论,但是实际上XV6切换线程的绝大部分场景都不是因为定时器中断,比如说一些系统调用在等待一些事件并决定让出CPU。exit系统调用会做各种操作然后调用yield函数来出让CPU,这里的出让并不依赖定时器中断。

回到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) 

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

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函数。

接下来打印出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指令,我们就可以返回到调度器线程中。

然后我们来看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的调用。这里可能会有点让人困惑,但是这就是线程切换的核心。

另一件需要注意的事情是,swtch函数是线程切换的核心,但是swtch函数中只有保存寄存器,再加载寄存器的操作。线程除了寄存器以外的还有很多其他状态,它有变量,堆中的数据等等,**但是所有的这些数据都在内存中,并且会保持不变。我们没有改变线程的任何栈或者堆数据。所以线程切换的过程中,处理器中的寄存器是唯一的不稳定状态,且需要保存并恢复。而所有其他在内存中的数据会保存在内存中不被改变,所以不用特意保存并恢复。**我们只是保存并恢复了处理器中的寄存器,因为我们想在新的线程中也使用相同的一组寄存器。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值