4. 进程状态机:UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE 状态转换图
xv6 的进程管理核心是一个有限状态机。每个 struct proc 实例都维护一个 state 字段,该字段在以下六种状态之间转换:UNUSED、EMBRYO、SLEEPING、RUNNABLE、RUNNING、ZOMBIE。理解这个状态机是掌握 fork 与 exec 底层实现的前提。
核心原则: 进程状态转换由内核调度器(scheduler)和系统调用共同驱动。每个状态都对应一组合法的操作和下一个可能的状态。
4.1 六种状态定义与语义
| 状态枚举 | 数值 | 语义描述 | 关键特征 |
|---|---|---|---|
UNUSED |
0 | 进程槽位空闲,未分配给任何进程 | proc 数组中的空位;allocproc() 会扫描此状态 |
EMBRYO |
1 | 进程正在被创建,尚未完成初始化 | 仅存在于 allocproc() 到 fork() 返回之间;短暂过渡态 |
SLEEPING |
2 | 进程因等待某事件(I/O、锁、子进程退出)而主动休眠 | 调用 sleep() 进入;由 wakeup() 唤醒回 RUNNABLE |
RUNNABLE |
3 | 进程已就绪,等待调度器分配 CPU | 位于调度队列中;scheduler() 会从中选择进程运行 |
RUNNING |
4 | 进程正在 CPU 上执行指令 | 仅当前 CPU 的 myproc() 指向的进程处于此状态 |
ZOMBIE |
5 | 进程已退出,但父进程尚未调用 wait() 回收资源 |
保留 struct proc 和退出状态;等待父进程读取 |
EMBRYO 状态的特殊性: 这是 xv6 独有的设计。在
allocproc() 分配进程槽位后、fork() 完成上下文复制前,进程处于 EMBRYO 状态。这防止了调度器在进程未完全初始化时错误地将其调度执行。
4.2 状态转换图
下图展示了 xv6 进程完整的生命周期状态转换。箭头上的标签标注了触发转换的内核函数或系统调用。
4.3 关键转换路径详解
4.3.1 创建路径:UNUSED → EMBRYO → RUNNABLE
这是 fork() 系统调用的核心路径。在 sys_fork() 中调用 fork(),后者调用 allocproc():
// kernel/proc.c
static struct proc*
allocproc(void)
{
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == UNUSED) {
p->state = EMBRYO; // 第一步:标记为 EMBRYO
release(&p->lock);
// 分配内核栈、trapframe 等资源
if((p->kstack = kalloc()) == 0){
p->state = UNUSED;
return 0;
}
// ...
return p;
} else {
release(&p->lock);
}
}
return 0;
}
随后 fork() 复制父进程的地址空间和上下文,最后将子进程状态设置为 RUNNABLE:
// kernel/proc.c
int
fork(void)
{
// ...
np = allocproc(); // 状态变为 EMBRYO
// 复制父进程内存、文件描述符、上下文...
// ...
acquire(&np->lock);
np->state = RUNNABLE; // 第二步:变为 RUNNABLE
release(&np->lock);
return pid;
}
EMBRYO 状态的保护作用: 如果
allocproc() 直接返回 RUNNABLE,调度器可能在 fork() 完成内存复制之前就调度子进程执行,导致子进程在未初始化的地址空间上运行。EMBRYO 状态确保了调度器不会选中尚未完全初始化的进程。
4.3.2 调度路径:RUNNABLE → RUNNING → RUNNABLE
调度器 scheduler() 在 main.c 启动后无限循环,扫描所有进程:
// kernel/proc.c
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
for(;;){
// 避免死锁
intr_on();
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
p->state = RUNNING; // RUNNABLE → RUNNING
c->proc = p;
swtch(&c->context, &p->context); // 上下文切换
// 进程让出 CPU 后回到此处
c->proc = 0;
}
release(&p->lock);
}
}
}
当进程主动调用 yield()(时钟中断触发)或 sleep() 时,会从 RUNNING 状态离开:
// kernel/proc.c
void
yield(void)
{
struct proc *p = myproc();
acquire(&p->lock);
p->state = RUNNABLE; // RUNNING → RUNNABLE
sched(); // 调用 swtch 切换回调度器
release(&p->lock);
}
4.3.3 退出路径:RUNNING → ZOMBIE → UNUSED
进程通过 exit() 系统调用终止自身:
// kernel/proc.c
void
exit(int status)
{
struct proc *p = myproc();
// 关闭所有打开的文件
for(int fd = 0; fd < NOFILE; fd++){
if(p->ofile[fd]){
fileclose(p->ofile[fd]);
p->ofile[fd] = 0;
}
}
// 唤醒父进程(如果父进程在 wait 中休眠)
wakeup(p->parent);
// 将子进程托付给 init 进程
reparent(p);
// 标记为 ZOMBIE,释放 CPU
acquire(&p->lock);
p->state = ZOMBIE; // RUNNING → ZOMBIE
sched();
// 不会返回
}
父进程调用 wait() 回收子进程:
// kernel/proc.c
int
wait(uint64 addr)
{
struct proc *p = myproc();
for(;;){
// 查找 ZOMBIE 子进程
for(np = proc; np < &proc[NPROC]; np++){
if(np->parent == p && np->state == ZOMBIE){
// 复制退出状态
// 释放子进程内核栈、trapframe
kfree((void*)np->kstack);
np->kstack = 0;
// 重置为 UNUSED
acquire(&np->lock);
np->state = UNUSED; // ZOMBIE → UNUSED
release(&np->lock);
return pid;
}
}
// 没有 ZOMBIE 子进程,休眠等待
sleep(p, &p->lock);
}
}
4.4 状态转换汇总表
| 源状态 | 目标状态 | 触发函数 | 系统调用/事件 | 说明 |
|---|---|---|---|---|
| UNUSED | EMBRYO | allocproc() |
fork() |
分配进程槽位,标记为正在创建 |
| EMBRYO | RUNNABLE | fork() |
fork() |
完成上下文复制,子进程就绪 |
| EMBRYO | UNUSED | allocproc() |
fork() 失败 |
资源分配失败,回退释放 |
| RUNNABLE | RUNNING | scheduler() |
调度器选择 | 分配 CPU 开始执行 |
| RUNNING | RUNNABLE | yield() |
时钟中断 | 时间片用完,主动让出 CPU |
| RUNNING | SLEEPING | sleep() |
等待事件 | 等待 I/O、锁或子进程 |
| SLEEPING | RUNNABLE | wakeup() |
事件到达 | 被唤醒,重新进入调度队列 |
| RUNNING | ZOMBIE | exit() |
exit() |
进程终止,保留退出状态 |
| ZOMBIE | UNUSED | wait() |
wait() |
父进程回收,槽位释放 |
4.5 状态机的不变量与安全约束
- 互斥性: 每个进程在同一时刻只能处于一种状态。状态转换必须持有
p->lock。 - 调度器独占 RUNNING: 每个 CPU 上最多只有一个进程处于
RUNNING状态,由c->proc指针标识。 - ZOMBIE 不可调度: 调度器只扫描
RUNNABLE状态的进程,ZOMBIE进程永远不会被选中执行。 - EMBRYO 不可见: 调度器不会选中
EMBRYO进程,因为其状态不是RUNNABLE。 - 父进程必须回收: 如果父进程先于子进程退出,子进程通过
reparent()托付给init进程(PID=1),由init负责wait()回收。
调试技巧: 在
proc.c 中可以通过 procdump() 函数打印所有进程的状态信息。该函数在 console.c 的 panic() 或 Ctrl+P 时被调用,是诊断进程状态卡死问题的第一工具。
4.6 状态机与系统调用的对应关系
下表将 fork 和 exec 系统调用映射到状态转换路径上:
| 系统调用 | 涉及的状态转换 | 对状态机的影响 |
|---|---|---|
fork() |
UNUSED → EMBRYO → RUNNABLE | 创建新进程,增加一个 RUNNABLE 节点 |
exec() |
RUNNING → RUNNING(状态不变) | 不改变状态,但替换地址空间和程序计数器 |
exit() |
RUNNING → ZOMBIE | 进程终止,等待父进程回收 |
wait() |
ZOMBIE → UNUSED | 回收子进程槽位,释放资源 |
sleep() |
RUNNING → SLEEPING | 进程阻塞,让出 CPU |
wakeup() |
SLEEPING → RUNNABLE | 唤醒阻塞进程,重新参与调度 |
关键洞察:
exec() 是唯一一个不改变进程状态的系统调用。它只在 RUNNING 状态下修改进程的内存映像和寄存器上下文,进程 ID、父进程关系、文件描述符表等保持不变。这意味着 exec() 后的进程仍然处于 RUNNING 状态,继续执行新的程序代码。