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:
- Read ELF header and program headers — verify the magic number and locate the load segments.
- Create a fresh page directory — a clean VM image so the old program is fully replaced.
- Allocate and copy segments —
allocuvmgrows the address space;loaduvmcopies each segment from disk. - Build the user stack — push
argvstrings and set up a guard page below the stack. - Commit — set
rip(instruction pointer) to the ELF entry point, updatersp, and callswitchuvmto 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:
- The parent may call
wait()later and needs the child's exit status. - If the slot were freed immediately, the parent's
wait()would have no record of the child.
What keeps zombies from accumulating? Two mechanisms:
- Parent calls
wait()— the normal case. - Reparenting to
init— if a parent exits before its children, those children become orphans and are adopted byinitproc. Sinceinitloops forever callingwait(), 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
struct procis the kernel's complete record of a process;stateandcontextdrive scheduling.exec()replaces a process's address space with an ELF image in five stages; it is atomic from the caller's perspective.exit()closes files, reparents children, wakes the parent, and transitions toZOMBIE—it does not free the process slot.- A zombie exists to let the parent collect the exit status;
wait()is what actually frees the slot. - Orphaned processes are adopted by
init, which perpetually callswait()to reap them. sleep()/wakeup()are the building blocks for all blocking synchronization in xv6.scheduler()is a simple round-robin loop;swtch()does the actual register save/restore.struct contextstores kernel registers;struct trapframestores user registers—don't confuse them.- The
EMBRYO → RUNNINGtransition does not exist: new processes goEMBRYO → RUNNABLE → RUNNING.