xv6阅读之进程调度
四、进程调度
在计算机资源不足以同时运行所有进程时,就需要操作系统考虑如何分配有限的资源,如CPU时间和内存。xv6通过切换每个CPU上的进程实现多路复用。当进程等待设备或管道IO,或等待子进程退出,或等待sleep系统调用完成时,通过睡眠和唤醒进行进程调度;或者当某个进程长期持有CPU进行计算时,也会被强制切换以运行其他进程。
4.1代码阅读
proc.c和proc.h的代码在上一节中已经描述过,这里对与调度相关的几个函数再进行详细说明。
4.1.1 swtch.S
函数swtch为内核线程切换上下文时执行把当前寄存器保存到旧线程的栈并从新线程的栈读取数据到寄存器的操作。保存和恢复栈指针和PC意味着CPU即将切换栈和要执行的代码。swtch并不关心线程,只是sl寄存器集。上下文储存在一个context结构中,swtch接受两个context结构的指针,保存加载数据然后返回。当swtch返回时,将返回到ra寄存器指定的位置,即在新线程的栈上返回。
4.1.2 scheduler()和sched()
- 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;
- }
sched()函数调用swtch,从被调度的进程中进入scheduler调度器,由scheduler来选择切换到哪个进程执行。调用时会进行一系列检查,确保满足开始调度的条件:持有进程的锁、中断已被关闭、进程不在RUNNING状态、中断嵌套深度为1,否则将panic崩溃并显示对应的错误。随后,sched调用swtch将当前上下文保存在p->context,并切换到CPU->scheduler中的调度程序上下文,因为scheduler以一个特殊线程运行在CPU上。swtch在scheduler的栈上返回就像是shceduler的swtch返回一样。
- //scheduler()
- 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);
- c->proc = 0;
- }
- release(&p->lock);
- }
scheduler()运行了一个循环:找到一个进程来运行,运行直到其停止,然后继续循环。找到一个RUNNABLE的p进程后,scheduler()在已经持有p->lock的情况下,设置进程的state,设置CPU的进程指针指向p,并切换CPU的上下文为p的上下文,调用swtch开始运行它。当该进程运行足够长时间或者需要等待时,则会执行swtch()来再次回到scheduler中,然后切换回内核虚拟空间,将当前进程标记为空,接着从上一次寻找的位置来继续循环寻找新的可运行的进程。
4.1.3 睡眠与唤醒
xv6在锁和调度之外,还有睡眠和唤醒的方法,它允许一个进程在等待事件时休眠,而一个进程在事件发生后将其唤醒。
- void sleep(void *chan, struct spinlock *lk)
- {
- struct proc *p = myproc();
- acquire(&p->lock); //DOC: sleeplock1
- release(lk);
- // Go to sleep.
- p->chan = chan;
- p->state = SLEEPING;
- sched();
- // Tidy up.
- p->chan = 0;
- release(&p->lock);
- acquire(lk);
- }
sleep(chan,lk)让当前进程在任意值chan上睡眠,称为等待通道。随后改变进程状态,调用sched()重新调度,释放CPU用于其他工作。
值得注意的是,sleep在改变状态前获取了p->lock作为睡眠锁,并释放了原来的锁lk(在上文中为wait_lock),在结束时释放睡眠锁并重新获取等待锁。这是因为使用睡眠的操作需要原子化,如果一个进程P1需要取信号量s,不足则睡眠,而另一个进程P2生产信号量s并唤醒睡眠中的P1,P2恰在其发现需要睡眠到进入睡眠状态之前满足其唤醒条件将其唤醒,那么P1再也不会被唤醒。而如果简单用锁包围睡眠唤醒条件检查和睡眠唤醒操作,就导致P1睡眠后一直持有锁,P2无法观察是否满足唤醒条件,更无法唤醒,而P1不被唤醒自然无法释放锁,这就导致死锁。
因此,我们修改sleep的接口,把保护信号量的锁写入参数中,当P1睡眠前,会获取P1进程的锁并释放信号量的锁。如果同时有并发的P2希望生产信号量,那么它在等待有限的时间直到P1进入睡眠后,就可以正确的访问信号量并在生产后将消费者P1唤醒,而在唤醒前,P1继续执行sleep剩下的操作,将释放P1->lock,并在接下来取用信号量之前重新获取保护信号量的条件锁。既然sleep持有p->lock,那么释放lk是安全的:其他进程可能会启动对wakeup(chan)的调用,但是wakeup将等待获取p->lock,因此将等待sleep把进程置于睡眠状态的完成,以防止wakeup错过sleep。
- void wakeup(void *chan)
- {
- struct proc *p;
- for(p = proc; p < &proc[NPROC]; p++) {
- if(p != myproc()){
- acquire(&p->lock);
- if(p->state == SLEEPING && p->chan == chan) {
- p->state = RUNNABLE;
- }
- release(&p->lock);
- }
- }
- }
wakeup()函数比较简单,它将唤醒所有在指定chan上睡眠的进程,并使其sleep调用返回,如果没有进程在chan上等待,则wakeup不会有任何操作。唤醒所有进程是因为有时多个进程在同一个管道上睡眠,比如等待读取同一个管道的IO时,一个wakeup就能唤醒,但会有一个首先运行并获取与sleep一同调用的锁,并读取管道中到来的数据。其他管道醒来时发现没有数据,又不得不进入睡眠状态。
4.1.4 wait、exit与kill
wait 首先要求获得 wait_lock避免丢失唤醒,然后查看进程表中是否有子进程,如果找到了处于ZOMBIE的子进程,就释放这个子进程的资源与结构体,将退出状态码复制给wait的地址,返回子进程的pid。如果p自身没有子进程或者p已经被杀死,那么就会返回失败的-1.如果所有子进程没有已经退出的,那么就调用sleep等待其中一个子进程退出,然后不断循环。
exit()终止当前进程:先检查是否要求退出init进程,然后关闭打开文件表,要求获得 wait_lock, 然后唤醒当前进程p的父进程,将当前进程的子进程托管给init进程,最后将自身状态设置为ZOMBIE,并且调用 sched 来让出 CPU。
killed不直接杀死进程,而是将进程的killed设置为1,并将sleeping的进程改为RUNNABLE,在下一次被调度时,经过usertrap函数切换到内核态,会检查killed的值,并杀死进程。
4.2 调度机制
图中描述了从旧的用户进程切换到新进程的步骤:从用户态切换到内核态(系统调用或中断)、到scheduler线程的上下文切换、到新进程内核线程的切换、返回到用户级进程的陷阱。
图为进程不同状态与其转换的函数。