xv6是支持多处理器多进程的操作系统,每个CPU都能够并行地运行不同的进程,同一个CPU也能通过不断地切换进程达到并发的效果,xv6在时钟中断机制下,在很短的时间内完成进程切换操作使得宏观上感觉在同一个CPU下能出现“并行”的效果。
进程上下文
xv6在进程调度中主要通过切换context上下文结构进行的:
struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};
context保存着 被调用函数必须手动保存的寄存器的值,Intel规定以上寄存器发生过程调用时必须由被调用者保存,同时eip保存的被调用函数的返回地址,正常情况下c函数被调用时编译器会自动为其加上保存context的代码,xv6使用context切换来完成进程切换操作。
每个进程都有一个指向自己context结构的指针,当发生进程切换时,通过保存旧的context,然后将恢复另一个context来切换到另一个进程,swich汇编代码是所有进程切换的基础,旧进程调用swich时首先保存自己的context,然后将栈指针指向新的context,弹出恢复寄存器,当switch返回时返回的地址则是新的进程返回swich的代码,也就是说,进程总是调用swich来切换新进程,而swich从此并不会返回,只有当进程再次被调度时才能恢复到返回swich的“状态”,进程的状态总是被恢复在swich返回时状态。
在这里需要注意的是为什么context只保存部分寄存器的值,因为swich的操作总是让一个进程返回到调用swich应该返回的地方,也就是说,在调用swich之前,Intel的寄存器使用规范已经保证调用者保存寄存器能够被被调用的过程破坏,所以就算当切换到另一个进程时寄存器或许是旧进程的,但是对于新进程来说,它或者总是不会读取这些数据已经被破换的寄存器(Intel体系规定了寄存器使用惯例,所有编程人员都应该注意这个问题)。
swich代码:
# Context switch
#
# void swtch(struct context **old, struct context *new);
#
# Save current register context in old
# and then load register context from new.
.globl swtch
swtch:
movl 4(%esp), %eax
movl 8(%esp), %edx
# Save old callee-save registers
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi
# Switch stacks
movl %esp, (%eax)
movl %edx, %esp
# Load new callee-save registers
popl %edi
popl %esi
popl %ebx
popl %ebp
ret
调度
xv6永远不会直接从一个进程的上下文切换到另一个进程的上下文,这些都是通过一个中间的内核线程实现的:内核调度器线程。具体如图:
在前面讲过当进程用完它的CPU时间片时,时钟中断会调用yield函数来让出CPU给新的进程,yield调用sched函数,sched调用swich来切换都调度器线程:
swtch(&proc->context, cpu->scheduler);
调度器线程从进程表中找到一个就绪进程,并初始化进程运行环境,然后调用swich切换到新的进程:
swtch(&cpu->scheduler, p->context);
调度器线程仅仅是简单地进行轮转调度,一旦找到就绪线程便切换到新的线程。
调度器完整代码如下:
void
scheduler(void)
{
struct proc *p;
for(;;){
// Enable interrupts on this processor.
sti();
// Loop over process table looking for process to run.
acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->state != RUNNABLE)
continue;
// Switch to chosen process. It is the process's job
// to release ptable.lock and then reacquire it
// before jumping back to us.
proc = p;
switchuvm(p);
p->state = RUNNING;
swtch(&cpu->scheduler, p->context);
switchkvm();
// Process is done running for now.
// It should have changed its p->state before coming back.
proc = 0;
}
release(&ptable.lock);
}
}
进程切换时的锁、中断问题
通过观察 scheduler和yield的代码可以知道,进程表的锁总是由旧进程获得,新进程释放,这样做的原因是为了保护进程切换能够正常进行,相关代码如下:
void
sched(void)
{
int intena;
if(!holding(&ptable.lock))
panic("sched ptable.lock");
if(cpu->ncli != 1)
panic("sched locks");
if(proc->state == RUNNING)
panic("sched running");
if(readeflags()&FL_IF)
panic("sched interruptible");
intena = cpu->intena;
swtch(&proc->context, cpu->scheduler);
cpu->intena = intena;
}
// Give up the CPU for one scheduling round.
void
yield(void)
{
acquire(&ptable.lock); //DOC: yieldlock
proc->state = RUNNABLE;
sched();
release(&ptable.lock);
}
同时在scheduler的代码中,在外层循环总是要释放进程表锁,这是为了防止CPU闲置时,由于当前CPU一直占有锁,其他CPU无法调度运行导致的死锁,同时总是要在调度时打开中断,这是为了当所有进程都在等待IO时,由于关闭中断而产生的死锁问题。
睡眠与唤醒
多个进程之间总是需要各种相互协作、同步的操作,在xv6中文文档中对于睡眠和唤醒机制讲得非常详细,这里放出链接:
xv6中的睡眠与唤醒
睡眠操作使得一个进程由于等待某个条件而让出CPU,唤醒操作则将等待队列的进程唤醒出来,睡眠和唤醒是一种进程间的同步操作。
睡眠与唤醒的问题主要在于“丢失的唤醒问题”,由于可能当某一进程检测到睡眠条件时,另一个进程触发了唤醒条件但是却没有唤醒任何进程,此时当前进程继续睡眠却永远无法被唤醒。
出现这种现象的原因是睡眠操作与检测睡眠条件不是一个原子操作,xv6将等待队列加入锁来实现,进程在检测睡眠条件之前获得锁并在sleep中将锁释放,由于sleep需要修改进程表数据所以也需要进程锁,这两个锁中任意一个都可以讲睡眠操作和检测睡眠条件置为原子操作,wakeup在调用前总是需要获得等待队列的锁,并且在wakeup操作中也需要使用进程表的锁。
等待队列结构:
struct q {
struct spinlock lock;
void *ptr;
};
sleep和wakeup实现:
总体思路是希望 sleep 将当前进程转化为 SLEEPING 状态并调用 sched 以释放 CPU,而 wakeup 则寻找一个睡眠状态的进程并把它标记为 RUNNABLE。
// Atomically release lock and sleep on chan.
// Reacquires lock when awakened.
void
sleep(void *chan, struct spinlock *lk)
{
if(proc == 0)
panic("sleep");
if(lk == 0)
panic("sleep without lk");
// Must acquire ptable.lock in order to
// change p->state and then call sched.
// Once we hold ptable.lock, we can be
// guaranteed that we won't miss any wakeup
// (wakeup runs with ptable.lock locked),
// so it's okay to release lk.
if(lk != &ptable.lock){ //DOC: sleeplock0
acquire(&ptable.lock); //DOC: sleeplock1
release(lk);
}
// Go to sleep.
proc->chan = chan;
proc->state = SLEEPING;
sched();
// Tidy up.
proc->chan = 0;
// Reacquire original lock.
if(lk != &ptable.lock){ //DOC: sleeplock2
release(&ptable.lock);
acquire(lk);
}
}
//PAGEBREAK!
// Wake up all processes sleeping on chan.
// The ptable lock must be held.
static void
wakeup1(void *chan)
{
struct proc *p;
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
if(p->state == SLEEPING && p->chan == chan)
p->state = RUNNABLE;
}
// Wake up all processes sleeping on chan.
void
wakeup(void *chan)
{
acquire(&ptable.lock);
wakeup1(chan);
release(&ptable.lock);
}
在这里需要注意的是sleep之所以调用sched而不是调用yield是因为此时sleep已经拥有进程表锁,所以调用sched让出CPU。