Process Creation with fork and exec
Every shell command a user types spawns a new process. Yet the operating system never runs a "create arbitrary program" system call directly. Instead, Unix-family systems build all process creation from a strange, elegant primitive called `fork`. This primitive duplicates the calling process. It is paired with a family of `exec` calls that replace a process's memory image with a new program. Understanding why process creation was split into two orthogonal operations is rewarding. This is true rather than having one "spawn" call. It is one of the most rewarding ideas in operating systems. It explains shells, pipelines, daemons, and servers all at once. The concrete motivating problem is this: how does a running program launch another program? It must wait for it to finish, and collect its result. All the while, the operating system keeps track of who owns what memory. It tracks what happens to open files. It tracks how exit status is communicated back. A naive design might bundle "make a new process" and "load a new program into it" into a single call. Unix instead separates these concerns. That separation is what lets a shell insert redirection and pipes between the fork and the exec. This trick would be far more awkward with a monolithic `spawn`. The big picture connects directly to concepts already familiar. Every process is represented by a Process Control Block that the kernel manages. `fork`/`exec`/`wait` are just System Calls that manipulate PCBs from user code. `fork` clones a PCB, and the address space it describes. `exec` overwrites the address space and instruction pointer. It keeps the same PCB and process ID. `wait` lets a parent synchronize with a child's termination. It also reaps the child's exit status. Together these three calls define the parent-child process hierarchy. Every Unix, Linux, and macOS system is built on this hierarchy. This article delivers a precise operational model of `fork`, `exec`, and `wait`. It provides a step-by-step trace of what the kernel does at each call. It offers a correctness argument for why fork's "copy" semantics are safe and efficient via copy-on-write. It includes a worked shell-pipeline example. It also discusses the classic bugs — zombies, orphans, and race conditions. These arise when this three-call dance is done carelessly.
- `fork()` creates a near-identical copy of the calling process; both continue executing from the same point, distinguished only by the return value.
- `exec()` family calls replace a process's code, data, and stack with a new program, but keep the same process ID and open file descriptors.
- `wait()`/`waitpid()` let a parent block until a child terminates and retrieve its exit status, avoiding "zombie" accumulation.
- Copy-on-write makes `fork` cheap: pages are shared read-only until either process writes, at which point they are duplicated.
- The classic idiom `fork` + `exec` + `wait` is how every shell launches commands, letting the child's environment be tuned before the new program loads.
- Failing to `wait` on a terminated child leaves a zombie PCB; a parent dying before its child creates an orphan reparented to `init`/`systemd`.
Separating "duplicate this process" (`fork`) from "load a new program" (`exec`) lets a shell customize a child's environment before the new program ever runs.
The idea
A process is a running instance of a program, represented in the kernel by a Process Control Block holding its process ID (PID), memory map, open file table, register state, and scheduling information. Process creation is the act of producing a new PCB and address space so that a second, independently schedulable process comes into existence. In Unix-derived systems this is accomplished with `fork()`, a system call that clones the calling process — called the parent — into a new process called the child, which is an almost exact copy: same code, same data (initially), same open files, but a distinct PID and a distinct address space going forward. `fork()` alone would only ever let you run copies of the same program. The second half of the idiom is `exec()` (in practice one of `execve`, `execvp`, `execl`, etc.), which does not create a new process at all — it replaces the calling process's text, data, heap, and stack segments with those of a new executable file, resetting the instruction pointer to that program's entry point. Crucially, `exec` preserves the PID, the parent-child relationship, and (by default) the open file descriptor table. This is the key design insight: file descriptors opened or redirected before `exec` survive into the new program, which is exactly how shell redirection (`cmd > file.txt`) and pipelines (`cmd1 | cmd2`) are implemented without any special-casing inside `cmd1` or `cmd2` themselves. The third piece, `wait()` / `waitpid()`, lets a parent synchronize with child termination. When a child calls `exit()`, the kernel does not immediately destroy its PCB; it transitions the child to a zombie state, retaining its exit status until some ancestor calls `wait` to "reap" it. This design exists so that exit status — a single integer, otherwise unrecoverable once a process is gone — can be reliably delivered to whichever ancestor cares, even if that ancestor is busy doing something else when the child actually finishes.
How it works
The full fork–exec–wait protocol, as used by every Unix shell, proceeds in five conceptual steps. First, the parent calls `fork()`. The kernel duplicates the parent's PCB, giving the child a new PID and (logically) a full copy of the address space; both parent and child now resume execution at the instruction immediately after the `fork()` call, distinguished only by `fork`'s return value — zero in the child, the child's PID in the parent, and a negative value in the parent if creation failed. Second, the child (identified by the zero return value) typically performs setup: closing or duplicating file descriptors for redirection, changing its working directory, or resetting signal handlers. Third, the child calls one of the `exec` functions, naming the new program to run; on success this call never returns, because the calling process's image has been replaced entirely. Fourth, the parent — running concurrently — calls `wait()` or `waitpid()`, blocking until the child terminates, or polling if it passed `WNOHANG`. Fifth, the kernel delivers the child's exit status to the parent and reclaims the zombie PCB. ``` // Parent process, e.g. a shell executing one command pid = fork() if pid < 0: // fork failed: no child was created report_error("fork failed") return FAILURE else if pid == 0: // ----- child branch ----- // Optional: redirect stdin/stdout/stderr, close unneeded fds setup_redirections() execve(program_path, argv, envp) // exec only returns on failure (e.g. file not found, bad permissions) report_error("exec failed") exit(127) // conventional "command not found" status else: // ----- parent branch, pid = child's PID ----- child_status = 0 result_pid = waitpid(pid, &child_status, 0) // block until this child exits if WIFEXITED(child_status): code = WEXITSTATUS(child_status) report("child exited with code", code) else if WIFSIGNALED(child_status): sig = WTERMSIG(child_status) report("child killed by signal", sig) ``` Two subtleties make this idiom work correctly rather than merely "usually work." First, `fork()` is called exactly once, but it returns twice — once in each process — which is why the branch on `pid` is essential; forgetting the `pid == 0` check means both processes run the parent's logic. Second, the ordering of `wait` relative to `exec` matters not at all for correctness (the kernel schedules parent and child independently), but the ordering of redirection setup relative to `exec` is critical: file descriptor manipulation must happen in the child, after `fork` but before `exec`, precisely because `exec` inherits the descriptor table as it stands at call time. A minimal pseudocode sketch of the kernel-side `fork` implementation clarifies what "copy" really means: ``` // Kernel-side fork() implementation sketch function kernel_fork(parent_pcb): child_pcb = allocate_pcb() child_pcb.pid = generate_new_pid() child_pcb.parent_pid = parent_pcb.pid child_pcb.registers = copy(parent_pcb.registers) child_pcb.open_files = copy(parent_pcb.open_files) // fd table duplicated, // underlying file objects shared // Address space: do NOT physically copy pages yet. for each page_table_entry in parent_pcb.page_table: child_entry = duplicate_entry(page_table_entry) child_entry.writable = false // mark both copies read-only page_table_entry.writable = false mark_as_shared(page_table_entry.physical_frame) // increment refcount child_pcb.page_table = built from above entries child_pcb.state = READY scheduler.enqueue(child_pcb) // Set up return values: child sees 0, parent sees child_pcb.pid parent_pcb.registers.return_value = child_pcb.pid child_pcb.registers.return_value = 0 return // control returns to both, via normal scheduling ``` And the corresponding `exec` sketch: ``` // Kernel-side exec() implementation sketch function kernel_exec(pcb, program_path, argv, envp): file = open_executable(program_path) if file is invalid: return ERROR // pcb is left completely unchanged new_image = parse_and_load(file) // read ELF/PE headers, segments destroy_address_space(pcb) // discard old text/data/heap/stack pcb.page_table = map_segments(new_image) pcb.registers.instruction_pointer = new_image.entry_point pcb.registers.stack_pointer = setup_stack(argv, envp) // pcb.pid, pcb.parent_pid, pcb.open_files are UNCHANGED return // never returns to caller's old code — new program begins executing ```
Complexity & correctness
Time complexity of `fork`. A naive fork that eagerly copies every page of the parent's address space takes time proportional to the resident memory size, where is the number of resident pages, because each page must be duplicated. Real systems avoid this using copy-on-write (COW): `fork` only duplicates the page table — an operation where is the number of page table entries, typically far smaller than and bounded by the address space's mapped region — and marks all shared physical frames read-only in both processes. No physical page is actually copied until one of the two processes attempts to write to it, at which point the kernel intercepts the resulting protection fault, allocates a fresh frame, copies just that one page, and resumes execution. This defers cost from at fork-time to per page actually modified, which is essential because the overwhelmingly common case — `fork` immediately followed by `exec` — never touches most of the parent's memory at all before it's discarded.
Correctness of copy-on-write. The invariant COW must preserve is: each process, at every point in its execution, observes memory contents identical to what an eager, full copy would have produced. This holds because (1) immediately after `fork`, both processes' page tables point to the same physical frames with identical data, satisfying the invariant trivially; and (2) any write by either process is trapped by the write-protect bit before it can occur, at which point the kernel copies the specific page being written, remaps only the writing process's page table entry to the new frame, and only then permits the write to proceed. Because the trap-and-copy happens strictly before the write takes effect, the other process's view of that page is never disturbed — it still sees the original, shared frame. Reference counting on physical frames ensures the last process to still share a page (rather than the first) is responsible for eventually freeing it, avoiding both premature frees and permanent leaks.
Time complexity of `exec`. `exec` must read and parse an executable's headers (proportional to header size, effectively constant for a fixed format), tear down the old address space's page table entries ( in the old page count), and construct new mappings for the new program's segments ( in the new page count). Because most segments are demand-paged rather than loaded eagerly, `exec` does not scale with the executable's total size on disk, only with the number of segments and the argument/environment vector length used to build the initial stack — an term where is the total bytes of `argv`/`envp`.
Correctness of `wait`. The invariant is that every terminated child's exit status is deliverable to exactly one waiting call in an ancestor, even under arbitrary scheduling delays. The kernel guarantees this by never discarding a terminated child's PCB outright; instead it demotes the PCB to a zombie, retaining only the exit status and accounting information, and defers full deallocation until a `wait` call consumes it. This is why zombies are cheap (they hold no address space, no file descriptors — those are released at `exit` time) but not free: an unreaped zombie still occupies a PCB slot and a PID, which is finite. The tradeoff is explicit: Unix sacrifices a small, bounded amount of kernel memory per terminated-but-unreaped child in exchange for a lossless notification channel that survives arbitrary scheduling races between exit and wait.
Space complexity. A `fork` costs additional kernel memory for the duplicated page table and PCB, independent of how much data is later written (COW pages are allocated lazily, "pay as you go"). A zombie costs — a PCB stub — regardless of how large the process was in life, since its address space was already reclaimed at exit.
Worked example
Consider a simplified shell implementing a two-stage pipeline, `ls | wc -l`, which counts how many files are in the current directory. This exercises `fork`, `exec`, file descriptor manipulation, and `wait` together.
| Step | Actor | Action | Effect |
|---|---|---|---|
| 1 | Shell | `pipe(fd)` | Kernel creates a pipe; `fd[0]` = read end, `fd[1]` = write end, both inherited by future children |
| 2 | Shell | `fork()` → `pid1` | Child A created; both shell and child A now hold copies of `fd[0]`, `fd[1]` |
| 3 | Child A | `dup2(fd[1], STDOUT_FILENO)` | Child A's stdout now points at the pipe's write end |
| 4 | Child A | close `fd[0]`, close `fd[1]` | Only the duplicated stdout descriptor remains open to the pipe |
| 5 | Child A | `execve("/bin/ls", …)` | `ls`'s image replaces Child A; it writes filenames to stdout, i.e., into the pipe |
| 6 | Shell | `fork()` → `pid2` | Child B created, independent of Child A |
| 7 | Child B | `dup2(fd[0], STDIN_FILENO)` | Child B's stdin now points at the pipe's read end |
| 8 | Child B | close `fd[0]`, close `fd[1]` | Only the duplicated stdin remains open to the pipe |
| 9 | Child B | `execve("/usr/bin/wc", {"wc","-l"}, …)` | `wc -l`'s image replaces Child B; it reads lines from stdin |
| 10 | Shell | close `fd[0]`, close `fd[1]` | Shell no longer needs the pipe; only the two children reference it now |
| 11 | Shell | `waitpid(pid1, …)` then `waitpid(pid2, …)` | Blocks until both children finish; reaps their exit statuses |
| 12 | Kernel | pipe torn down | Once no process holds either end open, the pipe buffer is freed |
Tracing the PCBs: at step 2, `pid1` is a fresh PCB whose `parent_pid` field equals the shell's PID, and whose file descriptor table is a copy — same entries, but an independent table, so closing `fd[0]` in Child A does not close it in the shell. At step 5, `execve` replaces Child A's address space and instruction pointer, but its PID (`pid1`) and its now-redirected file descriptor table survive unchanged — this is exactly why `ls`'s output, which the `ls` binary itself writes to "file descriptor 1" with no knowledge of pipes, ends up flowing into `wc`. At step 11, the shell blocks on `pid1` first; if `wc -l` (Child B) happens to finish first because it was faster to start, its PCB becomes a zombie and simply waits — patiently, at memory cost — until the shell's second `waitpid` call reaps it. No data is lost regardless of which child finishes first, which is the correctness property `wait`/zombie semantics were designed to guarantee.
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Forgetting to check `fork()`'s return value | Treating fork as if it can't fail (e.g., process/resource limits reached) | Both "parent" and "child" logic run in the same process, corrupting control flow |
| Not calling `wait`/`waitpid` at all | Assuming child cleanup is automatic | Terminated children accumulate as zombies, eventually exhausting the PID space |
| Redirecting file descriptors before `fork` instead of in the child | Misunderstanding that `exec` inherits descriptors set at call time, not at program start | The shell's own stdout gets redirected too, not just the child's |
| Leaving unused pipe ends open in the child | Copy-and-paste from parent setup code without closing inherited descriptors | Readers on a pipe never see EOF because a stray writable descriptor keeps the pipe "open" |
| Assuming `fork` is expensive because it "copies everything" | Not knowing about copy-on-write | Over-engineering around a cost that modern kernels already eliminate for the common case |
| Parent exits before reaping or before child finishes | Ignoring the orphan-reparenting rule | Child is reparented to `init`/`systemd` (PID 1), which periodically reaps it — usually harmless, but surprising if unexpected |
Pioneers & history
The fork/exec model traces back to the earliest days of Unix at Bell Labs, where the designers deliberately chose a minimal, orthogonal primitive over a monolithic "spawn" call — a decision credited largely to Ken Thompson, who is said to have implemented the original `fork` in a single afternoon by adapting an idea from the Berkeley timesharing system and Project GENIE, both of which had experimented with process duplication.
Origins · 1 minKen Thompson and how this was discovered
Thompson, together with Dennis Ritchie, designed the original Unix kernel at Bell Labs in 1969–1970. The fork/exec split was central to keeping the kernel's process-management interface small enough to fit the extremely limited memory of the PDP-7, while still being expressive enough to build a full shell, pipelines, and job control entirely in user space.
Where this matters
Every Unix-like shell — `bash`, `zsh`, `dash`, `fish` — is fundamentally a loop that parses a command line, `fork`s, redirects file descriptors and/or sets up pipes in the child, `exec`s the requested program, and `wait`s for it, exactly as traced above. Container runtimes such as Docker and Kubernetes's `containerd` use the same primitives, layered with namespace and cgroup setup calls between `fork` (or `clone`) and `exec`, to isolate a new process's view of the filesystem, network, and process tree. Web servers and network daemons that use a process-per-connection model, including classic Apache's prefork MPM and many CGI-based systems, rely on `fork` to isolate each client's request-handling in its own address space, trading the low memory-sharing overhead of copy-on-write against the higher cost of per-process context switching compared to a threaded model — the natural segue toward Threads and Concurrency, where multiple execution contexts share a single address space instead of forking a new one. Language runtimes (Python's `subprocess`, Java's `ProcessBuilder`) expose higher-level wrappers that internally perform exactly this fork/exec/wait sequence, and understanding the underlying primitive explains otherwise-mysterious behaviors, such as why a forked child in Python can unexpectedly inherit open file handles, or why improperly reaped subprocesses show up as `` zombies under system monitoring tools.
Ask the tutor: How would this fork/exec/wait model need to change if the operating system used threads instead of processes as its basic unit of scheduling?
Why doesn't Unix just have one system call that creates and runs a new program?
Splitting process creation into `fork` and `exec` lets arbitrary setup happen in between, entirely in user space, with no special-cased API. A shell can redirect stdin/stdout, change the working directory, drop privileges, adjust resource limits, or set up pipes between siblings — all in the child, after `fork` but before `exec` — without the kernel needing to know about any of it. A single `spawn`-style call would need parameters for every one of these behaviors, or a callback mechanism, making the kernel interface far more complex. The two-call design keeps the kernel minimal while making the user-space shell expressive.
Does fork() really copy the entire address space every time?
No, not physically. Modern implementations use copy-on-write: `fork` duplicates only the page table, marking every page read-only and shared between parent and child. Actual physical page copies happen lazily, one page at a time, only when either process writes to a shared page, triggering a protection fault the kernel handles by allocating a private copy for the writer. Since the extremely common case is `fork` immediately followed by `exec` — which discards the entire copied address space anyway — this laziness avoids nearly all of the copying that a naive eager implementation would perform.
What happens if a child process never gets exec'd?
Nothing forces a forked child to call `exec`; it's entirely legal for the child branch to simply continue running the same program as the parent, which is exactly how `fork` is used without `exec` in some worker-process or preforking-server designs. The child has its own independent copy of memory (via copy-on-write) and its own PID, so the two processes can diverge in behavior based on the `pid == 0` check, without ever loading a different executable.
What is the difference between a zombie process and an orphan process?
A zombie is a terminated child whose exit status hasn't yet been collected by `wait`; its PCB lingers, holding just the exit status, until an ancestor reaps it. An orphan is the opposite situation: a still-running child whose parent has already terminated. The kernel handles orphans by reparenting them to `init` (PID 1) or an equivalent system process such as `systemd`, which periodically calls `wait` on all of its children, ensuring orphans are eventually reaped even though their original parent is gone. Zombies are about status not yet collected; orphans are about parents not yet present.
Can waitpid() be used to avoid blocking while waiting for a child?
Yes. Passing the `WNOHANG` option to `waitpid` makes it return immediately if the specified child hasn't terminated yet, rather than blocking the caller. This lets a process poll for child completion while doing other work, which is common in event-loop-based servers that must remain responsive to other events (new connections, timers) while children run in the background. An alternative, more scalable approach registers a handler for the `SIGCHLD` signal, which the kernel delivers whenever a child changes state, and calls a non-blocking `waitpid` from within that handler.
How does this relate to threads, which are covered next?
Both `fork` and thread creation produce a new, independently schedulable execution context, but they differ in what is shared. `fork` gives the child its own copy of the address space (via copy-on-write) and its own file descriptor table, so parent and child are strongly isolated. Threads, by contrast, share a single address space and most kernel resources within one process, communicating through shared memory rather than through pipes or files. The fork/exec model is the natural baseline against which the tradeoffs of threading — faster context switches, but no memory isolation between execution contexts — are usually explained.
Why does exec() need a whole family of variants like execl, execv, execve, execvp?
The variants differ only in how arguments and environment are passed to the caller's convenience, not in the underlying kernel mechanism, which is `execve` at the syscall level. `execl` takes arguments as a variadic list (useful when the argument count is known at compile time); `execv` takes an array (useful when arguments are built dynamically); the `p`-suffixed versions search the `PATH` environment variable for the executable instead of requiring a full path; and the `e`-suffixed versions let the caller supply a custom environment rather than inheriting the current one. All of them ultimately invoke the same kernel-level image-replacement logic.