Processes & the Process Model
A process is not a program — it is the living, breathing execution of one, complete with its own memory universe, identity, and a kernel-maintained dossier that makes multitasking possible.
Overview
Every time you run ls, open a browser tab, or fire up a game, the operating system conjures something remarkable: a process. Not the compiled binary sitting on disk — that is just a recipe. The process is the recipe being cooked: flour weighed, ovens hot, timers ticking. Understanding processes at this depth is the single most important conceptual step on the path to kernel work, because nearly every kernel subsystem — schedulers, memory managers, file descriptors, signals, namespaces — revolves around processes.
This module builds the full mental model from scratch. We start with the fundamental question "what problem do processes solve?" and work up through the data structures the kernel uses to track them, the state machine a process moves through during its life, the virtual address space illusion, the mechanics of context switching, and the beautiful Unix fork/exec model that lets one process spawn another. By the end, you will be able to read the kernel's task_struct definition and recognize exactly what each field is for.
One crucial distinction to lock in early: a program is a static artifact — an ELF binary on disk. A process is a dynamic entity — an instance of that program running, with its own private memory, its own program counter, its own open files, its own PID. The same program can produce many simultaneous processes; each is isolated from the others. This isolation is not magic — it is a collaboration between hardware (the MMU) and the kernel, and we will peel it apart layer by layer.
The concepts here underpin everything else in OS theory. Threads, virtual memory, IPC, containers — all are extensions or refinements of the process model. Invest the time here; it compounds enormously.
The Linux kernel's central job, at the most reduced description, is: run processes, share hardware fairly, keep them from interfering with each other. Every patch you will ever send to the kernel either touches a data structure that belongs to a process (task_struct, mm_struct, files_struct, …) or affects a policy that governs processes (scheduling, memory reclaim, signal delivery, cgroup accounting). Without a rock-solid mental model of what a process is and how the kernel tracks it, kernel code is an unnavigable sea of current-> dereferences and cryptic state flags.
More practically: the concepts in this module are the prerequisite for every subsequent track. Memory management builds on the process address space. The scheduler builds on process states and the run queue. Namespaces and cgroups are augmentations of the process model. Even device drivers interact with processes through file operations and wait queues. Mastering this module is not optional — it is the foundation on which the entire rest of KernelPath rests.
Process Lifecycle State Machine
Every process moves through these states during its lifetime. TASK_RUNNING covers both 'on-CPU' and 'runnable-waiting-for-CPU'. EXIT_ZOMBIE is the brief period between a process calling exit() and its parent calling wait() — the PCB survives only to deliver the exit code.
The concepts
01What Is a Process? Program vs. Process
A program is a passive entity: a file on disk (commonly ELF format on Linux) containing machine instructions, data, and metadata. It has no inherent concept of "where we are" in execution or what memory is in use.
A process is the program in motion. It is the OS abstraction that gives a program:
- Its own CPU context — a program counter, stack pointer, and register file that record exactly where execution is and what intermediate values are live.
- Its own virtual address space — the illusion that it owns all of memory, from address 0 to 2^64.
- Its own identity — a process ID (PID), user/group IDs, and a parent.
- Its own resources — open file descriptors, signal handlers, timers.
The analogy: a recipe (program) vs. a chef actively cooking it (process). The same recipe can be followed by ten chefs simultaneously and each will have their own state: one is dicing onions, another is reducing the sauce. Neither knows about the other's state.
On Linux, the kernel represents each process with a task_struct in kernel memory. When you call getpid() from C, the kernel returns current->pid where current is a per-CPU pointer to the task_struct of the currently running process. Note: Linux also uses task_struct for threads — threads are "lightweight processes" sharing an address space, but each has its own task_struct.
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("I am process %d\n", getpid());
printf("My parent is %d\n", getppid());
return 0;
}
// Compile: gcc -o whoami whoami.c
// Run twice at the same time:
// ./whoami & ./whoami
// Two processes, two different PIDs, same binary.
02The Process Control Block — task_struct
For the OS to manage a process, it must store a dossier about it somewhere in kernel memory. This dossier is the Process Control Block (PCB). In Linux, the PCB is struct task_struct, defined in include/linux/sched.h. It is enormous — over 700 fields in recent kernels — but conceptually falls into a handful of groups:
| Group | Key fields | Purpose |
|---|---|---|
| Identity | pid, tgid, comm | Who is this process? |
| State | __state, exit_state | What is it doing right now? |
| Scheduling | prio, sched_class, se (sched_entity) | How should it be scheduled? |
| Memory | mm (pointer to mm_struct) | What address space does it own? |
| File system | fs (pointer to fs_struct), files (pointer to files_struct) | CWD, open FDs |
| Signal | sighand, signal, pending | Signal handlers and pending signals |
| Credentials | cred (pointer to struct cred) | UID, GID, capabilities |
| Relationships | parent, children, sibling | Process tree |
| CPU context | thread (arch-specific) | Saved registers when not running |
When the kernel needs to switch from process A to process B, it saves A's CPU registers into A->thread, then loads B's registers from B->thread. The CPU then resumes B from exactly where it left off. This is context switching.
The task_struct is allocated via a slab allocator for efficiency. On x86-64, current is stored in the gs segment register base, making current->field a single memory access.
/* Tiny kernel module that prints the current task's name and PID.
Requires kernel headers and a kernel build environment. */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/sched.h>
static int __init mymod_init(void)
{
/* 'current' is a macro: per-CPU pointer to the running task_struct */
pr_info("Hello from PID %d (%s)\n",
current->pid, current->comm);
return 0;
}
static void __exit mymod_exit(void) {}
module_init(mymod_init);
module_exit(mymod_exit);
MODULE_LICENSE("GPL");
03Process States and the State Machine
A process is not always running — it may be waiting for I/O, sleeping, stopped by a signal, or becoming a zombie. The kernel tracks this with a state field in task_struct. Linux defines these primary states:
- TASK_RUNNING (R): The process is either currently executing on a CPU, or it is runnable and waiting for a CPU. There is no separate "ready" state in Linux — runnable and running share one state.
- TASK_INTERRUPTIBLE (S): Sleeping, waiting for an event (e.g., data from a socket). Can be woken by a signal. This is the most common sleep state — most shell commands show as 'S' when idle.
- TASK_UNINTERRUPTIBLE (D): Sleeping, waiting for an event, but cannot be woken by a signal. Used when waking up mid-operation would corrupt kernel data structures — classically, waiting for disk I/O. A process stuck in D-state is often a sign of a hardware or driver problem.
- TASK_STOPPED (T): Execution stopped by SIGSTOP or a debugger (SIGTSTP). Will resume when SIGCONT is sent.
- TASK_TRACED (t): Being traced by ptrace (i.e., a debugger like gdb has attached).
- EXIT_ZOMBIE (Z): The process has called
exit()and terminated, but its parent has not yet calledwait()to collect its exit status. The PCB is still alive purely to hold the exit code. Afterwait(), the PCB is freed and the process ceases to exist. - EXIT_DEAD: After
wait()collects the status. The process disappears entirely.
The transitions between states are driven by system calls, signals, and kernel events. For example: a process reading a slow disk goes R → D → R (when the read completes). A process receiving SIGSTOP goes R → T → R (on SIGCONT).
You can observe states with ps aux (the STAT column) or cat /proc/<pid>/status.
# Observe process states live:
ps aux
# STAT column: S=sleeping, R=running, D=uninterruptible, Z=zombie, T=stopped
# To create a zombie intentionally (educational):
# In C: fork(), parent sleeps, child exits — child becomes Z
# until parent calls wait()
python3 -c "
import os, time
pid = os.fork()
if pid == 0:
os._exit(0) # child exits immediately
else:
print(f'Child PID {pid} — check it with: ps -p {pid} -o pid,stat')
time.sleep(10) # parent delays wait() — child is Z for 10 seconds
os.wait()
"
04The Virtual Address Space
Each process believes it owns the entire memory address space — on a 64-bit Linux system, that is a 128 TiB user-space region (addresses 0x0000000000000000 to 0x00007FFFFFFFFFFF). This belief is the virtual address space, and it is an illusion maintained by hardware (the Memory Management Unit, or MMU) and the kernel.
A process's address space is divided into segments (regions called VMAs — Virtual Memory Areas in Linux):
High addresses (stack grows down)
+--------------------+
| Stack | local variables, function call frames
| (grows downward) |
+--------------------+
| (unmapped gap) | accessing this → segfault
+--------------------+
| Memory-mapped | mmap'd files, shared libs (e.g., libc.so)
| regions |
+--------------------+
| Heap | malloc/free live here
| (grows upward) |
+--------------------+
| BSS segment | zero-initialized global/static vars
+--------------------+
| Data segment | initialized global/static vars
+--------------------+
| Text segment | executable code (read + execute, not write)
+--------------------+
Low addresses
Why virtual addresses? Two reasons:
- Isolation: Process A's virtual address 0x1000 maps to a different physical page than process B's virtual address 0x1000. Neither can touch the other's memory.
- Flexibility: The physical RAM on a machine is fixed, but each process can have a sparse virtual address space far larger — unused virtual regions consume no physical RAM.
In the kernel, a process's address space is represented by struct mm_struct. Within it, a linked list (and red-black tree) of struct vm_area_struct (VMA) records each mapped region: its start/end virtual address, permissions (read/write/execute), and what backs it (a file, anonymous memory, etc.).
When a process accesses an address for the first time, the MMU raises a page fault. The kernel's page fault handler checks if the address is in a valid VMA. If yes, it allocates a physical page and maps it. If no, it sends SIGSEGV — the infamous segmentation fault.
#include <stdio.h>
#include <stdlib.h>
int global_init = 42; /* data segment */
int global_uninit; /* BSS segment */
int main(void) {
int local = 7; /* stack */
int *heap = malloc(16); /* heap */
*heap = 99;
/* /proc/self/maps shows every VMA in this process's address space */
FILE *f = fopen("/proc/self/maps", "r");
char line[256];
while (fgets(line, sizeof(line), f))
printf("%s", line);
fclose(f);
free(heap);
return 0;
}
05Context Switching — The Art of Freezing Time
The OS creates the illusion that many processes run simultaneously on one CPU by switching between them rapidly — typically every few milliseconds. This is context switching.
A context switch involves:
- Saving the CPU state of the outgoing process: program counter (instruction pointer), stack pointer, all general-purpose registers, floating-point state, and control registers (like CR3 on x86, which holds the page-table base). These are stored in the process's
task_struct.thread(arch-specific struct). - Switching the memory map: loading the page-table base register with the new process's page table root. On x86-64, this means writing to CR3. This flushes the TLB (Translation Lookaside Buffer) unless PCID (Process Context IDs) is used to tag TLB entries by process.
- Restoring the CPU state of the incoming process from its
task_struct.thread. - Returning to user space: the CPU resumes execution at the incoming process's saved program counter.
The context switch itself happens in kernel mode, typically inside schedule() which calls context_switch() which calls the arch-specific switch_to() macro. On x86-64, switch_to() uses a small assembly trampoline to swap stack pointers and save/restore non-volatile registers.
Cost of a context switch: A context switch costs roughly 1–10 microseconds of CPU time (saving/restoring registers, TLB flush if no PCID). On a modern system with 100 processes, you might do ~1000 switches per second per CPU — a tiny overhead. But the indirect cost — cache pollution (the new process's working set is cold in L1/L2 cache) — can be significant for latency-sensitive workloads.
Voluntary vs. involuntary preemption: A process can voluntarily yield the CPU (e.g., by calling sleep() or blocking on I/O). Or the kernel can involuntarily preempt it when its time slice expires (driven by a timer interrupt). Linux is a preemptive kernel — even processes that never yield will eventually be preempted.
/* Observe context switch counts for a process:
/proc/<pid>/status shows voluntary_ctxt_switches
and nonvoluntary_ctxt_switches */
/* Time a context switch experimentally:
Two processes communicate via a pipe;
each ping-pong counts as 2 context switches */
#include <stdio.h>
#include <unistd.h>
#include <time.h>
int main(void) {
int p1[2], p2[2];
pipe(p1); pipe(p2);
if (fork() == 0) { /* child */
char buf;
for (int i = 0; i < 100000; i++) {
read(p1[0], &buf, 1);
write(p2[1], &buf, 1);
}
return 0;
}
char buf = 'x';
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < 100000; i++) {
write(p1[1], &buf, 1);
read(p2[0], &buf, 1);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
long ns = (t1.tv_sec - t0.tv_sec) * 1000000000L
+ (t1.tv_nsec - t0.tv_nsec);
printf("Avg context switch: %ld ns\n", ns / 200000);
return 0;
}
06fork() — Cloning a Universe
fork() is one of the most elegant system calls ever designed. When a process calls fork(), the kernel creates an almost-identical copy of the calling process. The child gets:
- A new PID
- A copy of the parent's virtual address space (via Copy-on-Write)
- A copy of the parent's file descriptor table (sharing the same underlying open file descriptions)
- A copy of the parent's signal handlers
- Its own program counter — set to the instruction immediately after the
fork()call
fork() returns twice: once in the parent (returning the child's PID), and once in the child (returning 0). This is how a single call produces two execution paths.
Copy-on-Write (CoW): Copying gigabytes of address space on every fork would be catastrophically slow. Instead, after fork, parent and child share the same physical pages, but those pages are marked read-only in both page tables. The moment either process tries to write to a shared page, the MMU triggers a page fault. The kernel then makes a private copy of just that one page for the writing process — this is CoW. If the child immediately calls exec(), most pages are never written and never copied.
The shell uses fork every time you run a command. When you type ls in bash:
- bash calls
fork()— now there are two bash processes - The child calls
exec("ls", ...)— replacing its address space with thelsbinary - The parent calls
wait()— suspending until the child exits - The child
lsruns, prints output, exits - The parent (bash) resumes and prints the next prompt
This fork/exec separation is a deliberate design choice: it lets you set up the child's environment (redirect file descriptors, change the working directory, set resource limits) between the fork and exec, using ordinary system calls rather than complex exec API arguments.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
printf("Before fork: PID=%d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
perror("fork"); exit(1);
} else if (pid == 0) {
/* --- CHILD --- */
printf("Child: PID=%d, parent=%d\n", getpid(), getppid());
exit(42); /* exit with code 42 */
} else {
/* --- PARENT --- */
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
printf("Parent: child exited with %d\n",
WEXITSTATUS(status));
}
return 0;
}
07exec() — Becoming a New Program
exec() is the complement to fork(). Where fork() copies the calling process, exec() replaces the calling process's address space entirely with a new program loaded from disk. After a successful exec(), the process has:
- A brand new text, data, BSS, heap, and stack (loaded from the new ELF binary)
- The same PID — the process identity is preserved, only the content changes
- Most file descriptors still open (unless marked
O_CLOEXEC) - The same PID, PPID, and process group
The exec family of functions (execv, execvp, execve, execl, execlp, execle) all do the same thing with different argument conventions. The true system call is execve(path, argv, envp) — all others are library wrappers.
What exec does internally:
- Opens and parses the ELF binary (or interprets the
#!shebang line for scripts) - Maps the ELF segments into the new virtual address space
- Sets up the initial stack with
argc,argv[], andenvp[] - Transfers control to the ELF entry point (
_startin glibc, which eventually callsmain())
The shebang trick: When you run ./script.py, the kernel reads the first two bytes. If they are #!, it treats the rest of the first line as the interpreter path. It is exec that implements this — the kernel itself handles #!.
exec never returns on success. If your code continues after execve(), the exec failed — check errno.
#include <stdio.h>
#include <unistd.h>
int main(void) {
char *const argv[] = { "ls", "-la", "/tmp", NULL };
char *const envp[] = { "HOME=/root", NULL };
printf("About to exec ls — this line prints\n");
execve("/bin/ls", argv, envp);
/* execve only reaches here on failure */
perror("execve failed");
return 1;
/* The printf below never runs if execve succeeds */
}
08wait() and the Zombie Problem
When a process calls exit(), it does not immediately disappear. The kernel preserves a small rump of the process — its PID and exit status — in case the parent wants to know how things went. A process in this state is a zombie (state Z in ps).
The parent retrieves the exit status by calling wait() or waitpid(). This is called reaping the child. Once reaped, the kernel frees the last remnants of the task_struct and the PID becomes available for reuse.
The zombie leak problem: If the parent never calls wait(), the zombie accumulates. One zombie is harmless (it uses only a tiny amount of kernel memory and one PID). But a process that spawns thousands of children without reaping them can exhaust the PID table (default max: 32768 on Linux, configurable via /proc/sys/kernel/pid_max). This is a real production bug in long-running servers.
Orphans and init adoption: If the parent exits before the child, the child becomes an orphan. The kernel re-parents all orphans to the init process (PID 1 — systemd on modern systems). Since init always calls wait(), orphan children are always reaped cleanly.
SIGCHLD: The kernel sends SIGCHLD to the parent whenever a child changes state (exits, stops, continues). Servers often set up a SIGCHLD handler that calls waitpid(-1, NULL, WNOHANG) in a loop to reap all ready children without blocking.
Practical pattern: Always call waitpid() after fork(). Use WNOHANG if you do not want to block, or handle SIGCHLD.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
/* SIGCHLD handler: reaps all finished children */
static void reap_children(int sig) {
int status;
pid_t pid;
/* Loop because multiple children may have exited */
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("Reaped child %d, exit=%d\n",
pid, WEXITSTATUS(status));
}
}
int main(void) {
signal(SIGCHLD, reap_children);
for (int i = 0; i < 5; i++) {
if (fork() == 0) {
/* child: sleep briefly then exit */
usleep(i * 100000);
_exit(i);
}
}
/* parent does other work; SIGCHLD reaps children async */
sleep(2);
return 0;
}
09The Process Lifecycle — Birth to Death
Every process in Linux is born from another process (except PID 1, which the kernel spawns directly). This creates a tree rooted at PID 1. Let's trace the complete lifecycle:
Birth: fork() (or the more modern clone() with flags) creates the process. The kernel allocates a new task_struct, copies the parent's mm_struct (with CoW page tables), copies the files_struct, assigns a new PID, and adds the new task_struct to the scheduler run queue.
Execution: The process runs on a CPU. It may make system calls, block on I/O (entering TASK_INTERRUPTIBLE or TASK_UNINTERRUPTIBLE), receive signals, and spawn its own children.
exec: At any point the process can replace itself with a new program via execve(). Its PID stays the same but its memory image is reloaded.
Death: The process calls exit() (or _exit(), or returns from main() which calls exit()). The kernel:
- Sets state to EXIT_ZOMBIE
- Releases most resources: closes all file descriptors, frees the heap, releases VMAs
- Sends SIGCHLD to the parent
- Keeps a skeleton
task_structwith just the PID and exit status
Reaping: The parent calls wait()/waitpid(). The kernel delivers the exit status, frees the last task_struct, and releases the PID.
Special case — PID 1: If init/systemd itself dies, the kernel panics. There is a watchdog mechanism for this.
Observing the tree: pstree shows the full process tree. ps -ef shows PPID (parent PID) for each process. The tree is always a single rooted tree — every process has exactly one parent (after re-parenting of orphans).
clone() vs fork(): In the Linux kernel, fork() is actually implemented via clone() with specific flags. clone() is the more general system call used to create both processes and threads, with fine-grained control over what is shared (address space, file table, signal handlers, etc.).
/* Visualize the process tree from inside C */
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
/* Spawn a grandchild to show a 3-level tree */
pid_t child = fork();
if (child == 0) {
pid_t grandchild = fork();
if (grandchild == 0) {
/* grandchild: just sleep so pstree can see it */
printf("Grandchild PID=%d, parent=%d\n",
getpid(), getppid());
sleep(5);
_exit(0);
}
wait(NULL); /* child reaps grandchild */
_exit(0);
}
printf("Parent PID=%d, child=%d\n", getpid(), child);
/* In another terminal: pstree -p <parent_pid> */
wait(NULL);
return 0;
}
10The /proc Filesystem — A Window into Living Processes
Linux exposes process information through the /proc virtual filesystem — a pseudo-filesystem that exists only in RAM and is generated on-demand by the kernel. There is no disk backing it. Reading /proc/1234/status causes the kernel to format the task_struct of PID 1234 into text and hand it to you.
Key /proc/<pid>/ entries:
| File | Contents |
|---|---|
status | Human-readable summary: name, PID, PPID, state, memory usage, UIDs |
stat | Machine-readable stats (used by ps, top) — includes state, scheduling stats, parent PID |
maps | All VMAs: address range, permissions, backing file |
smaps | Detailed per-VMA memory stats (RSS, PSS, private/shared) |
fd/ | Directory of symlinks, one per open file descriptor |
cmdline | Full command line (null-separated) |
environ | Environment variables at exec time |
exe | Symlink to the executable file |
mem | Raw virtual memory (readable with ptrace authorization) |
/proc/self/ is a magic symlink that always points to the current process's directory. This is how top and htop work — they read /proc/<pid>/stat for every PID they find by listing /proc/.
This is also how debuggers find information about a process without kernel module support — pure POSIX file I/O. Understanding /proc is essential for writing system tools and for kernel debugging.
#!/bin/bash
# Quick process inspector using only /proc
PID=${1:-$$} # default: inspect ourselves
echo "=== Process $PID ==="
grep -E '^(Name|State|Pid|PPid|VmRSS|Threads)' /proc/$PID/status
echo
echo "=== Open file descriptors ==="
ls -la /proc/$PID/fd 2>/dev/null | head -20
echo
echo "=== Memory map (first 10 VMAs) ==="
head -10 /proc/$PID/maps
echo
echo "=== Executable ==="
readlink /proc/$PID/exe
Key insights
- ✦A process is not a program. The binary on disk is inert; the process is the living instance — with CPU state, memory, and identity. Confusing the two is the most common conceptual error beginners make.
- ✦fork() returns twice — once in parent, once in child. The separation between fork() and exec() is a deliberate Unix design that gives you a window to reconfigure the child before it runs a new program (redirecting stdout to a file, closing sockets, setting resource limits).
- ✦Copy-on-Write after fork() means you pay only for the pages you actually modify — forking a 1 GB process is nearly instantaneous because no physical copying happens until a write occurs.
- ✦The D-state (TASK_UNINTERRUPTIBLE) is the kernel telling you: 'this process is in the middle of something that cannot be interrupted without data corruption.' A stuck D-state process almost always indicates a kernel or driver bug, not a user-space problem.
- ✦The /proc filesystem is not a real filesystem. Every read from /proc is a kernel function call in disguise. This is how you can inspect live kernel state with nothing but cat.
- ✦Every process in Linux (except PID 1) has a parent. The process table is a tree, and this tree structure is fundamental to how signals, wait(), and resource accounting work.
- ✦Zombie processes are not bugs by themselves — they are a necessary feature that lets parents retrieve exit codes. The bug is failing to reap them in a timely manner.
- ✦context_switch() is the heartbeat of multitasking: save registers, swap page tables, restore registers. Everything else — scheduling policy, fairness, latency — is about deciding *when* to call it.
Common pitfalls
- ⚠Calling printf() before fork() and not flushing: printf uses a user-space buffer. If you fork before flushing, the buffer is duplicated and you get doubled output. Always fflush(stdout) or use write() syscall before fork().
- ⚠Forgetting to call wait() after fork(): the child becomes a zombie. In long-running servers that fork frequently without reaping, this exhausts the PID table.
- ⚠Checking the wrong return value from fork(): fork() returns 0 to the child, the child's PID to the parent, and -1 on error. Beginners often check if (pid) meaning 'parent' — this is correct but fragile. Always handle the -1 error case.
- ⚠Assuming exec() returns on success: after a successful execve(), your code after the call is gone. If you reach the line after execve(), it failed. Always call perror() there.
- ⚠Confusing exit() and _exit(): exit() flushes stdio buffers and runs atexit handlers; _exit() goes straight to the kernel. In a child after fork(), always use _exit() to avoid flushing the parent's duplicated buffers.
- ⚠Reading /proc/PID/stat by field number without understanding that the 'comm' field (field 2) can contain spaces and parentheses, breaking naive whitespace splitting — use the kernel's documented parsing (find the last ')' to split the fields).
- ⚠Believing that a high zombie count always means a bug: a brief spike of zombies during rapid fork/exit cycles is normal. A steady accumulation that never drains is the problem.
- ⚠Assuming virtual address equality means memory sharing: two processes can have the same virtual address (e.g., 0x400000 for their text segment) but these map to completely different physical pages. Virtual addresses are process-local.
Practice
- 01easy
Write a C program that forks 5 children. Each child should print its PID and its position (1st, 2nd, etc.) then exit with that position number as its exit code. The parent should wait for all children and print each one's exit code in the order they finish (not birth order). Verify that children may finish out of order.
- 02medium
Build a minimal shell that supports running external commands (like /bin/ls, /bin/echo) using fork+execvp, with I/O redirection for stdout to a file (e.g., 'ls > out.txt'). The shell should print a prompt, read a line, parse the command and optional '>output' token, then fork, set up the redirect using dup2() in the child, and exec. The parent waits and loops.
- 03medium
Write a process inspector in C that reads /proc/<pid>/maps and /proc/<pid>/status for a given PID (passed as argv[1]) and prints: total number of VMAs, total virtual memory size in KB, resident set size (RSS) in KB, and lists any VMA that is executable (has 'x' in its permissions). Test on a real process like your shell.
- 04medium
Implement a zombie factory and cure: write a program that creates 20 zombie processes (forks 20 children, children exit immediately, parent deliberately delays wait()). While zombies are live, use ps to observe them. Then implement the cure: install a SIGCHLD handler that reaps all available children with waitpid(-1, NULL, WNOHANG) in a loop. Confirm that after the signal handler fires, the zombies disappear.
- 05hard
Measure the cost of a context switch on your machine using the pipe round-trip method: fork one child, communicate via two pipes, have parent and child alternate sending a single byte 200,000 times, measure total wall time, divide by 400,000 (each byte crosses 2 context switches). Compare your result against the theoretical cache miss cost (~100ns per L3 miss). How many cache misses does a context switch 'feel like'?
Resources
The gold standard free OS textbook. The Processes and Process API chapters cover this module's content with exceptional clarity and humor.
The definitive reference for fork() semantics, including exactly what is and is not inherited by the child.
Covers the full execve semantics including ELF loading, shebang handling, and what survives across exec.
Complete documentation of the /proc filesystem — every field in /proc/PID/status, stat, maps, and more.
The clearest bridge between theory and Linux kernel source. Chapter 3 maps directly to this module.
More exhaustive than Love's book; goes deeper into kernel internals including the assembly-level context switch.
The actual task_struct definition. Cross-referenced and searchable via Bootlin's Elixir. Read it alongside Love's book.
The implementation of fork(), copy_process(), and clone(). Seeing the source of copy_process() makes the PCB copying concrete.
LWN article on context switch costs and the PCID optimization for TLB management. Good deep-dive into real overhead.
KernelNewbies wiki is a gentle entry point into kernel concepts for newcomers, with many worked examples.
Checkpoints — you can now…
- ✓You can explain to someone else why fork() returns twice and what the return value means in each case, without looking it up.
- ✓You can open /proc/<pid>/maps for any running process and identify which VMA is the stack, heap, text segment, and shared libraries.
- ✓You can write a C program that forks a child, has the child exec a new program with a file descriptor redirect (e.g., stdout to a file) using dup2(), and has the parent correctly reap the child.
- ✓You can describe all six primary process states (R, S, D, T, Z, X) and give a concrete real-world scenario that puts a process in each state.
- ✓You can explain Copy-on-Write after fork() — what triggers an actual page copy, and why it makes fork() fast even for large processes.
- ✓You can look at a task_struct field (e.g., mm, files, sighand) and explain what kernel subsystem it belongs to and why it is a pointer rather than embedded inline.
- ✓You can write a SIGCHLD handler that correctly reaps all zombie children without blocking, using waitpid(-1, NULL, WNOHANG) in a loop.
- ✓You can describe the sequence of kernel operations during a context switch: what is saved, where it is saved, what register changes, and why the TLB may be flushed.