Thread switching
今天讨论 thread 以及 xv6 如何在多个线程之间完成切换。
# 1. Thread 概述
线程可以认为是一种在有多个任务时简化编程的抽象,线程有多种定义,在这里,我们认为线程就是单个串行执行代码的单元,它只占用一个 CPU 且以普通的方式一个接一个地执行指令。
线程还具有状态,我们可以随时保存线程的状态并暂停线程的运行,并在之后通过恢复状态来恢复线程的运行。线程的状态包含了三个部分:
- 程序计数器(PC),表示当前线程执行指令的位置
- 保存变量的寄存器
- 程序的 stack。通常每个线程都有属于自己的 stack,里面记录了函数调用的记录
操作系统中线程系统的工作就是管理多个线程的运行。我们可能会启动成百上千个线程,而线程系统的工作就是弄清楚如何管理这些线程并让它们都能运行。
多线程并行有两个主要策略:
- 在多核处理器上使用多个 CPU,每个 CPU 都可以运行一个线程(无法解决 4 个 CPU 却有上千个线程的问题)
- 一个 CPU 在多个线程之间来回切换(这节课主要讨论的)
与大多数 OS 一样,xv6 结合了这两个策略,在多个可用的 CPU 核上来回切换运行多个线程。
不同线程之间的一个主要区别就是:线程之间是否会共享内存。
Linux 是每个进程可以有多个线程,但 xv6 是一个进程只有一个线程。
xv6 内核共享了内存,并且 xv6 支持内核线程的概念。对于每个用户进程都有一个内核线程来执行来自用户的 system call,所有内核线程都共享了内核内存,所以 xv6 的内核线程的确会共享内存。
另一方面,xv6 还有另外一种线程:每个用户进程都有一个独立的内存地址空间,并且包含了一个线程,这个线程控制了用户进程代码指令的执行。所以 XV6 中的用户线程之间没有共享内存,你可以有多个用户进程,但是每个用户进程都是拥有一个线程的独立地址空间。XV6
在 Linux 中,允许一个用户进程包含多个线程,同一进程的多个线程之间共享进程的地址空间。在 Linux 的实现中用到了很多今天介绍的技术,但 Linux 的实现会更加复杂。
# 2. XV6 的线程调度
实现内核的线程系统存在以下挑战:
- 线程间的切换。停止一个线程的运行并启动另一个线程的过程被称为 Scheduling(线程调度)。xv6 为每个 CPU 核都创建了一个线程调度器(scheduler)。
- 线程切换时状态的保存和恢复。
- 撤回线程对 CPU 的控制,并放置一边稍后再运行。比如运行密集型线程可能长时间都不会自愿让出控制权。
首先介绍一下如何撤回线程对 CPU 的控制权。
具体实现就是利用定时器中断。每个 CPU 核上都有一个硬件设备,它会定时产生中断,这个中断会被传输到 kernel 中,并将程序运行的控制权从用户空间代码切换到内核中的中断处理程序。这时,即使用户程序在占用 CPU,内核也会从用户空间进程获取 CPU 控制权。位于内核的定时器中断处理程序,会自愿地将 CPU 出让(yield)给线程调度器,并告诉调度器说可以让一些其他的线程来运行了。
所以这里的基本流程就是:定时器中断将 CPU 控制权给到 kernel,kernel 再自愿地出让 CPU。这里的处理流程被称为 pre-emptive scheduling,意思是即使用户代码本身没有出让 CPU,定时器中断仍然会将 CPU 的控制权拿走,并出让给线程调度器。与之相反的是 voluntary scheduling。
在执行线程调度的时候,OS 需要能根据状态区分几类线程(实际上类型更多):
- RUNNING:线程正在某个 CPU 上运行
- RUNABLE:线程没有在运行,但一旦有 CPU 空闲就可以运行
- SLEEPING:线程在等待一些 IO 事件等,之后才能运行
这节课主要关注 RUNNING 和 RUNABLE 这两类线程,pre-emptive scheduling 实际上就是将一个 RUNNING 线程转换为一个 RUNABLE 线程。
注意在切换线程时,我们将 RUNNING 的线程的 CPU 状态拷贝到内存中保存下来,这里需要拷贝的信息就是 PC 和 register 值。在决定要运行一个线程时,就需要恢复这些 CPU 状态值。
# 3. XV6 的线程切换(一)
接下来将介绍 xv6 中线程切换时如何实现的。现在先从简单开始。
我们运行多个用户进程,例如 C Compiler(CC)、LS、Shell 等,每个进程都有自己的内存、user stack,并且在进程运行时在 CPU 上还有 PC、register 等。当程序因 system call 或 interrupt 走到了内核,那么用户空间状态会保存在 trapframe 中,同时这个用户程序的内核线程被激活,之后 CPU 在内核栈上运行,在内核处理完 system call 或者 insterrupt handler 后需要返回用户空间,trapframe 中的用户进程状态就会被恢复。
如果 xv6 内核决定从一个用户进程切换到另一个用户基础南横,那么首先在内核中,第一个进程的内核线程会被切换到第二个进程的内核线程,之后再在第二个进程的内核线程中返回到用户空间的第二个进程。这里的返回也是通过恢复 trapframe 中保存的用户进程状态来完成的。
当 xv6 从 CC 程序的内核线程切换到 LS 程序的内核线程时:
- xv6 将 CC 的内核线程的内核寄存器保存在一个 context 对象中
- xv6 恢复 LS 程序的内核线程的 context 对象,也就是恢复内核线程的寄存器等状态
- LS 的内核线程会继续运行在它的内核线程栈上,完成它的中断处理程序
- 恢复 LS 程序的 trapframe 中的用户进程状态,再返回到用户空间的 LS 程序中
- 最后恢复 LS 程序的执行
这里的核心点在于,xv6 中任何时候都需要经历:
- 从一个用户进程切换到另一个用户进程,都需要从第一个用户进程接入到内核中,保存用户进程的状态并运行第一个用户进程的内核线程。
- 再从第一个用户进程的内核线程切换到第二个用户进程的内核线程。
- 之后,第二个用户进程的内核线程暂停自己,并恢复第二个用户进程的用户寄存器。
- 最后返回到第二个用户进程继续执行。
如此曲折的一个线路。
# 4. XV6 的线程切换(二)
实际的线程切换流程会复杂地多。
假设我们有进程 P1 正在运行,进程 P2 是 RUNABLE 且并不在运行,并假设我们 xv6 在硬件上有 CPU 0 和 CPU 1 两个核。
从一个正在运行的用户空间进程切换到另一个 RUNABLE 但还没有运行的用户空间进程的更完整的故事是:
- 与之前一样,一个定时器中断强迫 CPU 从用户空间进程切换到内核,trampoline 代码将用户寄存器保存于用户进程对应的 trapframe 对象中;
- 之后在内核中运行 usertrap 来执行对应的中断处理程序,这时 CPU 正在进程 P1 的内核线程和内核栈上,执行内核中普通的 C 代码;
- 假设 P1 对应的内核线程决定它想出让 CPU,它会通过一些工作调用到 swtch 函数(为了与 C 语言关键 switch 区分名称),这是整个线程切换的核心函数之一
- swtch 函数会保存用户进程 P1 对应内核线程的寄存器到 context 对象中。所以目前为止有两类寄存器:用户寄存器存在 trapframe 中,内核线程的寄存器存在 context 中。
但实际上 swtch 函数并不直接从一个内核线程切换到另一个内核线程。xv6 中一个 CPU 上运行的内核线程可以直接切换到的是这个 CPU 对应的调度器线程。所以如果我们运行在 CPU0,那 swtch 函数会恢复之前为 CPU0 的调度器线程保存的寄存器和 stack pointer,之后就在调度器线程的 context 下执行 schedulder 函数。
在schedulder函数中会做一些清理工作,例如将进程P1设置成RUNABLE状态。之后再通过进程表单找到下一个RUNABLE进程。假设找到的下一个进程是P2(虽然也有可能找到的还是P1),schedulder函数会再次调用swtch函数,完成下面步骤:
- 先保存自己的寄存器到调度器线程的 context 对象
- 找到进程 P2 之前保存的 context,恢复其中的寄存器
- 因为进程P2在进入RUNABLE状态之前,如刚刚介绍的进程P1一样,必然也调用了 swtch 函数。所以之前的 swtch 函数会被恢复,并返回到进程P2所在的系统调用或者中断处理程序中(注,因为P2进程之前调用swtch函数必然在系统调用或者中断处理程序中)。
- 不论是系统调用也好中断处理程序也好,在从用户空间进入到内核空间时会保存用户寄存器到 trapframe 对象。所以当内核程序执行完成之后,trapframe 中的用户寄存器会被恢复。
- 最后用户进程P2就恢复运行了。
每一个 CPU 都有一个完全不同的调度器线程。调度器线程也是一种内核线程,它也有自己的 context 对象。任何运行在 CPU1 上的进程,当它决定出让 CPU,它都会切换到 CPU1 对应的调度器线程,并由调度器线程切换到下一个进程。
Question:context 保存在哪? Answer:每一个内核线程都有一个context对象。但是内核线程实际上有两类。每一个用户进程有一个对应的内核线程,它的context对象保存在用户进程对应的proc结构体中。每一个调度器线程,它也有自己的context对象,但是它却没有对应的进程和proc结构体,所以调度器线程的context对象保存在cpu结构体中。在内核中,有一个cpu结构体的数组,每个cpu结构体对应一个CPU核,每个结构体中都有一个context字段。
Question:每一个 CPU 的调度器线程有自己的栈吗? Answer:是的,每一个调度器线程都有自己独立的栈。实际上调度器线程的所有内容,包括栈和context,与用户进程不一样,都是在系统启动时就设置好了。如果你查看 XV6 的 entry.S 和 start.c 文件,你就可以看到为每个 CPU 核设置好调度器线程。
这里有一个术语:context switching,在本节课主要指一个内核线程和调度器线程之间的切换。在其他场合下,根据上下文,可能指两个用户进程的切换、两个内核进程的切换等等。
这里有一个关键信息需要记住:每个 CPU 核在一个时间只会运行一个线程,它要么是运行用户进程的线程,要么是运行内核线程,要么是运行这个 CPU 核对应的调度器线程。类似的每一个线程要么是只运行在一个 CPU 核上,要么它的状态被保存在 context 中。线程永远不会运行在多个 CPU 核上。
在 XV6 的代码中,context 对象总是由 swtch 函数产生,所以 context 总是保存了内核线程在执行 swtch 函数时的状态。当我们在恢复一个内核线程时,对于刚恢复的线程所做的第一件事情就是从之前的 swtch 函数中返回(注,有点抽象,后面有代码分析)。
Question:我们这里一直在说线程,但是从我看来 XV6 的实现中,一个进程就只有一个线程,有没有可能一个进程有多个线程? Prof:我们这里的用词的确有点让人混淆。在XV6中,一个进程要么在用户空间执行指令,要么是在内核空间执行指令,要么它的状态被保存在 context 和 trapframe 中,并且没有执行任何指令。这里该怎么称呼它呢?你可以根据自己的喜好来称呼它,对于我来说,每个进程有两个线程,一个用户空间线程,一个内核空间线程,并且存在限制使得一个进程要么运行在用户空间线程,要么为了执行系统调用或者响应中断而运行在内核空间线程 ,但是永远也不会两者同时运行。
Question:怎么区分不同进程的内核线程? Prof:每一个进程都有一个独立的内核线程。实际上有两件事情可以区分不同进程的内核线程,其中一件是,每个进程都有不同的内核栈,它由proc结构体中的kstack字段所指向;另一件就是,任何内核代码都可以通过调用myproc函数来获取当前CPU正在运行的进程。内核线程可以通过调用这个函数知道自己属于哪个用户进程。myproc函数会使用tp寄存器来获取当前的CPU核的ID,并使用这个ID在一个保存了所有CPU上运行的进程的结构体数组中,找到对应的proc结构体。这就是不同的内核线程区分自己的方法。
# 5. 进程切换示例程序
# 5.1 proc 结构体
在展示示例程序前,先看一下 proc.h 中 proc 结构体中与进程切换有关的内容:
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; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // 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)
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- trapframe 字段保存了用户空间线程的寄存器
- context 保存了内核线程的寄存器
- kstack 保存了当前进程的内核栈,这是进程中在内核中执行时保存函数调用的位置
- state 字段保存了当前进程的状态,要么是 RUNNING,要么是 RUNABLE,要么是 SLEEPING
- lock 字段保护了很多数据,在这里主要保护了 state 字段的更新,防止多个 CPU 调度器线程对 proc 状态的并发访问
# 5.2 切换进程的示例程序
下面这个演示程序,会在程序中从一个进程切换到另一个进程:
这个程序中会创建两个进程,两个进程会一直运行。代码首先通过fork创建了一个子进程,然后两个进程都会进入一个死循环,并每隔一段时间生成一个输出表明程序还在运行。但是它们都不会很频繁的打印输出(注,每隔1000000次循环才打印一个输出),并且它们也不会主动出让CPU(注,因为每个进程都执行的是没有sleep的死循环)。所以我们这里有了两个运算密集型进程,并且因为我们接下来启动的XV6只有一个CPU核,它们都运行在同一个CPU上。为了让这两个进程都能运行,有必要让两个进程之间能相互切换。
接下来运行这个 spin 程序:
可以看到两种字符在交替输出,由于 xv6 只有一个 CPU 核,所以是每个字符输出一会,然后切换到另一种。所以在这里我们可以看到定时器中断在起作用。
# 5.3 开始调试
在内部实现中,usertrap
函数(trap.c)会通过调用 devintr()
来识别出这是一个定时器中断,如果是定时器中断,devintr 会返回 2,我们看一下 usertrap 如果处理的调用:
usertrap 函数通过识别 devintr 函数返回 2,进入到 yield()
函数,在 yield 函数中,当前进程就会让出 CPU 并让下一个进程运行。在进入 yield 之前,我们先打印以下当前 proc 结构体的内容:
可以看到,当前进程是 spin 程序,pid 是 3,目前与预期一样,当进程切换后,预期进程的 pid 会不一样。
我们可以看一下 p->trapframe->epc
的值,这是在定时器中断触发时,用户进程正在执行的指令:
对应 spin.asm 文件可以看到,这个地址的指令正是在死循环中:
这与预期的一致。
# 6. 进程切换 - yield 和 sched 函数
前面说过,usertrap 通过 devintr 识别出这是一个定时器中断,会进入到 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);
}
2
3
4
5
6
7
8
9
10
yield 函数只做了几件事情:获取进程的 lock,以防在 lock 释放之前进程的状态会变得不一致。将进程状态变为 RUNNABLE,但其实这个进程还在当前进程的内核线程中运行着,这也就是为什么需要加锁。现在,进程需要让出 CPU,并切换到调度器进程,当前的 RUNNABLE 状态表示这个进程在之后还可以再继续运行。
于是 yield 函数调用了 sched 函数(proc.c),sched 函数如下:
// 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()->context);
mycpu()->intena = intena;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
可以看出,sched 函数也基本没干什么事,只是做了一些合理性检查,如果发现异常就 panic 从而避免之后可能带来的 bug。我们直接跳过所有的检查,来到位于底部的 swtch 函数。
# 7. 进程切换 - switch 函数
在 sched 函数中是这样调用 switch 函数的:
swtch(&p->context, &c->context);
这里设计两个 context:
- swtch 将当前内核线程的寄存器保存到
p->context
中 - swtch 将
c->context
中保存的当前 CPU 核的调度器线程的寄存器恢复到当前 CPU 核中
这么一切换 context,我们现在的 CPU 中的状态就变成了 c->context
中所保存的状态,我们看一下这个状态是什么样的:
这里面就是之前保存的“当前 CPU 核的调度器线程的寄存器值”。在这些寄存器中,最有趣的是 ra 寄存器(Return Address register),它的值是当前函数的返回地址,所以在接下来的函数返回中,会将代码的执行返回到 ra 寄存器所指示的地址,我们看一下 ra 所指示的地址的指令是什么:
从输出中看到,ra 寄存器中的地址是 scheduler 函数的指令地址,所以 swtch 函数在执行完毕并进行函数返回后,会返回到 scheduler 函数。
我们看一下 swtch 函数的内容(swtch.s):
.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
可以看到这个函数的实现分成了两个部分:上半部分保存当前寄存器内容到 context 对象中,下半部分将调度器线程的寄存器值恢复到当前 CPU 中。最后调用 ret
指令,从而返回到 ra 寄存器所指示的地址,也就是 scheduler 函数。
两个有趣的点:
- 这里并没有保存 PC 的值。因为 PC 的值没有意义了,我们之前想恢复的时候,是想恢复到调用 swtch 处并返回的那个点,而那个点的地址也就是当前寄存器 ra 的值,即在 sched 函数中调用 swtch 函数时所认为的函数返回地址,所以我们在之后恢复
p->context
时,只要把 ra 寄存器恢复了,那程序在函数返回时就会回到正确的位置(即 sched 函数调用 swtch 的地址),这是 PC 的值会随着函数调用而更新。 - RISC-V 有 32 个寄存器,这里只保存并恢复了 14 个。因为 swtch 函数是一个普通 C 函数,调用者在调用 swtch 时已经将 caller saved register 保存到了栈上,所以 swtch 函数只需要保存 callee save register。
在我们恢复了 c->context
并 ret
之前,看一下 sp 寄存器(Stack Pointer)的值:
可以看到 sp 指向内存中 stack0 区域,这个区域实际上是在启动顺序中非常非常早的一个位置,start.s 在这个区域创建了栈,这样才可以调用第一个 C 函数。所以调度器线程运行在 CPU 对应的 bootstack 上。
再看一下 ra 寄存器:
现在指向了 scheduler 函数,因为我们恢复了调度器线程的 context 对象中的内容。
现在,我们已经在调度器线程中所调用的 swtch 函数中了,接下来我们通过 ret
指令,就真的返回到调度器线程中了。
# 7. 进程切换 - 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 nproc = 0;
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);
}
if(nproc <= 2) { // only init and sh exist
intr_on();
asm volatile("wfi");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
现在从 swtch 返回到了运行在 CPU 所拥有的调度器线程中。注意,虽然现在是从 swtch 返回的,但并不是返回的之前那个 pid = 3 的 spin 进程的 swtch,那个 spin 进程的 swtch 还没有返回,而是被保存在 pid 为 3 的栈和 context 对象中,现在返回的是之前调度器线程对于 swtch 函数的调用。
在scheduler函数中,因为我们已经停止了spin进程的运行,所以我们需要抹去对于spin进程的记录。我们接下来将c->proc设置为0(c->proc = 0;)。因为我们现在并没有在这个CPU核上运行这个进程,为了不让任何人感到困惑,我们这里将CPU核运行的进程对象设置为0。
之前在 yield 函数中获取了进程的锁,因为 yield 不想进程完全进入到 RUNABLE 状态之前,任何其他的 CPU 核的调度器线程看到这个进程并运行它。而现在我们完成了从 spin 进程切换走,所以现在可以释放锁了。这就是 release(&p->lock) 的意义。现在,我们仍然在 scheduler 函数中,但是其他的 CPU 核可以找到 spin 进程,并且因为 spin 进程是 RUNABLE 状态,其他的 CPU 可以运行它。这没有问题,因为我们已经完整的保存了 spin 进程的寄存器,并且我们不在 spin 进程的栈上运行程序,而是在当前 CPU 核的调度器线程栈上运行程序,所以其他的 CPU 核运行 spin 程序并没有问题。但是因为启动 QEMU 时我们只指定了一个核,所以在我们现在的演示中并没有其他的CPU核来运行 spin 程序。
接下来将简单介绍一下 p->lock,从调用的角度来说,这里的 lock 完成了两件事情:
- 出让 CPU 涉及到很多步骤,lock 确保了这些步骤的原子性
- 切换过程需要关闭中断,避免定时器中断看到还在切换过程中的进程。
现在我们在 scheduler 函数的循环中,代码会检查所有的进程并找到一个来运行。现在我们知道还有另一个进程,因为我们之前 fork 了另一个 spin 进程。这里我跳过进程检查,直接在找到 RUNABLE 进程的位置设置一个断点:
- 在代码的 468 行,获取了进程的锁,所以现在我们可以进行切换到进程的各种步骤。
- 在代码的 473 行,进程的状态被设置成了 RUNNING。
- 代码的 474 行将找到的 RUNABLE 进程记录为当前 CPU 执行的进程。
- 代码的 475 行,又调用了 swtch 函数来保存调度器线程的寄存器,并恢复目标进程的寄存器(注,实际上恢复的是目标进程的内核线程)。我们可以打印新的进程的名字来查看新的进程:
可以看到进程名还是 spin,但是 pid 变成了 4,而之前我们看到的 pid 是 3。我们还可以看一下目标进程的 context 对象:
其中 ra 寄存器的内容就是我们要切换到的目标线程的代码位置。虽然我们在代码 475 行调用的是 swtch 函数,但是我们前面已经看过了 swtch 函数会返回到即将恢复的 ra 寄存器地址,所以我们真正关心的就是 ra 指向的地址:
查看这个地址的内容,可以看到这个 swtch 函数会返回到 sched 函数中。这完全在意料之中,因为可以预期的是,将要切换到的进程之前是被定时器中断通过 sched 函数挂起的,并且之前在 sched 函数中又调用了 swtch 函数。
在 swtch 函数的最开始,我们仍然在调度器线程中,但是这一次是从调度器线程切换到目标进程的内核线程。所以从 swtch 函数内部将会返回到目标进程的内核线程的 sched 函数。接着打印一下 backtrace(当前进程的栈上记录的各个函数掉用):
我们可以看到,之前有一个 usertrap 的调用,这必然是之前因为定时器中断而出现的调用。之后在中断处理函数中还调用了 yield 和 sched 函数,正如我们之前看到的一样。但是,这里调用 yield 和 sched 函数是在 pid 为 4 的进程调用的,而不是我们刚刚看的 pid 为 3 的进程。
这里有件事情需要注意,调度器线程调用了 swtch 函数,但是我们从 swtch 函数返回时,实际上是返回到了对于 swtch 的另一个调用,而不是调度器线程中的调用。我们返回到的是 pid 为 4 的进程在很久之前对于 swtch 的调用。这里可能会有点让人困惑,但是这就是线程切换的核心。
另一件需要注意的事情是,swtch 函数是线程切换的核心,但是 swtch 函数中只有保存寄存器,再加载寄存器的操作。线程除了寄存器以外的还有很多其他状态,它有变量,堆中的数据等等,但是所有的这些数据都在内存中,并且会保持不变。我们没有改变线程的任何栈或者堆数据。所以线程切换的过程中,处理器中的寄存器是唯一的不稳定状态,且需要保存并恢复。而所有其他在内存中的数据会保存在内存中不被改变,所以不用特意保存并恢复。我们只是保存并恢复了处理器中的寄存器,因为我们想在新的线程中也使用相同的一组寄存器。
Linux 是支持一个进程包含多个线程,Linux 的实现比较复杂,或许最简单的解释方式是:几乎可以认为 Linux 中的每个线程都是一个完整的进程。Linux 中,我们平常说一个进程中的多个线程,本质上是共享同一块内存的多个独立进程。所以 Linux 中一个进程的多个线程仍然是通过一个内存地址空间执行代码。如果你在一个进程创建了 2 个线程,那基本上是 2 个进程共享一个地址空间。之后,调度就与 XV6 是一致的,也就是针对每个进程进行调度。