skip to content

Search

调度

13 min read Updated:
四叶草与鹅卵石

XV6线程调度原理与机制

CPU栈与进程栈

在静态区中已经提前分配好了CPU栈(start.c)。最开始时,CPU跑在各自的CPU栈上。entry.S中设置了各CPU的sp指针指向对应的CPU栈

// entry.S needs one stack per CPU.
__attribute__((aligned(16))) char stack0[4096 * NCPU];

XV6为每个进程槽(共64个)分配了一个进程栈,每个进程栈之间用guard page隔开。guard page的PTE_V标记为0(没有映射),访问时表示栈溢出,会报错

进程栈供进程进入内核时内核代码使用,位于内核页表中,与用户页表的用户栈完全不同

内核进程栈的分配发生于main()函数中的kvminit()步骤,最终调用proc_mapstacks()函数为每个进程槽分配空间并映射到页表中

// Allocate a page for each process's kernel stack.
// Map it high in memory, followed by an invalid
// guard page.
void
proc_mapstacks(pagetable_t kpgtbl)
{
  struct proc *p;
 
  for (p = proc; p < &proc[NPROC]; p++) {
    char *pa = kalloc();
    if (pa == 0)
      panic("kalloc");
    uint64 va = KSTACK((int)(p - proc));  // 一次下降2页,但是只分配一页
    kvmmap(kpgtbl, va, (uint64)pa, PGSIZE, PTE_R | PTE_W);
  }
}

CPU栈与进程栈的关系在于,最开始CPU跑在CPU栈上,在scheduler()函数死循环中不断寻找可以运行的进程

当找到时,CPU执行swtch函数,将sp切换到对应的进程栈上面,执行相应进程内核代码

当在用户态程序中遇到中断或异常时,如果判断要执行线程切换,CPU会再次执行swtch函数,将sp切换回CPU栈上,接着走scheduler()函数

线程切换工具

context结构体

context结构体用于保存线程的上下文,本质上就是保存了callee-saved寄存器的值以及ra,sp寄存器

// kernel/proc.h
// Saved registers for kernel context switches.
struct context {
  uint64 ra;
  uint64 sp;
  // callee-saved
  uint64 s0, s1, ..., s11;
};

它被放置于两种地方,CPU自己的结构体,以及进程的结构体

  • 进程的 struct proc 里:struct context context; // swtch() here to run process
  • CPU 的 struct cpu 里:struct context context; // swtch() here to enter scheduler()

swtch

swtch函数是执行线程切换的核心,由于要操纵寄存器所以使用汇编直接编写

函数原型

void swtch(struct context *old, struct context *new);
.globl swtch
swtch:
        sd ra, 0(a0)      # old->ra  = ra
        sd sp, 8(a0)      # old->sp  = sp
        sd s0, 16(a0)
        ...
        sd s11, 104(a0)
 
        ld ra, 0(a1)      # ra = new->ra
        ld sp, 8(a1)      # sp = new->sp     ← 换栈!
        ld s0, 16(a1)
        ...
        ld s11, 104(a1)
 
        ret               # = jalr x0, 0(ra)

其做的工作,就是把当前寄存器组(也就是context结构体里面的成员)存到old里面,然后把new结构体中的字段加载到寄存器中

注意sp与ra的值也被设置为new结构体中的值,这样在ret指令执行时,就会跳转到new->ra指向的地址继续执行,并且使用new->sp作为栈指针。如此实现了栈切换与指令跳转

线程切换过程

线程切换常见的场景是定时器中断时,CPU从一个线程切换到另一个线程,下面以此为例子讲解

陷入内核

触发定时器中断,进入usertrap()函数,devintr()判断为时钟中断,which_dev设置为2,之后调用yield()函数

if (which_dev == 2)
    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,然后执行sched()函数,在sched()函数中真正发生线程的切换,做四项检查,然后 swtch:

void sched(void) {
  int intena;
  struct proc *p = myproc();
 
  if (!holding(&p->lock))  panic("sched p->lock");      // 必须持有 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;
}

其将旧的进程上下文保存到p->context里面,然后加载CPU结构体中提前保存的mycpu()->context

CPU结构体中保存的数据实际上会使得其ret的时候跳转到scheduler()里swtch的下一行,sp换成CPU栈

关于为什么mycpu()->context里面存了这些东西请看下文

scheduler

scheduler()函数是调度器的核心,死循环中反复执行下面的循环不断寻找RUNNABLE状态的进程,然后执行swtch切换到该进程

