线程切换(1)

xv6中的线程

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

  1. 内核线程的概念,对于每个用户进程都有一个内核线程来执行来自用户进程的系统调用。所有的内核线程都共享了内核内存,所以XV6的内核线程的确会共享内存。
  2. 每一个用户进程都有独立的内存地址空间,并且包含了一个线程,这个线程控制了用户进程代码指令的执行。所以XV6中的用户线程之间没有共享内存,你可以有多个用户进程,但是每个用户进程都是拥有一个线程的独立地址空间。XV6中的进程不会共享内存。

    在一些其他更加复杂的系统中,例如Linux,允许在一个用户进程中包含多个线程,进程中的多个线程共享进程的地址空间


xv6线程调度

每个CPU核都创建了一个线程调度器
对于运算密集型线程,线程调度可以利用定时器中断。定时器中断处理程序会自愿的将CPU让出(yield)给线程调度器,让其他线程运行。
线程状态:

  1. RUNNING,线程当前正在某个CPU上运行
  2. RUNABLE,线程还没有在某个CPU上运行,但是一旦有空闲的CPU就可以运行
  3. SLEEPING,这个状态意味着线程在等待一些I/O事件,它只会在I/O事件发生了之后运行

xv6线程切换

用户程序在运行时,实际上是用户进程中的一个用户线程在运行。如果程序执行了一个系统调用或者因为响应中断走到了内核中,那么相应的用户空间状态会被保存在程序的trapframe中,同时属于这个用户程序的内核线程被激活。
如果XV6内核决定从一个用户进程切换到另一个用户进程,那么首先在内核中第一个进程的内核线程会被切换到第二个进程的内核线程。之后再在第二个进程的内核线程中返回到用户空间的第二个进程,这里返回到用户空间也是通过恢复trapframe完成的。

完整过程(以时钟切换为例):

  1. 定时器中断强迫CPU从用户空间切换到内核,用户空间的代码保存在trapframe中。
  2. 内核运行usertrap,这时候运行的是进程1的内核线程。
  3. 调用swtch函数,保存进程1的内核线程寄存器到context对象,在proc结构体中有一个context。(用户寄存器在trapframe,内核线程寄存器在context)。
  4. swtch函数恢复原来在这个CPU上的调度器线程保存的寄存器和stack pointer,swtch函数返回后,CPU寄存器被设置为调度器线程的上下文,然后就在调度下线程的context下执行scheduler函数。
  5. scheduler函数把P1设置成RUNABLE状态,查找下一个RUNABLE进程,再次调用swtch函数, swtch函数完成后,返回到了新线程的上下文中。

    整个xv6中一个CPU核对应一个内核调度线程,它在系统启动时创建。这个内核调度线程是一个死循环,它不断地选择可运行的进程并进行上下文切换,以实现多进程并发。XV6的start.s文件,可以看到为每个CPU核设置好调度器线程。


代码讲解

yield函数

yield是线程切换的第一步。当触发时钟中断时,会调用yield函数,当前进程会出让CPU并让另一个进程运行。

1
2
3
4
5
6
7
void yield(void){
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE;
sched();
release(&p->lock);
}

主要内容是加锁,防止一个线程在多个CPU核上被调度。将进程的状态改为RUNABLE,表示让出CPU,随后执行sched函数。

sched函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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函数

1
2
3
4
5
6
7
8
.globl swtch
swtch:
sd ra, 0(a0)
....省略

ld ra, 0(a1)
....省略
ret

a0是p->context的地址,a1是cpu中的context结构体地址

  1. 会将当前的内核线程的寄存器(ra,sp,s0等)保存到p->context中,proc结构体中的context字段就是用来保存该进程内核线程寄存器的。
  2. 然后会把cpu中的context赋值给(ra,sp,s0)等寄存器

    CPU结构体中的context保存了当前CPU核的调度器线程的寄存器!!!
    内核调度线程没有进程与之对应,并且一个CPU核只有一个内核调度线程,所以把它所需要的context直接放到了cpu的context结构体中保存。而cpu中context结构体中有一个返回地址一定就是scheduler函数的某一条指令。

  • swtch函数中只保存并恢复了14个寄存器

    switch是按照一个普通函数来调用的,对于有些寄存器,swtch函数的调用者默认swtch函数会做修改,所以调用者已经在自己的栈上保存了这些寄存器,当函数返回时,这些寄存器会自动恢复。所以swtch 函数里只需要保存Callee Saved Register就行。

  • 返回地址的妙用

    正因为switch是按照一个普通函数来调用的,在这里,所以ra寄存器存储的是内核线程1的返回地址,随后ld ra, 0(a1)指令,把返回地址给换了,换成了CPU处理器调度器线程的返回地址,以及后面的一些上下文。随后swtch函数完成后,返回到了scheduler函数。

scheduler函数

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

调度器线程在scheduler函数中也会调用了swtch函数,同样的,它把CPU上寄存器换成了新线程的,并把调度器线程的寄存器保存到了mycpu()->context中。所以我们从swtch函数返回时,如果不考虑调度器线程在里面的作用,实际上是返回到了对于switch的另一个调用,而不是调度器线程中的调用。我们返回到的是调度到的新进程在很久之前对于switch的调用。这就是线程切换的核心。

在课程的示例中,P1线程由于定时器的中断而被调度,执行yield函数,sched函数,swtch函数后而被阻塞。假设P2线程正在执行,随后P2线程也经过同样的过程再执行调度线程,再执行P1,P1被唤醒的刚开始执行指令的地址就是sched函数中的swtch的返回地址。
如果不是因为定时器中断发生的切换,ra寄存器可能指向其他位置。

scheduler函数又会调用swtch函数,但参数稍有不同

1
2
3
p->state = RUNNING;
c->proc = p;
swtch(&c->context, &p->context);

这样就把当前调度器线程的寄存器传入cpu的context结构体中。再把寄存器的值换成调度的新进程的内核线程的context。


线程切换(1)
http://example.com/2024/02/29/操作系统/xv6-labs/线程切换1/
作者
LiuZhaocheng
发布于
2024年2月29日
许可协议