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 进程完整的生命周期状态转换。箭头上的标签标注了触发转换的内核函数或系统调用。

xv6 进程状态转换图 xv6 进程六状态转换图 UNUSED EMBRYO RUNNABLE RUNNING SLEEPING ZOMBIE allocproc() fork() 完成复制 scheduler() 调度 yield() 时间片到 sleep() wakeup() exit() wait() 回收 状态转换关键规则: • UNUSED → EMBRYO:allocproc() 分配进程槽位,设置状态为 EMBRYO • EMBRYO → RUNNABLE:fork() 完成上下文复制,进程就绪 • RUNNING → ZOMBIE:exit() 释放大部分资源,保留 proc 结构 • ZOMBIE → UNUSED:wait() 读取退出状态,释放 proc 槽位

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 状态,继续执行新的程序代码。