xv6内核剖析 010
今天继续我们之前的proc.h
和proc.c
void reparent(struct proc *p)
这个函数将一个将亡进程的子进程(也就是即将称为孤儿进程的子进程)交给进程树的根进程(第一个被初始化的进程)。我们可以称之为,托孤,哈哈哈
void
reparent(struct proc *p) // p是父进程
{
struct proc *pp;
// 遍历进程表找到父进程的所有子进程
for(pp = proc; pp < &proc[NPROC]; pp++){
// 注释:这段代码在访问pp->parent的时候并没有
// 锁住pp->lock,因为如果pp或者pp的一个子进程
// 正在exit()中,先锁住pp->lock可能会导致
// 死锁
if(pp->parent == p){
// 必须先获取互斥锁在进程操作前,
// 注释:pp->parent在检查和acquire()之间不能
// 被改变,因为只有父进程能够改变它,并且当前进
// 进程就是父进程。
acquire(&pp->lock);
pp->parent = initproc;
// 注释:我们应该在这里唤醒init进程,但是那样
// 我们需要获取initproc->lock,这将会导致死锁,
// 因为我们已经锁住了init进程的一个子进程(pp),
// 这就是为什么exit()总是会在获取任何的锁之前
// 唤醒init进程
release(&pp->lock);
}
}
}
说实话,本来很简单的代码,愣是被这个注释搞蒙蔽了,其实最主要的还是死锁预防,我们看看exit()
先吧。
void exit(int status)
这个函数有很多地方值得我们学习的,尤其是死锁预防策略。
void
exit(int status)
{
struct proc *p = myproc();
if(p == initproc)
panic("init exiting");
for(int fd = 0; fd < NOFILE; fd++){
if(p->ofile[fd]){
struct file *f = p->ofile[fd];
fileclose(f);
p->ofile[fd] = 0;
}
}
// cwd是进程当前所在的位置,
begin_op(); // 开启一个事务transmition
iput(p->cwd); // 这是一个inode
end_op(); // 提交事务
p->cwd = 0;
// 唤醒initproc进程
// 将initproc->state设置成RUNNABLE
acquire(&initproc->lock);
wakeup1(initproc);
release(&initproc->lock);
// 注释:我们可能会将一个子进程的传递给init。我们
// 无法精准地唤醒它,因为我们一旦获取了其他任何锁之后,
// 我们就不能够获取init的进程锁。所以不管是否需要我们
// 都需要唤醒init,init的唤醒信号可能会丢失,当时这
// 看起来没有损害。
acquire(&p->lock);
struct proc *original_parent = p->parent;
release(&p->lock);
// 严格保持锁的获取顺序,
// 不然会导致死锁
acquire(&original_parent->lock);
acquire(&p->lock);
reparent(p);
wakeup1(original_parent);
p->xstate = status;
p->state = ZOMBIE;
release(&original_parent->lock);
// 调度
sched();
panic("zombie exit");
}
wait(uint64 addr)
等待一个子进程退出并将它的进程id返回
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++){
// 避免死锁,所以不需要锁住np->parent
if(np->parent == p){
acquire(&np->lock);
havekids = 1;
if(np->state == ZOMBIE){
// Found one.
// 找到一个退出的子进程
pid = np->pid;
// 将子进程的进程id复制到
// 用户空间中指定的位置
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
}
}
进程调度器void scheduler(void)
这个函数涉及到进程上下文切换(swith context),第一次看的话可能会比较抽象。
但是理解这个进程的上下文切换有助于我们理解协程(corutine),线程(thread)切换。
回顾一下前面,我们在讨论struct cpu
的时候提到,在这个cpu的结构体中有一个context
成员属性,这个context就是保存了cpu对应的调度器的上下文,在调度的时候,被调度程序用struct cpu
中的context
取代原本的context,这样就切换到了调度函数中了,就能够进行调度,而原来的进程就作为一个就绪状态调度进程等待cpu的调度。一般来说,引起上下文切换的原因主要是:
- 用户进程调用了yield
- 时钟中断产生,强制进行上下文切换,但是在xv6中,acquire的调用会使当前的cpu屏蔽中断。不进行切换。其实这在linux中的rcu(read-copy-update)中也有体现,可以去看看我之前写的文章
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) {
p->state = RUNNING;
c->proc = p;
// 上下文切换将被调度的进程上下文切换到当前cpu中
// 这里需要我们注意--->我们在下面展开说
// 先把代码看完
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);
}
// 如果当前没有就绪状态的进程,cou就陷入休眠
if(found == 0) {
// 打开中断
intr_on();
// 下面详细说明
asm volatile("wfi");
}
}
}
这个函数有两点需要说明的
-
swtch(&c->context, &p->context);
调度器执行完这个函数,并不会返回,因为这个函数底层是汇编代码,它改变了一系列寄存器的值:加载了被调度进程的上下文,并且将原来的调度器的上下文保存cpu中的context属性中以备下一次调度,我们可以理解为调度器在调用完swtch()之后就被暂停了,然后cpu通过ra寄存器返回了被调度进程上下文,那什么时候调度器的上下文才会被加载呢?这跟我们之前说的是一样的,(当运行的进程调用了yield或者一个时钟中断(以软中断的形式产生)时)。
-
asm volatile("wfi");
这段内联汇编是什么意思呢?我们直接看满分回答
使用 C 语言的内联汇编语法(
asm
关键字),插入了一条对处理器的特殊指令"wfi"
。这里的volatile
关键字表明这条汇编指令对程序行为有重要影响,编译器不应对其进行优化。"wfi"
是一个广泛应用于基于ARM架构和其他某些处理器体系结构的指令,其全称为 “Wait For Interrupt”(等待中断)。当执行到这条指令时,处理器会进入低功耗模式,停止执行任何指令(除了监控中断请求),直到接收到一个有效的中断信号。这样做的目的是节省能源,同时保持对中断事件的即时响应能力。一旦有任何中断发生,处理器将立即退出
wfi
模式,处理相应的中断服务例程,然后返回到执行流的下一条指令(即wfi
后面的代码)。综上所述,这段代码的主要作用是:
- 当变量
found == 0
时,- 开启处理器的中断功能,以便能及时响应外部事件;
- 通过执行
wfi
指令,使处理器进入低功耗、等待中断的状态,直到有中断发生。
这样的设计常出现在实时系统或嵌入式系统中,用于在满足特定条件时,使系统进入节能模式并等待外部事件触发进一步操作。
- 当变量
sched(void)
这个函数是用户态进程在内核空间中切换到调度器线程时调用的
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;
}
yield(void)
进程让出cpu,比较简单
void
yield(void)
{
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE;
sched();
release(&p->lock);
}
forkret(void)
fork系统调用从内核空间中返回
void
forkret(void)
{
static int first = 1;
// Still holding p->lock from scheduler.
release(&myproc()->lock);
if (first) {
// 初始化文件系统,只初始化一次
first = 0;
fsinit(ROOTDEV);
}
// 返回用户空间
usertrapret();
}
为什么需要在第一次fork返回之前初始化文件系统呢?
注释里面说到了,在fsinit中有sleep之类的调用,所以需要在进程中调用,也就是说,文件系统是由init进程进行初始化的。因为进程可能需要从外部存储设备中读取或者写入数据所以需要在fork返回之前调用,这个时候,进行已经初始化好了,就相当于内核将文件系统初始化的操作均摊在这一次fork中(类似于我们高中时候说的上课的小动作)。
sleep(void *chan, struct spinlock *k)
单单看这个声明就有点像我们平常开发中用到的条件变量(condition variable),我们可以将chan(channal)看成是一个条件变量,k看成是平时用到的互斥锁
void
sleep(void *chan, struct spinlock *lk)
{
struct proc *p = myproc();
// 获取进程锁,防止唤醒信号的丢失
if(lk != &p->lock){ //DOC: sleeplock0
acquire(&p->lock); //DOC: sleeplock1
release(lk);
}
// Go to sleep.
p->chan = chan;
// 这个会被其他的进程修改,所以才能在后面被唤醒
p->state = SLEEPING;
sched();
// Tidy up.
p->chan = 0;
// Reacquire original lock.
// 恢复调用sleep之前的状态,重新获取互斥锁
if(lk != &p->lock){
release(&p->lock);
acquire(lk);
}
}
还有几个函数就能结束了,我们明天继续吧。