进程
1.实验目标
阅读并了解xv6内核的进程控制块(PCB),了解进程信息、进程状态转换、进程调度等等问题在xv6内核中的设计,最后完成exittest的编写,收集父进程和子进程信息。
2.实验过程记录
(1).了解xv6的进程控制块信息
操作内容:在VS Code中查看并理解proc.h的信息
不难发现,相较于Linux内核的进程控制块,xv6的struct proc要精简得多,首先是一个自旋锁变量,因为PCB中含有六个作为临界区的属性(状态、父进程、chan、是否已被Kill、退出状态以及进程号),因此需要加锁以保护PCB,而后续的类似页表、栈地址、文件系统相关的内容等均为struct proc的私有属性,一般不允许访问,因此并不需要使用自旋锁进行保护。
在proc.h中还有struct cpu、struct context等值得关注的结构体,cpu结构体是对CPU的抽象,其保留了两个重要信息:一个是当前运行的进程,还有一个则是上下文信息,这二者将在后续发生进程调度时产生重要影响。
struct context则保存了进程的寄存器信息用于后续进程调度的上下文切换。
(2).理解xv6的进程管理
操作内容: 在VS Code中查看并理解proc.c的代码
首先可以注意到的是几个基本变量的声明,cpus保存了所有CPU当前运行的进程信息,如步骤1中阅读的struct cpu那样,而不同于Linux内核利用链表的形式来管理进程,xv6采取了一个全局的struct proc数组来保存所有的进程控制块,这样随机访问进程信息的效率会提升,但是因为是连续存储,xv6能启动的进程数量可能相比Linux就比较有限了。initproc指针会保存userinit所创建的最初的用户态进程,nextpid则作为每个进程创建时能够分配的pid而存在,而正式因为这个nextpid作为临界区存在,所以有一个配套的自旋锁pid_lock用来保护nextpid,因为在创建进程时需要nextpid++操作以为下一个进程做准备。
下一个比较重要的部分是allocproc函数,它负责在创建进程的时候分配进程控制块的空间,在分配完毕之后返回分配的进程控制块的地址:
static struct proc *allocproc(void) {
struct proc *p;
for (p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if (p->state == UNUSED) {
goto found;
} else {
release(&p->lock);
}
}
return 0;
found:
p->pid = allocpid();
// Allocate a trapframe page.
if ((p->trapframe = (struct trapframe *)kalloc()) == 0) {
release(&p->lock);
return 0;
}
// An empty user page table.
p->pagetable = proc_pagetable(p);
if (p->pagetable == 0) {
freeproc(p);
release(&p->lock);
return 0;
}
// Set up new context to start executing at forkret,
// which returns to user space.
memset(&p->context, 0, sizeof(p->context));
p->context.ra = (uint64)forkret;
p->context.sp = p->kstack + PGSIZE;
return p;
}
因为进程以数组形式管理,因此在分配进程的时候需要去寻找一个处在UNUSED也就是未被使用状态的位置,每一次寻找的时候会获取进程块的锁,如果找到了则带着锁前往后续操作,否则释放锁,继续查看下一个进程块为止是否空闲,如果始终没有找到空闲,则会返回0,也就是NULL,代表未能成功分配进程空间。
当成功找到了空间后,就会通过goto前往found标签,首先为进程块使用allocpid分配一个进程号,之后使用kalloc函数为p分配trap帧(涉及一些中断的信息),如果分配失败则释放锁并返回NULL,否则继续为p分配用户页表,同理,失败的情况下释放内存,释放锁并返回NULL。
最后一步则是将p的上下文信息置零,然后将栈指针等信息单独保存到对应的寄存器,之后就完成了整个进程的分配。
在之后比较关键的函数就是fork函数了:
// Create a new process, copying the parent.
// Sets up child kernel stack to return as if from fork() system call.
int fork(void) {
int i, pid;
struct proc *np;
struct proc *p = myproc();
// Allocate process.
if ((np = allocproc()) == 0) {
return -1;
}
// Copy user memory from parent to child.
if (uvmcopy(p->pagetable, np->pagetable, p->sz) < 0) {
freeproc(np);
release(&np->lock);
return -1;
}
np->sz = p->sz;
np->parent = p;
// copy saved user registers.
*(np->trapframe) = *(p->trapframe);
// Cause fork to return 0 in the child.
np->trapframe->a0 = 0;
// increment reference counts on open file descriptors.
for (i = 0; i < NOFILE; i++)
if (p->ofile[i]) np->ofile[i] = filedup(p->ofile[i]);
np->cwd = idup(p->cwd);
safestrcpy(np->name, p->name, sizeof(p->name));
pid = np->pid;
np->state = RUNNABLE;
release(&np->lock);
return pid;
}
fork函数算是一个非常非常常用的系统调用了,因为它会在进程内被调用,因此首先会通过myproc函数获取本进程的进程控制块信息并存入p,然后尝试调用allocproc为子进程np分配进程空间,分配成功后,首先会将父进程的页表信息拷贝到子进程中,完成后将子进程np的父进程设置为p,恰巧因为trapframe结构体是平凡可复制的,因此直接通过赋值的方式完成了trapframe的拷贝,之后利用filedup函数将每个父进程p打开的文件复用产生新的fd用于子进程访问对应的文件,之后使用安全的strcpy函数完成进程名的赋值,最后使得子进程进入就绪态,释放子进程的自旋锁,然后返回子进程的pid,就完成了整个fork函数的全部操作。
接下来是exit函数,这个函数负责在退出进程的时候保持zombie态直到父进程调用wait等待其退出,代码如下:
// Exit the current process. Does not return.
// An exited process remains in the zombie state
// until its parent calls wait().
void exit(int status) {
struct proc *p = myproc();
if (p == initproc) panic("init exiting");
// Close all open files.
for (int fd = 0; fd < NOFILE; fd++) {
if (p->ofile[fd]) {
struct file *f = p->ofile[fd];
fileclose(f);
p->ofile[fd] = 0;
}
}
begin_op();
iput(p->cwd);
end_op();
p->cwd = 0;
// we might re-parent a child to init. we can't be precise about
// waking up init, since we can't acquire its lock once we've
// acquired any other proc lock. so wake up init whether that's
// necessary or not. init may miss this wakeup, but that seems
// harmless.
acquire(&initproc->lock);
wakeup1(initproc);
release(&initproc->lock);
// grab a copy of p->parent, to ensure that we unlock the same
// parent we locked. in case our parent gives us away to init while
// we're waiting for the parent lock. we may then race with an
// exiting parent, but the result will be a harmless spurious wakeup
// to a dead or wrong process; proc structs are never re-allocated
// as anything else.
acquire(&p->lock);
struct proc *original_parent = p->parent;
release(&p->lock);
// we need the parent's lock in order to wake it up from wait().
// the parent-then-child rule says we have to lock it first.
acquire(&original_parent->lock);
acquire(&p->lock);
// Give any children to init.
reparent(p);
// Parent might be sleeping in wait().
wakeup1(original_parent);
p->xstate = status;
p->state = ZOMBIE;
release(&original_parent->lock);
// Jump into the scheduler, never to return.
sched();
panic("zombie exit");
}
为节省篇幅,去除了所有的空行,exit会首先获取当前进程的PCB,如果当前进程恰巧是用户态初始进程,则会通过内核panic的方式告知init exiting。exit需要做的第一步操作就是关闭所有已经打开的文件,在这里遍历所有的fd,使用fileclose关闭对应的文件。
接下来为了让init进程接管当前进程的子进程,因此首先尝试唤醒init进程,但是这个操作并不一定会成功,但是无阻碍的释放锁不会造成什么严重的问题,所以xv6代码就这么做了。然后exit又尝试去获得当前进程真正的父进程的PCB,之后会首先获取父进程的锁,再获取当前进程的锁(代码注释说这是遵循了先父再子的原则),在获取了两个锁之后就可以将当前进程挂靠到init进程收养了,之后再尝试唤醒当前进程真正的父进程,之后将当前进程的退出状态设置为传入的值status,然后设置进程状态为ZOMBIE,之后释放掉真正父进程的锁,调用sched进入调度的环节,从而完成了整个exit的过程。
wait函数则是fork/exit/wait三者的最后一环,父进程可以通过使用wait来等待子进程退出后再执行其他的操作,它的代码如下:
// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int wait(uint64 addr) {
struct proc *np;
int havekids, pid;
struct proc *p = myproc();
// hold p->lock for the whole time to avoid lost
// wakeups from a child's exit().
acquire(&p->lock);
for (;;) {
// Scan through table looking for exited children.
havekids = 0;
for (np = proc; np < &proc[NPROC]; np++) {
// this code uses np->parent without holding np->lock.
// acquiring the lock first would cause a deadlock,
// since np might be an ancestor, and we already hold p->lock.
if (np->parent == p) {
// np->parent can't change between the check and the acquire()
// because only the parent changes it, and we're the parent.
acquire(&np->lock);
havekids = 1;
if (np->state == ZOMBIE) {
// Found one.
pid = np->pid;
if (addr != 0 && copyout(p->pagetable, addr, (char *)&np->xstate, sizeof(np->xstate)) < 0) {
release(&np->lock);
release(&p->lock);
return -1;
}
freeproc(np);
release(&np->lock);
release(&p->lock);
return pid;
}
release(&np->lock);
}
}
// No point waiting if we don't have any children.
if (!havekids || p->killed) {
release(&p->lock);
return -1;
}
// Wait for a child to exit.
sleep(p, &p->lock); // DOC: wait-sleep
}
}
首先还是一样获取当前的进程信息,然后获取锁,因为PCB中是不保存自己的子进程信息的,因此现在获取了当前PCB后,wait会在进程表中寻找父进程为当前进程p的进程,作为自己的子进程,在找到对应进程之后,会首先获取子进程的锁,并将有子进程的flag设置为1,如果子进程的状态已经是ZOMBIE,则会在确保子进程的PCB被释放后释放两个锁,返回刚刚退出的子进程的pid。如果不是ZOMBIE,则释放子进程的锁,在内圈循环中继续遍历进程表找到其他的子进程块;如果遍历结束后没有可以释放的子进程,则会由外圈循环开始持续进行这个操作,直到有一个子进程被释放为止。否则如果不存在子进程可以释放亦或是进程p本身已经被killed,则会直接释放锁,然后退出循环。为了避免出现轮询次数过多导致过多的CPU占用,每次内圈循环结束后会进行一段时间的等待睡眠,这个过程中会释放锁以避免长期占用导致死锁问题。
之后是yield函数,这个函数用于进程主动在当前调度轮次中放弃CPU进入调度状态:
// 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函数的实现非常简单,获取PCB信息后将p的状态设置为就绪态,然后调用sched完成下一轮的CPU调度,之后释放当前进程的锁。
Kill函数的实现也比较简单:
// Kill the process with the given pid.
// The victim won't exit until it tries to return
// to user space (see usertrap() in trap.c).
int kill(int pid) {
struct proc *p;
for (p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if (p->pid == pid) {
p->killed = 1;
if (p->state == SLEEPING) {
// Wake process from sleep().
p->state = RUNNABLE;
}
release(&p->lock);
return 0;
}
release(&p->lock);
}
return -1;
}
首先遍历进程表,获取锁后查看pid,如果找到了对应的进程,则将进程p的killed记为1,标记为已经被killed,如果p的状态处在阻塞态,则将其转为就绪态,这样可以保证在被killed之后,下一轮的进程调度一旦调度到进程p则会检查自己的killed,如果已经变为1,则会主动执行exit操作以自行退出,之后则进行释放锁等最后的操作。
(3).理解进程调度流程
操作内容:在VS Code中查看并理解proc.c的代码
首先需要关注的是scheduler函数,它是CPU调度的核心函数:
// Per-CPU process scheduler.
// Each CPU calls scheduler() after setting itself up.
// Scheduler never returns. It loops, doing:
// - choose a process to run.
// - swtch to start running that process.
// - eventually that process transfers control
// via swtch back to the 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();
int found = 0;
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;
found = 1;
}
release(&p->lock);
}
if (found == 0) {
intr_on();
asm volatile("wfi");
}
}
}
在每个CPU完成了初始化后,都会调用scheduler开始死循环完成调度操作,首先通过mycpu获取当前CPU信息,然后将进程置空,开始死循环后,为了避免死锁,首先通过调用riscv.h中的intr_on()完成开中断操作,接下来开始遍历进程表,找到就绪态的进程后就将自己的proc指向对应的进程,并且通过swtch函数完成上下文切换过程(swtch函数会在后面解析),在进程运行结束后,proc被重新设置为空,之后设置found为1代表这一轮循环找到了进程,之后无论是否执行进程都释放锁,在搜索了一轮进程表后,如果没有找到处在就绪态的进程,则开中断,并且执行一条”wfi”指令进入CPU的sleep状态,在当前CPU不接到一个中断信号之前,该CPU都不会被唤醒,是一种低功耗的状态。
接下来是在前面的进程管理函数中很常见的sched函数,它是进程自行完成的调用:
// Switch to scheduler. Must hold only p->lock
// and have changed proc->state. Saves and restores
// intena because intena is a property of this
// kernel thread, not this CPU. It should
// be proc->intena and proc->noff, but that would
// break in the few places where a lock is held but
// there's no process.
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;
}
进程调度的过程首先是获取自己的进程信息,在保证当前进程没有被锁、是第一次执行push_off、进程没有处在运行态并且当前CPU已经关中断的情况下,才可以完成上下文切换的过程。虽然这个函数很简单,但是noff、intena和push_off三个名词算是第一次见到,因此我去寻找了一下对应的代码:
// push_off/pop_off are like intr_off()/intr_on() except that they are matched:
// it takes two pop_off()s to undo two push_off()s. Also, if interrupts
// are initially off, then push_off, pop_off leaves them off.
void push_off(void) {
int old = intr_get();
intr_off();
if (mycpu()->noff == 0) mycpu()->intena = old;
mycpu()->noff += 1;
}
void pop_off(void) {
struct cpu *c = mycpu();
if (intr_get()) panic("pop_off - interruptible");
if (c->noff < 1) panic("pop_off");
c->noff -= 1;
if (c->noff == 0 && c->intena) intr_on();
}
push_off函数定义在自旋锁当中,push_off的操作比较简单,首先获取当前CPU的中断状态,之后直接关闭当前CPU中断,如果CPU的noff为0,则将CPU的intena字段记为old,之后再将noff字段加一,因此对于intena字段的理解大概是这样:intena只有在noff为0的时候才会记录为上一次CPU的中断状态,结合文件前面的acquire和release函数我大概理解了这个过程:只有在CPU首次进行push_off操作的时候,intena会记录上一次CPU的中断状态,pop_off会将noff减1,仅当有对应次pop_off以及上一次CPU的中断状态为开的情况下,才会开中断。因此noff字段记录了当前push_off的次数,intena记录在进行push_off和pop_off操作之前的中断状态。
所以调度的过程就比较直观了,它确保了当前CPU并不是只进行了一次获取锁的push_off操作,之后确保进程不是运行态,然后再二次确认进程当前CPU处于关中断的状态。之后保存当前CPU的intena信息,然后完成上下文切换,最后再还原intena信息。这是sched的流程,不过我的疑问在于:为什么一定要保证当前的CPU处于关中断状态,并且当前CPU只能被push_off一次呢?关中断对于进程调度过程到底意味着什么?我决定看看swtch函数再来研究有关关中断的这些疑问。
swtch函数位于swtch.S当中,使用汇编语言实现:
# Context switch
#
# void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.
.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
所以其实看完了swtch的代码之后发现,swtch没啥秘密,其实做的就是最简单的将旧的寄存器信息存在旧的变量里,把新的寄存器信息加载到寄存器当中去,因此要研究进程调度的过程,还需要一些别的东西。
所以当然不会这么快结束,在查询资料之后发现关于进程调度的时间片轮转调度机制这一块写在kernel/trap.c下:
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void usertrap(void) {
…
// give up the CPU if this is a timer interrupt.
if (which_dev == 2) yield();
usertrapret();
}
trap.c中的usertrap函数描绘了接收到用户态的中断、异常和系统调用的处理机制,我关注的内容主要在于最后:当发生时钟中断的时候则调用yield函数让出CPU进行下一轮调度,在同一个文件中我找到了clockintr(时钟中断)这个函数:
void clockintr() {
acquire(&tickslock);
ticks++;
wakeup(&ticks);
release(&tickslock);
}
它的实现很简单,就是每次对ticks++,从首先获取锁,再自增,再唤醒在ticks上等待的进程,这一点很像条件变量的实现,依照我的理解,应当存在一个进程专门负责触发时钟中断,它会始终在ticks这个量上进行等待,后续发现这个实现其实非常简单,每个ticks对应一个0.1s,每次自增通知对应的进程,在这个状况下,每0.1s就进行一次yield操作进行进程调度,这也是最简单的时间片轮转调度的实现。
因此此时回到当初的问题,首先为什么要保证在进入调度之前处于关中断状态呢? 查阅了资料之后得到了这样一种解释:xv6要求在内核临界区操作时必须关中断,否则可能产生这样的死锁:
-
- 进程A在内核态获取P锁,此时触发时钟中断进入中断处理程序
-
- 中断处理程序也在内核态尝试获取P锁,由于P锁目前在进程A手中,只有A继续执行才有可能释放P锁,因此中断处理程序必须返回进程A才有可能拿到锁
-
- 但是这种情况下中断处理程序不可能返回进程A,因此中断处理程序永远无法获取锁,此时就出现了死锁现象
这大概解答了我的疑问,而至此,关于xv6进程调度的全过程就已经近乎明了了,进程的调度依赖于CPU上无限运行的scheduler完成,scheduler的工作很简单,就是不断地寻找就绪态的程序完成上下文切换,所有用户态进程都会受到时间片轮转调度的影响,被0.1s一次的时钟中断打断,之后切换为就绪态,调用yield让出CPU资源,完成后续的操作。
(4).设计进程信息收集代码
操作内容:参考实验手册要求,在exit中实现正确的输出,并且通过exittest进行测试
这个代码的实现是比较简单的,首先对于枚举类型state,我定义了一个数组用于专门打印状态名:
在将所有子进程交给init进程前,打印自己进程和父进程的所有信息(这么做是因为只有在这个地方,我们才获取到了p和original_parent的两把锁,只有这个时候我们才能访问临界区)
之后还需要修改reparent函数,同理,在获取到子进程的锁之后才打印子进程的一些信息,这里还加了一个child_cnt变量用户计算子进程的数量:
最终,make qemu并使用exittest进行测试就可以得到结果:
可以发现两次调用exittest的结果是有些许不一致的,这是可以容忍的,因为init进程的状态以及进程号这些都是不可控的因素,在尝试过后基本可以认为实验结果和需求一致,实验成功。
使用grade-lab-syscall测试,对于exit的test顺利通过
3.存在的问题及解决方案
问题:为什么在内核调度之前要保证关中断呢?
如同在步骤三中已经探究到的结果可以知道,因为内核调度涉及到了内核临界区访问,此时如果发生中断进入中断处理程序,就可能会因为内核临界区上锁而导致无法切换回原进程而导致死锁现象发生,因此在这个时候必须关中断以避免当前CPU接收中断进入中断处理程序。
实验小结
- 1、本次实验阅读了xv6内核关于进程管理和进程控制的代码,基本上已经完全理解了xv6是如何完成进程管理和进程调度的,也基本上从trap.c中了解了时钟中断是如何定义、如何触发以及触发后会发生的一些事情,基本上理解了时间片轮转调度在xv6内核中的定义。
- 2、 完成了对于exit系统调用以及reparent函数的修改,在合适的位置使用exit_info打印出了进程退出时的相关信息,完成了exittest的要求。