for (p = proc; p < &proc[NPROC]; p++) {
  acquire(&p->lock);
  if (p->state == RUNNABLE) {
    p->state = RUNNING;
    c->proc = p;
    swtch(&c->context, &p->context);   // 切到下一个进程
    mycpu()->intena = 0;
    c->proc = 0;
    found = 1;
  }
  release(&p->lock);                   // 注意这个释放
}

在这里的swtch函数中,旧的CPU上下文保存到mycpu()->context里面,里面ra的值实际为scheduler()函数中swtch的下一行,sp的值为CPU栈。因此下次进程再次调度时,sched函数中切换的mycpu()->context便是已经存好了的

注意到yield中获取的线程锁,scheduler中才释放。这样是使得进程切换的过程中,在临界区,也就是改变了proc->state为RUNNABLE但是还没有切换到调度器线程中时,其他CPU不会调度到该进程上来。否则两个CPU同时读写进程栈,造成混乱

下面是临界区保护的不变性

  1. 进程 RUNNING:寄存器在 CPU 里、c->proc 指向它;
  2. 进程 RUNNABLE:p->context 存着寄存器、没有 CPU 在它的栈上、没有 c->proc 指向它;

持 p->lock 时上述不变式常为假,锁把”中间态”藏起来

返回

当找到到RUNNABLE状态的进程后,执行swtch(&c->context, &p->context),CPU上下文切换到该进程的内核栈上,继续执行该进程的内核代码

之后与常规系统调用一致,正常返回到用户态程序中

prepare_return();
uint64 satp = MAKE_SATP(p->pagetable);
return satp;    // 交给 trampoline.S 的 userret,执行 sret 回用户态

对于之前挂起的进程而言,p->context在调度过程中在sched函数被保存

对于新进程,是allocproc() 手工伪造的:ra = forkret、sp = 栈顶。所以它第一次被调度,不是回到某个sched,而是”返回”到 forkret

void forkret(void) {
  struct proc *p = myproc();
 
  // Still holding p->lock from scheduler.
  release(&p->lock);        // ← forkret 存在的意义:释放调度器交接过来的锁
  ...
  prepare_return();         // 模仿 usertrap 的返回,回到用户态
  ...
  // 这里直接调用userret
}

forkret与一般进程返回不同,它直接调用trampoline.S中的userret函数返回

mycpu与myproc

mycpu

struct cpu {
  struct proc *proc;      // 本 CPU 当前运行的进程,或 null
  struct context context; // swtch() here to enter scheduler()
  int noff;               // push_off() 嵌套深度
  int intena;             // push_off 之前中断是否开着
};
extern struct cpu cpus[NCPU];   // NCPU = 8

各CPU依照hart_id索引到cpus数组中,由cpuid()函数返回当前CPU的索引。cpuid()函数通过读取tp寄存器的值来获取当前CPU的索引,tp寄存器在启动时被设置为CPU的索引值

int cpuid()
{
  int id = r_tp();
  return id;
}

mycpu()函数返回当前CPU的结构体指针

struct cpu *mycpu(void)
{
  int id = cpuid();
  struct cpu *c = &cpus[id];
  return c;
}

cpu结构体中的proc字段只在调度器scheduler()中被设置为当前运行的进程,其他时候为0

c->proc = 0;        // 进入循环前 / 进程让出后
...
c->proc = p;        // 选中进程、swtch 之前

myproc

cpu->proc的唯一的读者是myproc()函数,它返回CPU当前运行的进程指针。需要关闭中断的原因在于,若在 mycpu() 返回 c 之后、读取 c->proc 之前发生中断并触发调度,当前线程可能被迁到另一个 CPU,c 就指向了错误的 CPU,读到的 proc 也不对,所以必须关中断

struct proc *myproc(void) {
  push_off();
  struct cpu *c = mycpu();
  struct proc *p = c->proc;
  pop_off();
  return p;
}

tp

tp寄存器在启动时被设置为CPU的索引值。RISC-V 给每核一个mhartid,但它是机器模式 CSR,S模式读不了。于是 xv6 在启动早期把它抄进 tp(x4)

内核态中,tp寄存器一定为当前CPU的索引值,这是由以下保证的

  1. 返回用户态前, prepare_return() 函数中设置了trapframe->kernel_hartid为当前的tp值
  2. 用户态进入内核态时,将tp寄存器设置为trapframe->kernel_hartid
  3. 在返回用户态之后,进入内核态之前,用户态程序中trapframe->kernel_hartid的PTE_U标记为0,用户态程序无法修改该值。且进程会一直运行在同一个核上面,如果要切换核,必须走一遍完整的调度流程,避免不了进入内核态

由此,无论用户态程序如何修改tp寄存器的值,进入内核时tp的值已经被管理好且准确