重读 xv6(II)

xv6 进程机制

#

我们先分析进程机制的实现,源码 proc.c

创建进程 #

userinit 本质上是新建 init 进程,再通过 scheduler 执行该进程

我们调用 allocproc() 来请求进程资源,为什么使用“请求”这个字眼呢?

xv6 的进程资源是预先分配的,我们会维护一个固定的 proctable,根据状态来分配资源

对于一个进程,我们需要维护以下信息:

  1. statepidparentid 等 metadata
  2. trapframe,用于切换 syscall 内核态
  3. pagetable,用于虚拟内存
  4. context,用于调度 CPU

allocproc 的流程:

  1. 遍历 proctable,取出 UNUSED 的空闲进程
  2. 申请 pagetable,初始化页表映射与 trap
  3. 初始化 context,用于进程调度

freeproc 把申请的资源全部释放即可

切换进程 #

设计进程抽象的动机是 CPU 调度,而 scheduler 依赖的核心机制就是 上下文切换

每个 CPU 都在不间断的运行调度器 scheduler,收到中断时调用 yield 让出 CPU 进行调度

这一逻辑由三层构成:

  1. scheduler:找到状态为 RUNNABLE 的进程
  2. sched:proc 与 scheduler 间切换的胶水函数
  3. swtch:保存与替换寄存器内容,切换程序流

swtch 切换了 %ra 寄存器的内容,改变了 ret 的返回地址,因此结束后 CPU 会在新进程上运行

程序是一个状态机,swtch 的威力就在于无缝替换了这个状态机的上下文

同时 schedulersched 本身作为函数,既发挥了临界区的作用,又为重定向 ret 积攒了势能

这样的设计为进程提供了独占 CPU 的假象——swtch结束后,寄存器等资源不变,程序会从之前 trap 的地方继续执行,整个调度过程对用户完全透明


要注意到,我们已经假设 swtch 到是一个执行到一半的旧进程,逻辑上必须存在第二个进程

可是创建进程时,它可从来没有被执行过,如何设置新进程的上下文呢?

我们选择捏造一个进程 forkret,假装在此之前一直是它在工作,调度后会先从 forkret 执行

forkret 什么都不做,只是 trap 回到用户态,从新进程的第一条指令开始执行

实际上每个 proc 都是先 trap 到内核态,在 scheduler 内部被踢走的,所以 forkret 才要调用 usertrap


调度过程
调度过程
用户视角下的调度过程
用户视角下的调度过程
新进程的调度过程
新进程的调度过程

主动调度 #

等待 lock 时,进程可能会主动选择休眠,让出 CPU,此时状态转为 SLEEPING

这一套 sleep-wakeup 的逻辑,简单遍历 proctable 即可实现

进程树 #

每个进程都是 init proc 直接或间接 fork 得到的,这也是我们在 I 中称其为 “dummy porc” 的原因,它是所有进程的祖先

这里我们只需要讨论三个函数:

  1. wait 循环扫描 proctable,等待指定的子进程变为ZOMBIE,返回并释放结束的子进程
  2. kill 标记进程应该被删除,没有立刻删除的能力,需要内核程序不间断地进行自我检查
  3. exit 关闭进程,并把状态标记为 ZOMBIE,等待上层清理

注意到一个有趣的事实,exit 并不负责释放资源,而是交给 parent 进程释放,同时 exit 会将剩余的子节点全部移交给 init

exit 必须获取锁,防止父子进程同时死亡,否则就没人给 child proc 收尸了

因此 init proc 成为了事实上的垃圾回收者,循环执行 wait 以释放无主的 proctable 资源


这一节,我们回答了以下问题:

  1. 进程是如何被创建的?
  2. OS 怎么将调度过程透明化的?
  3. OS 如何自动管理进程资源?