conceptSystems Programming~9 min readUpdated 2026-07-02#systems#processes#fork#exec#wait#posix

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: fork creates a process without loading a new program. exec loads a new program without creating a new process. wait is 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 called exit, returned from main, or used _exit.
  • WEXITSTATUS(status) extracts the low 8-bit exit code when WIFEXITED is true.
  • WIFSIGNALED(status) and WTERMSIG(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:

  • fork produced a new PID (69397) while the parent kept running.
  • The child changed shared_counter to 777, but the parent's counter stayed 100; same initial memory, separate address spaces after writes.
  • The exec image kept the same PID (69397) but shared_counter went back to 100; 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 to inherited_fd=4.
  • waitpid reaped that exact child and decoded the normal exit status 42.

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. clone and clone3 can create processes or threads depending on flags; fork is the POSIX-shaped case. User code should not reach for raw clone unless it is implementing a runtime, container tool, or threading library.
  • glibc's fork wrapper does more than a raw syscall. In threaded programs it runs pthread_atfork handlers 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_spawn is often the preferred macOS path because it can avoid the hazards of fork in large multi-threaded processes.
  • The executable loader differs. Linux commonly loads ELF and then ld-linux for dynamically linked programs; macOS loads Mach-O and dyld. exec is the process operation; ELF/Mach-O is the file-format detail.

Failure modes & trade-offs

  • Forgetting that exec returns only on failure. Code after execvp is the error path. Handle it, report it, and end the child with _exit, not exit.
  • Double-flushing buffered I/O. After fork, parent and child may both hold copies of stdio buffers. If the child calls exit after an exec failure, it may flush data the parent will also flush. Use _exit in 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 parent 300; traditional status exposes only the low 8 bits for normal exits. Signal death is a different case.
  • Calling unsafe code after fork in a multi-threaded process. POSIX only guarantees async-signal-safe functions in the child before exec. This is where posix_spawn often 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 fork failure. fork can fail with process limits or memory pressure. Treat it like every other syscall boundary: check the return value.

In practice

  • Use fork plus exec when the child must customize its process state. Shells, supervisors, test runners, and pipeline builders need that pre-exec window.
  • Use posix_spawn when 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 waitpid in loops that handle EINTR. Signals can interrupt blocking waits; robust code retries or routes through a central child-reaping loop.
  • Use _exit in the child after fork if exec fails. The child should not run the parent's cleanup stack.
  • Remember the OS-project version. fork means duplicating an address-space view, exec means loading a fresh executable image, and wait means 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.html
  • execve(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.html
  • waitpid(2) - man7.org - status macros, zombies, wait variants, and child-state transitions. https://man7.org/linux/man-pages/man2/waitpid.2.html
  • clone(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/