Processes: fork, exec, and wait
A program is bytes on disk. A process is the OS-owned execution container built
around those bytes: PID, virtual address space, file descriptor table, credentials,
current directory, signal state, resource limits, and at least one thread of execution.
Unix process creation is deliberately split into three moves: fork makes another
process, exec changes what program that process is running, and wait lets the parent
observe and reap the child's termination. That split is why a shell can create a child,
wire up redirections and pipes in the child, then replace the child with grep.
The reset:
forkcreates a process without loading a new program.execloads a new program without creating a new process.waitis how the parent collects the final status so the kernel can forget the dead child.
The Unix split: create, transform, reap
Most high-level runtimes expose one "spawn a program" operation. Unix exposes the pieces. The classic shell shape is:
pid_t pid = fork();
if (pid == 0) {
/* child: adjust file descriptors, environment, signal state, cwd... */
execvp(argv[0], argv);
_exit(127); /* exec returns only on failure */
}
waitpid(pid, &status, 0);
That shape looks strange until you notice what it buys: the child has a short window
between fork and exec where it is still your program but has its own process identity.
That is the perfect time to set up the future program's world: dup2 stdin/stdout,
close unneeded pipe ends, set environment variables, change directory, reset signal
handlers, drop privileges, then exec.
This is also the mental bridge to the OS project. To implement this trio, a kernel needs
a process table, PID allocation, per-process address spaces, a file descriptor table, an
ELF loader, exit status storage, and a way for a parent to sleep until a child changes
state. fork/exec/wait is not just API trivia; it is the user-space face of process
management.
What fork really duplicates
fork() returns twice: once in the parent with the child's PID, and once in the child
with return value 0. The two processes continue at the same instruction after the call.
They have different PIDs, different kernel task records, and separate virtual address
spaces, but the child's memory starts with the same bytes the parent had.
Modern kernels avoid copying every page immediately. The usual implementation is
copy-on-write: parent and child initially map the same physical pages read-only; when
either process writes to a page, the kernel faults, copies that page, and resumes the
writer with a private copy. That makes fork cheap enough for the common fork then
exec path, where the child is about to throw away almost all inherited memory anyway.
Some state is duplicated by value, and some is duplicated by reference:
| State | After fork |
|---|---|
| PID | parent keeps its PID; child gets a new PID |
| virtual memory | same initial contents, separate address spaces, usually copy-on-write |
| C variables and heap | same initial values, then independent after writes |
| file descriptor table | table is copied, but entries refer to the same open file descriptions |
| file offsets | shared through those open file descriptions |
| current working directory | inherited |
| signal dispositions | inherited |
| threads | child contains only the calling thread |
The file descriptor row is load-bearing. If a parent has fd 3 pointing at a file and
then forks, the child also has fd 3, and both entries point at the same kernel open-file
object. Reads and writes can advance the same file offset. Pipes rely on the same rule:
the child can inherit one end of a pipe, exec another program, and that program can
still write to the inherited fd.
What exec really replaces
exec is a family of libc functions (execl, execv, execvp, execve, ...). The
kernel primitive is execve(path, argv, envp): load a new program image into the current
process. If it succeeds, it does not return, because the old code, stack, heap, globals,
and mappings are gone. The same PID continues, but the address space is rebuilt around
the new executable.
On ELF systems, execve makes the kernel inspect the executable headers, map loadable
segments, create a fresh user stack containing argv, envp, and auxiliary-vector
entries, then transfer control either to the program entry point or to the dynamic loader
named by PT_INTERP. On macOS the file format is Mach-O and the loader path differs,
but the process-level idea is the same: same process identity, new image.
What survives exec is just as important as what dies:
| Attribute | Across successful exec |
|---|---|
| PID and parent PID | preserved |
| open file descriptors | preserved unless marked close-on-exec |
| current working directory | preserved |
| environment | replaced by the envp you pass, or inherited by wrapper choice |
| memory mappings, heap, stack | replaced |
| caught signal handlers | reset to default |
| ignored signal dispositions | generally preserved |
| other threads | gone; the new image starts as one thread |
The close-on-exec flag (FD_CLOEXEC) is the defense against accidental fd leaks. If a
server opens a private socket, forks, and execs a helper program, any fd not marked
close-on-exec becomes visible inside the helper. That is a correctness bug and often a
security bug. Prefer APIs that set the flag atomically (O_CLOEXEC, pipe2 on Linux,
or fcntl(fd, F_SETFD, FD_CLOEXEC) when you must repair an existing fd).
What wait observes and why zombies exist
When a process exits, the kernel cannot immediately delete every trace of it. The parent may still need to know: did it exit normally, with what status, or did a signal kill it? So the kernel keeps a tiny dead-process record containing the PID, resource accounting, and termination status. That record is a zombie: not running, no user memory, but still occupying a slot until the parent waits.
waitpid(pid, &status, options) is the precise tool:
waitpid(child, &status, 0)waits for one specific child.waitpid(-1, &status, 0)waits for any child.WIFEXITED(status)asks whether the child calledexit, returned frommain, or used_exit.WEXITSTATUS(status)extracts the low 8-bit exit code whenWIFEXITEDis true.WIFSIGNALED(status)andWTERMSIG(status)report signal death.
If the parent exits before the child, the child is reparented to an ancestor process that
will reap it. On Linux that is usually systemd or a subreaper; historically it was PID
1 init. Long-running programs that create children must build a reaping strategy:
blocking waitpid, nonblocking waitpid(..., WNOHANG), or a SIGCHLD path.
Executable artifact: one child, one exec, one wait
The example lives in examples/systems-programming/processes-fork-exec-and-wait/. It
creates a pipe, forks, lets the child write one message before exec, then has the child
exec the same binary in a special mode and write a second message through the inherited
pipe. The parent reads the pipe and waits for exit status 42.
The heart of the demo:
pid_t child = fork();
if (child == 0) {
close(pipefd[0]);
shared_counter = 777;
write_all(pipefd[1], "fork child ... counter=777\n", 27);
char *const child_argv[] = {argv[0], "--exec-child", fd_arg, NULL};
execvp(child_argv[0], child_argv);
_exit(127);
}
close(pipefd[1]);
read(pipefd[0], buf, sizeof buf);
waitpid(child, &status, 0);
Compile and run:
cd examples/systems-programming/processes-fork-exec-and-wait
./run.sh
Real output from this machine:
== build ==
== run ==
parent: pid=69368 counter=100
parent: fork returned child pid=69397; counter is still 100
parent: messages from the child-side pipe
fork child: pid=69397 ppid=69368 inherited_fd=4 counter=777
exec image: pid=69397 ppid=69368 inherited_fd=4 counter=100
parent: waitpid reaped pid=69397 exit_status=42
parent: after wait, counter=100
Read the output as a process trace:
forkproduced a new PID (69397) while the parent kept running.- The child changed
shared_counterto777, but the parent's counter stayed100; same initial memory, separate address spaces after writes. - The
execimage kept the same PID (69397) butshared_counterwent back to100; the old image was replaced by a fresh run of the program. - The pipe fd survived across
exec, so the new image could still write toinherited_fd=4. waitpidreaped that exact child and decoded the normal exit status42.
macOS/BSD vs Linux: same POSIX shape, different lower layer
The portable contract is POSIX: call fork, call an exec* function, call waitpid,
and write code against the documented return values and errnos. That code works across
Linux, macOS, and BSDs when you stay at the libc/POSIX layer.
Underneath, the kernels differ:
- Linux exposes Linux-specific process primitives.
cloneandclone3can create processes or threads depending on flags;forkis the POSIX-shaped case. User code should not reach for rawcloneunless it is implementing a runtime, container tool, or threading library. - glibc's
forkwrapper does more than a raw syscall. In threaded programs it runspthread_atforkhandlers and preserves libc's expectations. Bypassing it can leave locks and runtime state inconsistent. - macOS strongly prefers libc/libSystem as the stable interface. Raw syscall numbers
are not a stable contract. For launching programs,
posix_spawnis often the preferred macOS path because it can avoid the hazards offorkin large multi-threaded processes. - The executable loader differs. Linux commonly loads ELF and then
ld-linuxfor dynamically linked programs; macOS loads Mach-O anddyld.execis the process operation; ELF/Mach-O is the file-format detail.
Failure modes & trade-offs
- Forgetting that
execreturns only on failure. Code afterexecvpis the error path. Handle it, report it, and end the child with_exit, notexit. - Double-flushing buffered I/O. After
fork, parent and child may both hold copies of stdio buffers. If the child callsexitafter anexecfailure, it may flush data the parent will also flush. Use_exitin the child error path. - Leaking file descriptors into executed programs. Any fd without close-on-exec can survive. This can keep sockets open, prevent pipe EOF, expose secrets, and create confusing lifetime bugs.
- Creating zombies. A parent that never waits leaves dead children as process-table records. Short tools may get away with it; daemons cannot.
- Misreading exit status.
exit(300)does not give the parent300; traditional status exposes only the low 8 bits for normal exits. Signal death is a different case. - Calling unsafe code after
forkin a multi-threaded process. POSIX only guarantees async-signal-safe functions in the child beforeexec. This is whereposix_spawnoften wins. - Assuming memory is shared after
fork. It is not shared like threads. If parent and child need to communicate, use pipes, sockets, shared memory, files, or another IPC mechanism. - Ignoring
forkfailure.forkcan fail with process limits or memory pressure. Treat it like every other syscall boundary: check the return value.
In practice
- Use
forkplusexecwhen the child must customize its process state. Shells, supervisors, test runners, and pipeline builders need that pre-execwindow. - Use
posix_spawnwhen you just want to launch a program. It is often simpler and safer in multi-threaded programs, especially on macOS. - Set close-on-exec by default. Make fd inheritance explicit, not accidental.
- Use
waitpidin loops that handleEINTR. Signals can interrupt blocking waits; robust code retries or routes through a central child-reaping loop. - Use
_exitin the child afterforkifexecfails. The child should not run the parent's cleanup stack. - Remember the OS-project version.
forkmeans duplicating an address-space view,execmeans loading a fresh executable image, andwaitmeans storing child status until the parent consumes it.
ARM64 appendix
The process model is not architecture-specific, but the syscall entry layer is. On ARM64
Linux, libc enters the kernel with svc #0, and the raw syscall numbers/registers differ
from x86-64. On Apple Silicon, libSystem is still the supported boundary and XNU's raw
syscall interface is not the contract you should program against. The C code in the demo
does not change because it uses POSIX wrappers; only libc and the kernel port need to know
the trap instruction and syscall table.
For an OS-from-scratch project, this distinction matters. The user-visible process semantics can be Unix-like on x86-64 or ARM64, but the low-level entry path, context switch frame, and executable ABI are architecture work.
Connects to: Systems Programming · The syscall: crossing the boundary into the kernel · The process address space & virtual memory · The ELF format · The dynamic loader, relocation, PLT, and GOT · The minimal standard library · OS from Scratch
Sources
- Michael Kerrisk - The Linux Programming Interface, ch. 24-27 - detailed treatment of process creation, termination, child monitoring, and program execution. https://man7.org/tlpi/
fork(2)- man7.org - Linux/POSIX semantics, copy-on-write notes, inheritance rules, and glibc wrapper details. https://man7.org/linux/man-pages/man2/fork.2.htmlexecve(2)- man7.org - the kernel-level exec contract: argv/envp, preserved attributes, reset attributes, interpreter scripts, and ELF interpreter handling. https://man7.org/linux/man-pages/man2/execve.2.htmlwaitpid(2)- man7.org - status macros, zombies, wait variants, and child-state transitions. https://man7.org/linux/man-pages/man2/waitpid.2.htmlclone(2)- man7.org - Linux-specific process/thread creation primitive underneath many higher-level runtime designs. https://man7.org/linux/man-pages/man2/clone.2.html- POSIX
fork()- The Open Group Base Specifications - portable definition of the process-creation side of the trio and its inheritance rules. https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html - Stevens & Rago - Advanced Programming in the UNIX Environment (APUE) - classic Unix process-control explanations and shell-shaped examples. http://www.apuebook.com/