Process Lifetime & Scheduling

Why This Matters

Every program you run—from a shell command to a web server—goes through the same lifecycle managed by the kernel: it is created, it loads an executable, it runs, and eventually it terminates. Understanding this lifecycle is the foundation for all advanced OS topics: concurrency, IPC, resource management, and security. In xv6, the code is small enough to read end-to-end, making it a perfect model for seeing how each step actually works in C and assembly.


The Process Descriptor: struct proc

The kernel tracks every process with a struct proc (defined in proc.h). Think of it as the kernel's complete dossier on a process:

Field Purpose
sz Size of the process's address space in bytes
pgdir Pointer to the process's page-directory (its VM root)
kstack Base of the per-process kernel stack
state Current lifecycle state (see below)
pid Process ID
parent Pointer to the parent struct proc
tf Trap frame — user registers saved on a system call or trap
context Kernel registers saved during a context switch
chan If non-zero, the channel this process is sleeping on
killed Non-zero when the process has been marked for termination
ofile[] Array of open file pointers
cwd Current working directory inode
name Human-readable name (debugging only)

The two most important fields for scheduling are state and context. state tells the scheduler whether the process can run; context holds the kernel-side registers needed to resume it.


Process States

xv6 defines six states in an enum:

UNUSED → EMBRYO → RUNNABLE → RUNNING → SLEEPING → RUNNABLE
                                     ↓
                                   ZOMBIE
State Meaning
UNUSED Slot is free in the process table
EMBRYO Being initialized by allocproc() / fork()
RUNNABLE Ready to run; waiting for CPU
RUNNING Currently executing on a CPU
SLEEPING Blocked waiting for an event (I/O, lock, child exit)
ZOMBIE Exited but not yet reaped by the parent

Key constraint: EMBRYO → RUNNING cannot happen directly. A new process must pass through RUNNABLE first—the scheduler only picks RUNNABLE processes.


exec() — Loading an ELF Binary

When a process calls exec("cmd", argv), the kernel replaces the process's address space with the new program. The steps are:

  1. Read ELF header and program headers — verify the magic number and locate the load segments.
  2. Create a fresh page directory — a clean VM image so the old program is fully replaced.
  3. Allocate and copy segmentsallocuvm grows the address space; loaduvm copies each segment from disk.
  4. Build the user stack — push argv strings and set up a guard page below the stack.
  5. Commit — set rip (instruction pointer) to the ELF entry point, update rsp, and call switchuvm to activate the new page table.

If any step fails, the old process image is restored and exec returns an error—the calling process survives. Only after the commit point is the old image destroyed.

The relevant exec.c calls:

readi(ip, (char*)&elf, 0, sizeof(elf));          // read ELF header
allocuvm(pgdir, sz, ph.vaddr + ph.memsz);        // allocate VM for segment
loaduvm(pgdir, (char*)ph.vaddr, ip, ph.off, ph.filesz); // copy segment

exit() — Process Termination

When a process calls exit() (or receives a fatal signal), the kernel must clean up without fully freeing the process slot—because the parent may still need to collect the exit status.

void exit(void) {
    // 1. Close all open file descriptors
    for (fd = 0; fd < NOFILE; fd++) {
        if (proc->ofile[fd]) {
            fileclose(proc->ofile[fd]);
            proc->ofile[fd] = 0;
        }
    }
    // 2. Re-parent any children this process owns
    for (p = ptable.proc; p < &ptable.proc[NPROC]; p++) {
        if (p->parent == proc) {
            p->parent = initproc;           // adopt by init
            if (p->state == ZOMBIE) wakeup1(initproc);
        }
    }
    // 3. Wake the parent (it may be sleeping in wait())
    wakeup1(proc->parent);
    // 4. Become a zombie — do NOT free the process slot yet
    proc->state = ZOMBIE;
    sched();  // give up the CPU; will never return here
}

After exit(), the process is a zombie: it holds no resources (files, memory) except its struct proc entry and kernel stack, which are needed until the parent calls wait().


Zombie Processes

A zombie is a process that has called exit() but whose parent has not yet called wait(). The kernel must keep the struct proc slot because:

