xv6 实验日志 - Thread
在 xv6 中,我们认为线程就是单个串行执行代码的单元,它只占用一个CPU并且以普通的方式一个接一个的执行指令
线程具有状态,我们可以随时保存线程的状态并暂停线程的运行,并在之后通过恢复状态来恢复线程的运行。线程的状态包含了三个部分:PC,寄存器,栈
在 xv6 中,每个进程有两个线程,一个用户线程执行用户空间代码,一个内核线程处理系统调用之类的事,并且存在限制使得一个进程要么运行在用户空间线程,要么为了执行系统调用或者响应中断而运行在内核空间线程 ,但是永远也不会两者同时运行。除此之外,每个 CPU 都有一个调度器线程,这些调度器线程有自己独立的栈
Linux 允许在一个用户进程中包含多个线程,进程中的多个线程共享进程的地址空间
学生提问:操作系统都带了线程的实现,如果想要在多个CPU上运行一个进程内的多个线程,那需要通过操作系统来处理而不是用户空间代码,是吧?那这里的线程切换是怎么工作的?是每个线程都与进程一样了吗?操作系统还会遍历所有存在的线程吗?比如说我们有8个核,每个CPU核都会在多个进程的更多个线程之间切换。同时我们也不想只在一个CPU核上切换一个进程的多个线程,是吧?
Robert教授:Linux是支持一个进程包含多个线程,Linux的实现比较复杂,或许最简单的解释方式是:几乎可以认为Linux中的每个线程都是一个完整的进程。Linux中,我们平常说一个进程中的多个线程,本质上是共享同一块内存的多个独立进程。所以Linux中一个进程的多个线程仍然是通过一个内存地址空间执行代码。如果你在一个进程创建了2个线程,那基本上是2个进程共享一个地址空间。之后,调度就与XV6是一致的,也就是针对每个进程进行调度
在 xv6 和其他的操作系统中,线程调度是这么实现的:定时器中断会强制的将 CPU 控制权从用户进程给到内核,这里是 pre-emptive scheduling(先发制人,或防御性调度),之后内核会代表用户进程(注,实际是内核中用户进程对应的内核线程会代表用户进程出让CPU),使用 voluntary scheduling
在执行线程调度的时候,操作系统需要能区分几类线程:
RUNNING:当前在 CPU 上运行的线程
RUNABLE: 一旦CPU有空闲时间就想要运行在CPU上的线程
SLEEPING: 以及不想运行在CPU上的线程,因为这些线程可能在等待I/O或者其他事件
对于 xv6 来说,并不会直接让用户线程出让CPU或者完成线程切换,而是由内核在合适的时间点做决定。内核会在两个场景下出让 CPU:一是定时器中断触发了,二是任何时候一个进程调用了系统调用并等待I/O
当人们在说 context switching(上下文切换)时,可以指线程切换也可以指进程切换,还可以是用户与内核的切换。这里的 context switching 主要是指一个内核线程和调度器线程之间的切换
每一个CPU核在一个时间只会做一件事情,每个CPU核在一个时间只会运行一个线程,它要么是运行用户进程的线程,要么是运行内核线程,要么是运行这个CPU核对应的调度器线程。所以在任何一个时间点,CPU核并没有做多件事情,而是只做一件事情。线程的切换创造了多个线程同时运行在一个CPU上的假象。类似的每一个线程要么是只运行在一个CPU核上,要么它的状态被保存在context中。线程永远不会运行在多个CPU核上,线程要么运行在一个CPU核上,要么就没有运行
xv6 线程调度
用户进程切换流程
从一个用户进程切换到另一个用户进程,都需要从第一个用户进程接入到内核中,保存用户进程的状太并运行第一个用户进程的内核线程
再从第一个用户进程的内核线程切换到 CPU 的调度线程
调度线程选择好了之后会进入第二个用户进程的内核线程
之后,第二个用户进程的内核线程暂停自己,并恢复第二个用户进程的用户寄存器
最后返回到第二个用户进程继续执行
状态保存位置
用户进程状态保存在 trapframe,内核线程状态保存在 proc 的 context,调度线程的状态保存在 cpu 的 context
现在,我们可以重新看看这几个熟悉的结构体了:
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // Depth of push_off() nesting.
int intena; // Were interrupts enabled before push_off()?
};
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
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
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// 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)
};
源代码
在 devintr 中,检测到时钟中断会返回 2,值传给了 which_dev。在 usertrap 里检测到 which_dev == 2 会调用 yield()
void
yield(void)
{
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE;
sched();
release(&p->lock);
}
yield 首先获取了进程的锁。这是为了将进程的 state 修改为 RUNABLE 时不被其他的 CPU 调度
然后进入 sched()。sched() 做了一堆合理性检查,然后走到 swtch(switch)
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 在 swtch.S 中定义,a0 是内核线程 context 的地址,a1 则是调度器线程的。这个函数保存了内核线程状态,然后切换到了调度器线程
.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 寄存器记录这 swtch 的返回地址,因此没有必要保存 pc。在 context 中我们只保存了 14 个 Callee Saved Register,因为 swtch 的调用者应默认除此之外的寄存器会被修改
内核线程的 ra 显然指向 sched() 内的下一条指令,但是调度器线程的 ra 是在 scheduler() 的某处。于是在 ret 之后进入到 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();
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);
}
}
}
此时的 pc 应该在 scheduler() 的 swtch 的下方。但是,我们刚刚返回的 swtch 和这里的 swtch 不是一个 swtch。它们所在的线程不同,而且传参的顺序不一样。这里的 swtch 显然是从调度器线程切换到新的内核线程
切换到调度器线程后,CPU 自然就没有在运行用户进程了,所以我们将 c->proc 设置为 0
之前 yield 为了防止线程切换被其他的调度器线程打断而申请了锁,现在我们完成了这一步骤,可以释放锁了
在调度的过程中锁完成了两件事:
首先,出让 CPU 涉及到很多步骤,我们需要将进程的状态从 RUNNING 改成 RUNABLE,我们需要将进程的寄存器保存在 context 对象中,并且我们还需要停止使用当前进程的栈。在这三个步骤完成之前,锁阻止任何一个其他核的调度器线程看到当前进程,确保了三个步骤的原子性。从CPU核的角度来说,三个步骤要么全发生,要么全不发生
当我们开始要运行一个进程时,p->lock 也有类似的保护功能。我们希望启动一个进程的过程也具有原子性,这也是为什么 scheduler 加锁
好,接下来代码会在一个死循环内检查所有的进程并找到一个来运行。找到之后,通过 swtch 来到这个新的内核线程的 sched(),返回 yield(),usertrap(),usertrapret(),在 userret 返回到对应的新用户进程
关于第一个 swtch 调用,请看这里