本文为MIT 6.S081课程第七章教材内容翻译加整理。
本课程前置知识主要涉及:
线程可以认为是一种在有多个任务时简化编程的抽象。一个线程可以认为是串行执行代码的单元。如果你写了一个程序只是按顺序执行代码,那么你可以认为这个程序就是个单线程程序,这是对于线程的一种宽松的定义。虽然人们对于线程有很多不同的定义,在这里,我们认为线程就是单个串行执行代码的单元,它只占用一个CPU并且以普通的方式一个接一个的执行指令。
除此之外,线程还具有状态,我们可以随时保存线程的状态并暂停线程的运行,并在之后通过恢复状态来恢复线程的运行。线程的状态包含了三个部分:
操作系统中线程系统的工作就是管理多个线程的运行。我们可能会启动成百上千个线程,而线程系统的工作就是弄清楚如何管理这些线程并让它们都能运行。
多线程的并行运行主要有两个策略:
实际上,与大多数其他操作系统一样,XV6结合了这两种策略,首先线程会运行在所有可用的CPU核上,其次每个CPU核会在多个线程之间切换,因为通常来说,线程数会远远多于CPU的核数。
不同线程系统之间的一个主要的区别就是,线程之间是否会共享内存。一种可能是你有一个地址空间,多个线程都在这一个地址空间内运行,并且它们可以看到彼此的更新。比如说共享一个地址空间的线程修改了一个变量,共享地址空间的另一个线程可以看到变量的修改。所以当多个线程运行在一个共享地址空间时,我们需要用到上节课讲到的锁。
XV6内核共享了内存,并且XV6支持内核线程的概念,对于每个用户进程都有一个内核线程来执行来自用户进程的系统调用。所有的内核线程都共享了内核内存,所以XV6的内核线程的确会共享内存。
另一方面,XV6还有另外一种线程。每一个用户进程都有独立的内存地址空间,并且包含了一个线程,这个线程控制了用户进程代码指令的执行。所以XV6中的用户线程之间没有共享内存,你可以有多个用户进程,但是每个用户进程都是拥有一个线程的独立地址空间。XV6中的进程不会共享内存。
在一些其他更加复杂的系统中,例如Linux,允许在一个用户进程中包含多个线程,进程中的多个线程共享进程的地址空间。当你想要实现一个运行在多个CPU核上的用户进程时,你就可以在用户进程中创建多个线程。Linux中也用到了很多我们今天会介绍的技术,但是在Linux中跟踪每个进程的多个线程比XV6中每个进程只有一个线程要复杂的多。
还有一些其他的方式可以支持在一台计算机上交织的运行多个任务,我们不会讨论它们,但是如果你感兴趣的话,你可以去搜索event-driven programming或者state machine,这些是在一台计算机上不使用线程但又能运行多个任务的技术。在所有的支持多任务的方法中,线程技术并不是非常有效的方法,但是线程通常是最方便,对程序员最友好的,并且可以用来支持大量不同任务的方法。
实现内核中的线程系统存在以下挑战:
接下来,我将首先介绍如何处理运算密集型线程。这里的具体实现你们之前或许已经知道了,就是利用定时器中断。在每个CPU核上,都存在一个硬件设备,它会定时产生中断。XV6与其他所有的操作系统一样,将这个中断传输到了内核中。所以即使我们正在用户空间计算π的前100万位,定时器中断仍然能在例如每隔10ms的某个时间触发,并将程序运行的控制权从用户空间代码切换到内核中的中断处理程序(注,因为中断处理程序优先级更高)
。哪怕这些用户空间进程并不配合工作(注,也就是用户空间进程一直占用CPU)
,内核也可以从用户空间进程获取CPU控制权。
位于内核的定时器中断处理程序,会自愿的将CPU出让(yield)给线程调度器,并告诉线程调度器说,你可以让一些其他的线程运行了。这里的出让其实也是一种线程切换,它会保存当前线程的状态,并在稍后恢复。
在之前的课程中,你们已经了解过了中断处理的流程。这里的基本流程是,定时器中断将CPU控制权给到内核,内核再自愿的出让CPU。
这样的处理流程被称为pre-emptive scheduling(抢占式调度)
:
pre-emptive
的意思是,即使用户代码本身没有出让CPU,定时器中断仍然会将CPU的控制权拿走,并出让给线程调度器。与之相反的是voluntary scheduling(协作式调度)
。有趣的是,在XV6和其他的操作系统中,线程调度是这么实现的:
pre-emptive scheduling
,之后内核会代表用户进程(注,实际是内核中用户进程对应的内核线程会代表用户进程出让CPU)
,使用voluntary scheduling
。在执行线程调度的时候,操作系统需要能区分几类线程:
这里不同的线程是由状态区分,但是实际上线程的完整状态会要复杂的多(注,线程的完整状态包含了程序计数器,寄存器,栈等等)
。下面是我们将会看到的一些线程状态:
今天这节课,我们主要关注RUNNING和RUNABLE
这两类线程。前面介绍的定时器中断或者说pre-emptive scheduling
,实际上就是将一个RUNNING
线程转换成一个RUNABLE
线程。通过出让CPU
,pre-emptive scheduling
将一个正在运行的线程转换成了一个当前不在运行但随时可以再运行的线程。因为当定时器中断触发时,这个线程还在好好的运行着。
对于RUNNING
状态下的线程,它的程序计数器和寄存器位于正在运行它的CPU硬件中。而RUNABLE
线程,因为并没有CPU与之关联,所以对于每一个RUNABLE
线程,当我们将它从RUNNING
转变成RUNABLE
时,我们需要将它还在RUNNING
时位于CPU
的状态拷贝到内存中的某个位置,注意这里不是从内存中的某处进行拷贝,而是从CPU中的寄存器拷贝。我们需要拷贝的信息就是程序计数器(Program Counter)和寄存器。
当线程调度器决定要运行一个RUNABLE线程时,这里涉及了很多步骤,但是其中一步是将之前保存的程序计数器和寄存器拷贝回调度器对应的CPU中。
我们或许会运行多个用户空间进程,例如C compiler(CC),LS,Shell,它们或许会,也或许不会想要同时运行。在用户空间,每个进程有自己的内存,对于我们这节课来说,我们更关心的是每个进程都包含了一个用户程序栈(user stack),并且当进程运行的时候,它在RISC-V处理器中会有程序计数器和寄存器。
当用户程序在运行时,实际上是用户进程中的一个用户线程在运行。如果程序执行了一个系统调用或者因为响应中断走到了内核中,那么相应的用户空间状态会被保存在程序的trapframe中(注,详见lec06),同时属于这个用户程序的内核线程被激活。
所以首先,用户的程序计数器,寄存器等等被保存到了trapframe中,之后CPU被切换到内核栈上运行,实际上会走到trampoline和usertrap代码中(注,详见lec06)。之后内核会运行一段时间处理系统调用或者执行中断处理程序。在处理完成之后,如果需要返回到用户空间,trapframe中保存的用户进程状态会被恢复。
除了系统调用,用户进程也有可能是因为CPU需要响应类似于定时器中断走到了内核空间。上一节提到的pre-emptive scheduling,会通过定时器中断将CPU运行切换到另一个用户进程。在定时器中断程序中,如果XV6内核决定从一个用户进程切换到另一个用户进程,那么首先在内核中第一个进程的内核线程会被切换到第二个进程的内核线程。之后再在第二个进程的内核线程中返回到用户空间的第二个进程,这里返回也是通过恢复trapframe中保存的用户进程状态完成
。
当XV6从CC程序的内核线程切换到LS程序的内核线程时:
这里核心点在于,在XV6中,任何时候都需要经历:
这么曲折的一个线路。
假设我们有进程P1正在运行,进程P2是RUNABLE当前并不在运行。假设在XV6中我们有2个CPU核,这意味着在硬件层面我们有CPU0和CPU1。
我们从一个正在运行的用户空间进程切换到另一个RUNABLE但是还没有运行的用户空间进程的更完整的故事是:
但是,实际上swtch函数并不是直接从一个内核线程切换到另一个内核线程。XV6中,一个CPU上运行的内核线程可以直接切换到的是这个CPU对应的调度器线程。所以如果我们运行在CPU0,swtch函数会恢复之前为CPU0的调度器线程保存的寄存器和stack pointer,之后就在调度器线程的context下执行schedulder函数中(注,后面代码分析有介绍)。
在schedulder函数中会做一些清理工作,例如将进程P1设置成RUNABLE状态。之后再通过进程表单找到下一个RUNABLE进程。假设找到的下一个进程是P2(虽然也有可能找到的还是P1),schedulder函数会再次调用swtch函数,完成下面步骤:
每一个CPU都有一个完全不同的调度器线程。调度器线程也是一种内核线程,它也有自己的context对象。任何运行在CPU1上的进程,当它决定出让CPU,它都会切换到CPU1对应的调度器线程,并由调度器线程切换到下一个进程。
context保存在哪?
为什么不能将context对象保存在进程对应的trapframe中?
出让CPU是由用户发起的还是由内核发起的?
用户进程调用sleep函数是不是会调用某个系统调用,然后将用户进程的信息保存在trapframe,然后触发进程切换,这时就不是定时器中断决定,而是用户进程自己决定了吧?
每一个CPU的调度器线程有自己的栈吗?
这里有一个术语需要解释一下。当人们在说context switching,他们通常说的是从一个线程切换到另一个线程,因为在切换的过程中需要先保存前一个线程的寄存器,然后再恢复之前保存的后一个线程的寄存器,这些寄存器都是保存在context对象中。在有些时候,context switching也指从一个用户进程切换到另一个用户进程的完整过程。偶尔你也会看到context switching是指从用户空间和内核空间之间的切换。对于我们这节课来说,context switching主要是指一个内核线程和调度器线程之间的切换。
这里有一些有用的信息可以记住。每一个CPU核在一个时间只会做一件事情,每个CPU核在一个时间只会运行一个线程,它要么是运行用户进程的线程,要么是运行内核线程,要么是运行这个CPU核对应的调度器线程。所以在任何一个时间点,CPU核并没有做多件事情,而是只做一件事情。线程的切换创造了多个线程同时运行在一个CPU上的假象。类似的每一个线程要么是只运行在一个CPU核上,要么它的状态被保存在context中。线程永远不会运行在多个CPU核上,线程要么运行在一个CPU核上,要么就没有运行。
在XV6的代码中,context对象总是由swtch函数产生,所以context总是保存了内核线程在执行swtch函数时的状态。当我们在恢复一个内核线程时,对于刚恢复的线程所做的第一件事情就是从之前的swtch函数中返回。
我们这里一直在说线程,但是从我看来XV6的实现中,一个进程就只有一个线程,有没有可能一个进程有多个线程?
我们先来看一下proc.h中的proc结构体,从结构体中我们可以看到很多之前介绍的内容:
enum procstate { UNUSED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
// 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 如果非0,表示当前进程睡眠在某个条件变量上
int killed; // If non-zero, have been killed 如果非0,表示当前进程被杀死了
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) sbrk指针目前位置,进程已用的内存范围: 0~sz
pagetable_t pagetable; // User page table 进程用户态页表地址
struct trapframe *trapframe; // data page for trampoline.S trapframe地址
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) 程序名字
};
我接下来会运行一个简单的演示程序,在这个程序中我们会从一个进程切换到另一个:
#include "kernel/types.h"
#include "user/user.h"
int main(int argc, char *agrv[])
{
int pid;
char c;
pid = fork();
if (pid == 0)
{
c = '/';
}
else
{
printf("parent pid is %d,child is %d", getpid(), pid);
// 其中一个\用于转义
c = '\\';
}
for (int i = 0;; i++)
{
if ((i % 1000000) == 0)
write(2, &c, 1);
}
exit(0);
}
这个程序中会创建两个进程,两个进程会一直运行。代码首先通过fork创建了一个子进程,然后两个进程都会进入一个死循环,并每隔一段时间生成一个输出表明程序还在运行。但是它们都不会很频繁的打印输出(注,每隔1000000次循环才打印一个输出),并且它们也不会主动出让CPU(注,因为每个进程都执行的是没有sleep的死循环)。
所以我们这里有了两个运算密集型进程,并且因为我们接下来启动的XV6只有一个CPU核,它们都运行在同一个CPU上。为了让这两个进程都能运行,有必要让两个进程之间能相互切换。
接下来让运行spin程序:
你可以看到一直有字符在输出,一个进程在输出“/”,另一个进程在输出""。从输出看,虽然现在XV6只有一个CPU核,但是每隔一会,XV6就在两个进程之间切换。“/”输出了一会之后,定时器中断将CPU切换到另一个进程运行然后又输出“\”一会。所以在这里我们可以看到定时器中断在起作用。
接下来,我在trap.c的devintr函数中的212行设置一个断点,这一行会识别出当前是在响应定时器中断:
b trap.c:212
之后在gdb中continue。立刻会停在中断的位置,因为定时器中断还是挺频繁的。现在我们可以确认我们在usertrap函数中,并且usertrap函数通过调用devintr函数来处理这里的中断(注,从下图的栈输出可以看出):
因为devintr函数处理定时器中断的代码基本没有内容,接下来我在gdb中输入finish来从devintr函数中返回到usertrap函数。当我们返回到usertrap函数时,虽然我们刚刚从devintr函数中返回,但是我们期望运行到下面的yield函数。所以我们期望devintr函数返回2:
在yield函数中,当前进程会出让CPU并让另一个进程运行。这个我们稍后再看。现在让我们看一下当定时器中断发生的时候,用户空间进程正在执行什么内容。我在gdb中输入print p来打印名称为p的变量。变量p包含了当前进程的proc结构体:
怎么区分不同进程的内核栈?
我首先会打印p->name来获取进程的名称:
当前进程是spin程序,如预期一样。
当前的进程ID是4,进程切换之后,我们预期进程ID会不一样。
我们还可以通过打印变量p的trapframe字段获取表示用户空间状态的32个寄存器,这些都是我们在Lec06中学过的内容。这里面最有意思的可能是trapframe中保存的用户程序计数器:
我们可以查看spin.asm文件来确定对应地址的指令:
可以看到定时器中断触发时,用户进程正在执行死循环的加1,这符合我们的预期。
看起来所有的CPU核要能完成线程切换都需要有一个定时器中断,那如果硬件定时器出现故障了怎么办?
我的问题是,定时器中断是来自于某个硬件,如果硬件出现故障了呢?
当一个线程结束执行了,比如说在用户空间通过exit系统调用结束线程,同时也会关闭进程的内核线程。那么线程结束之后和下一个定时器中断之间这段时间,CPU仍然会被这个线程占有吗?还是说我们在结束线程的时候会启动一个新的线程?
回到devintr函数返回到usertrap函数中的位置。在gdb里面输入几次step走到yield函数的调用。yield函数是整个线程切换的第一步,下面是yield函数的内容:
yield函数只做了几件事情,它首先获取了进程的锁。实际上,在锁释放之前,进程的状态会变得不一致,例如,yield将要将进程的状态改为RUNABLE,表明进程并没有在运行,但是实际上这个进程还在运行,代码正在当前进程的内核线程中运行。
所以这里加锁的目的之一就是:
接下来yield函数中将进程的状态改为RUNABLE。这里的意思是,当前进程要出让CPU,并切换到调度器线程。当前进程的状态是RUNABLE意味着它还会再次运行,因为毕竟现在是一个定时器中断打断了当前正在运行的进程。
之后yield函数中调用了位于proc.c文件中的sched函数。我们进入到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.
// yield函数中已经获取了当前进程的锁
void
sched(void)
{
int intena;
struct proc *p = myproc();
// 当前cpu必须获取了当前进程锁
if(!holding(&p->lock))
panic("sched p->lock");
// 当前cpu只能持有一把锁
if(mycpu()->noff != 1)
panic("sched locks");
// 当前进程不能是正在运行状态
if(p->state == RUNNING)
panic("sched running");
// 中断要关闭
if(intr_get())
panic("sched interruptible");
// 保存cpu加锁前的中断状态
intena = mycpu()->intena;
// 跳转到scheduler函数进行任务调度--->scheduler函数由调度器线程执行
swtch(&p->context, &mycpu()->context);
// 恢复cpu加锁前的中断状态
mycpu()->intena = intena;
}
可以看出,sched函数基本没有干任何事情,只是做了一些合理性检查,如果发现异常就panic。为什么会有这么多检查?
swtch函数负责跳转到scheduler函数执行任务调度,由于scheduler函数由调度器线程执行,所以这里我们相当于进行了一次内核线程切换,因为调度器线程也是内核线程,所以是进行内核线程间的切换 --> 本质是涉及到内核栈的切换。
当前进程的内核线程到调度器线程必然需要经历上下文切换:
接下来,我们快速的看一下我们将要切换到的context(注,也就是调度器线程的context)
。因为我们只有一个CPU核,这里我在gdb中print cpus[0].context :
这里看到的就是之前保存的当前CPU核的调度器线程的寄存器。在这些寄存器中,最有趣的就是ra(Return Address)寄存器,因为ra寄存器保存的是当前函数的返回地址,所以调度器线程中的代码会返回到ra寄存器中的地址。通过查看kernel.asm,我们可以知道这个地址的内容是什么。也可以在gdb中输入“x/i cpus[0]->context->ra”进行查看。
输出中包含了地址中的指令和指令所在的函数名。所以我们将要返回到scheduler函数中。
这里大家可以思考一个问题: 当前CPU里面context的ra和sp寄存器初始化是在何处?
# 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
Swtch只保存被调用方保存的寄存器(callee-saved registers);调用方保存的寄存器(caller-saved registers)通过调用C代码保存在栈上(如果需要):
// Saved registers for kernel context switches.
struct context {
uint64 ra;
uint64 sp;
// callee-saved
uint64 s0;
uint64 s1;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
callee-saved registers:
caller-saved registers:
因为我们接下来要调用swtch函数,上面已经给出了swtch函数的源码,通过阅读源码可知:
另一个问题是,为什么RISC-V中有32个寄存器,但是swtch函数中只保存并恢复了14个寄存器?
这里遵循RISC-V函数调用约定,只保存callee-saved registers,但是大家可以思考为什么内核线程之间切换只需要保存callee-saved registers,而用户态到内核态之间的切换却需要保存所有通用寄存器状态呢?
最后我想看的是sp(Stack Pointer)寄存器:
从它的值很难看出它的意义是什么。它实际是当前进程的内核栈地址,它由虚拟内存系统映射在了一个高地址。
现在,我们保存了当前的寄存器,并从调度器线程的context对象恢复了寄存器,我直接跳到swtch函数的最后,也就是ret指令的位置:
在我们实际返回之前,我们再来打印一些有趣的寄存器。首先sp寄存器有了一个不同的值,
sp寄存器的值现在在内存中的stack0区域中。这个区域实际上是在启动顺序中非常非常早的一个位置,start.s在这个区域创建了栈,这样才可以调用第一个C函数。所以调度器线程运行在CPU对应的bootstack上。
其次是ra寄存器:
现在指向了scheduler函数,因为我们恢复了调度器线程的context对象中的内容。
现在,我们其实已经在调度器线程中了,这里寄存器的值与上次打印的已经完全不一样了。虽然我们还在swtch函数中,但是现在我们实际上位于调度器线程调用的swtch函数中。调度器线程在启动过程中调用的也是swtch函数。接下来通过执行ret指令,我们就可以返回到调度器线程中。
我不知道我们使用的RISC-V处理器是不是有一些其他的状态?但是我知道一些Intel的X86芯片有floating point unit state等其他的状态,我们需要处理这些状态吗?
为什么swtch函数要用汇编来实现,而不是C语言?
当前CPU里面context的ra和sp寄存器初始化是在何处?
每个hart启动到调用scheduler函数前,可以认为是运行在boot thread函数里面,当进入scheduler函数时,可以将boot thread看做是调度器线程。
来看一下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");
}
}
}
调度器线程通过Scheduler函数切换到Spin进程运行,然后Spin进程运行一段时间后,因为时钟中断发生,调用yield函数再次切换回调度器线程运行:
在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程序。
接下来我将简单介绍一下p->lock。从调度的角度来说,这里的锁完成了两件事情。
我们的Spin程序中调用fork函数创建了一个子进程,此时fork出来的子进程状态转换过程:
问题是fork出来的子进程的p->context内容何时被初始化的呢?
每个新创建出来的进程的ra值被设置为了forkret,该函数内部会调用usertrapret完成内核态到用户态的切换,并恢复trapframe中保存的用户态原先执行的状态:
// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void
forkret(void)
{
static int first = 1;
// Still holding p->lock from scheduler.
release(&myproc()->lock);
if (first) {
// File system initialization must be run in the context of a
// regular process (e.g., because it calls sleep), and thus cannot
// be run from main().
first = 0;
fsinit(ROOTDEV);
}
usertrapret();
}
在fortret函数中,if(first)是什么意思?
新进程trapframe中保存的用户态信息的初始化有以下三种场景:
因为fork拷贝的进程会同时拷贝父进程的程序计数器,所以我们唯一不是通过fork创建进程的场景就是创建第一个进程的时候。这时需要设置程序计数器为0。
如果不是因为定时器中断发生的切换,我们是不是可以期望ra寄存器指向其他位置,例如sleep函数?
是的,我们之前看到了代码执行到这里会包含一些系统调用相关的函数。你基本上回答了自己的问题,如果我们因为定时器中断之外的原因而停止了执行当前的进程,switch会返回到一些系统调用的代码中,而不是我们这里看到sched函数。我记得sleep最后也调用了sched函数,虽然bracktrace可能看起来会不一样,但是还是会包含sched。所以我这里只介绍了一种进程间切换的方法,也就是因为定时器中断而发生切换。但是还有其他的可能会触发进程切换,例如等待I/O或者等待另一个进程向pipe写数据。
操作系统都带了线程的实现,如果想要在多个CPU上运行一个进程内的多个线程,那需要通过操作系统来处理而不是用户空间代码,是吧?那这里的线程切换是怎么工作的?是每个线程都与进程一样了吗?操作系统还会遍历所有存在的线程吗?比如说我们有8个核,每个CPU核都会在多个进程的更多个线程之间切换。同时我们也不想只在一个CPU核上切换一个进程的多个线程,是吧?
用户可以指定将线程绑定在某个CPU上吗?操作系统如何确保一个进程的多个线程不会运行在同一个CPU核上?要不然就违背了多线程的初衷了。
所以说一个进程中的多个线程会有相同的page table?