What keeps zombies from accumulating? Two mechanisms:

  1. Parent calls wait() — the normal case.
  2. Reparenting to init — if a parent exits before its children, those children become orphans and are adopted by initproc. Since init loops forever calling wait(), it eventually reaps all orphans.

If a parent never calls wait(), zombie slots accumulate in the process table. In pingpong.c, the console prints "zombie!" because the parent exits without calling wait(), and the shell detects the zombie slot.


wait() — Reaping a Zombie Child

int wait(void) {
    for (;;) {
        for (p = ptable.proc; p < &ptable.proc[NPROC]; p++) {
            if (p->parent != proc) continue;
            if (p->state == ZOMBIE) {
                // Free the child's kernel resources
                kfree(p->kstack);
                freevm(p->pgdir);
                p->state = UNUSED;     // slot is free again
                return pid;
            }
        }
        // No zombie child yet — sleep until one exits
        sleep(proc, &ptable.lock);
    }
}

wait() scans the process table for any child in ZOMBIE state. When found, it frees the kernel stack and page table, marks the slot UNUSED, and returns the child's PID. If no zombie child exists yet, the parent sleeps and is woken when a child calls exit().


sleep() and wakeup()

Processes block voluntarily using sleep(chan, lock):

void sleep(void *chan, struct spinlock *lk) {
    proc->chan = chan;       // remember what we're waiting for
    proc->state = SLEEPING;
    sched();                // give up the CPU
}

chan is an arbitrary pointer used as a "channel"—any kernel address can serve as a condition variable. wakeup(chan) sets all processes sleeping on chan back to RUNNABLE.

This is how wait() and exit() coordinate: the parent sleeps on itself as the channel; exit() calls wakeup1(proc->parent) to wake it.


scheduler() and swtch()

The scheduler runs on a dedicated per-CPU kernel stack, looping forever:

// called from mpmain() at the end of kernel initialization
for (p = ptable.proc; p < &ptable.proc[NPROC]; p++) {
    if (p->state != RUNNABLE) continue;
    proc = p;
    switchuvm(p);          // load process's page table into CR3
    p->state = RUNNING;
    swtch(&cpu->scheduler, p->context);  // switch to process's kernel stack
    switchkvm();           // restore kernel page table
}

swtch() (in swtch.S) is the low-level context switch: it saves the current callee-saved registers onto the old stack and restores them from the new stack. After swtch() returns, execution continues in the new process's kernel stack—wherever it last called sched().

struct context stores the kernel-mode registers needed to resume a process (callee-saved registers + eip). It is distinct from the trap frame (struct trapframe), which saves user-mode registers during a system call or hardware trap.


The Full Fork Lifecycle

When fork() creates a child, here is the complete path back to user space:

fork()
  └─ allocproc()          # allocate struct proc, kstack; set state = EMBRYO
       └─ sets p->context->eip = forkret
  
scheduler()
  └─ swtch(&cpu->sched, p->context)   # context switch into child's kernel stack
       └─ forkret()                   # first function child "returns" to
            └─ trapret()              # pops trap frame
                 └─ [user space]      # child starts executing at fork() return

Because allocproc sets the new process's context->eip to forkret, the first time the scheduler runs the child via swtch(), it lands in forkret, which then falls through to trapret and returns to user space as if returning from the original fork() system call—with return value 0 in the child.


Key Takeaways

Practice

  1. When the xv6 kernel is creating a new process via fork(), what is the first state assigned to it by allocproc()?
  2. Why does xv6 use a ZOMBIE state instead of immediately freeing a process when it calls exit()?
  3. What does wait() do when it finds a child process in the ZOMBIE state?
  4. Which state transition cannot happen directly in xv6?
  5. In xv6, which function actually saves and restores the CPU registers during a context switch between the scheduler and a user process?
  6. What is the purpose of struct context in xv6?
  7. Describe the sequence of steps that exec() takes to replace a process's address space with a new ELF binary. What would happen if exec() failed halfway through?
  8. A parent process forks a child, but the parent exits before calling wait(). What happens to the child process, and why doesn't its struct proc slot leak?
  9. Trace the path a newly forked child process takes from allocproc() all the way to executing its first user-space instruction. Explain the role of each step.
  10. In the pingpong example, the console prints 'zombie!' when the parent does not call wait(). Which of the following best explains why this is detected and printed?