Processesforkexecwaitexit codeorphan

Creating Processes: fork, exec, wait

Unix-style systems create processes by cloning the caller (fork) and then optionally replacing the clone’s program (exec); the parent collects the result with wait; Windows does it in one CreateProcess call, and every language’s "run a subprocess" API is a thin wrapper over one of the two.

Unix-styleLinuxWindowsRuntime-specific
▶ InteractiveInterview question
Progress

The problem

Your server needs to run ffmpeg on an upload, capture its output and its exit code, and not leak anything if it hangs. Somewhere under subprocess.run or child_process.spawn a new process comes into existence with its own PID and address space. What does the kernel actually do, and where do the classic bugs — zombies, orphans, hung pipes — come from?

`fork`: two of the same

Unix-style

fork() is called once and returns twice: in the parent it returns the child’s PID, in the child it returns 0. At the instant of return the two processes are identical except for that return value, the PID, and a few accounting fields: same code, same variables with the same values, same open descriptors pointing at the same underlying files (and sharing their offsets), same signal dispositions, same working directory. From then on they diverge; a write to a variable in one is invisible to the other.

The kernel does not copy the memory. It copies the page tables and marks every writable page copy-on-write: both processes share the physical pages read-only until one writes, at which point that one page is duplicated (see Copy-on-Write). A fork of a 2 GB process therefore costs microseconds to low milliseconds — proportional to the number of page-table entries, not the number of bytes — plus later, scattered page faults as the child starts writing. It also means a fork from a multi-threaded process is dangerous: only the calling thread is cloned, and any lock another thread held at that instant is held forever in the child. That is why the rule is "only exec after fork in a multi-threaded program", and why glibc’s posix_spawn and Python’s spawn start method exist.

fork, exec, wait — the three calls behind every subprocess API
1#include <unistd.h>
2#include <sys/wait.h>
3
4int run(const char* path, char* const argv[]) {
5 pid_t pid = fork();
6 if (pid < 0) return -1; // EAGAIN: process limit, ENOMEM
7 if (pid == 0) { // child: same memory, same fds
8 execv(path, argv); // replace image; only returns on error
9 _exit(127); // do not run parent's atexit handlers
10 }
11 int status; // parent
12 if (waitpid(pid, &status, 0) < 0) return -1;
13 if (WIFEXITED(status)) return WEXITSTATUS(status); // 0..255
14 if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); // shell convention
15 return -1;
16}

`exec`: a new program in the same process

Unix-style

The exec family (execve is the system call; execl, execvp and friends are libc conveniences that search PATH or build the argument list) throws away the calling process’s address space and builds a new one from an executable, as described in Follow a Program: from `./server` to the First Instruction. The process keeps its PID, its parent, its descriptors (except those marked close-on-exec, O_CLOEXEC), its working directory, its uid — and loses its memory, its threads, its signal handlers and its pending atexit functions. exec never returns on success; if you see the line after it, it failed.

The window between fork and exec is where the parent shapes the child: dup2 a pipe onto descriptor 1 so the child’s stdout flows to the parent, chdir, setuid to drop privileges, setrlimit to cap memory, close descriptors the child should not inherit. Every subprocess library’s cwd=, env=, stdout=PIPE, uid= option is a call inserted into this window. Descriptors that are *not* marked close-on-exec leak into the child, which is the mechanism behind "the port is still in use after the server exited" — a child it spawned inherited the listening socket.

`wait`, exit codes, orphans and zombies

Unix-style

A process ends by returning from main, calling exit(n), or being killed by a signal. The kernel frees its memory and descriptors immediately but keeps the process record with the exit status until the parent calls wait() or waitpid(). Until then the child is a zombie: visible in ps as <defunct>, consuming a PID and nothing else. A parent that spawns children and never waits accumulates zombies until fork fails with EAGAIN. The fix is in the parent: waitpid(-1, …, WNOHANG) in a SIGCHLD handler, or ignore SIGCHLD explicitly, which tells the kernel to auto-reap.

If the parent exits first, the children are orphans: still running, re-parented to PID 1 (or the nearest subreaper), which reaps them when they exit. Orphans are not a leak in themselves — a daemon deliberately orphans itself to detach from the terminal — but they are the mechanism behind "I killed the server and its workers are still serving". Killing a process does not kill its children unless you kill the process group (kill -- -PGID) or use a cgroup.

Exit codes are 8-bit: 0 means success by convention, 1–255 mean whatever the program says, and a signal death is reported separately (WIFSIGNALED). Shells encode signal death as 128 + signal number, so exit status 137 is SIGKILL (128 + 9) — usually the OOM killer or a container runtime — and 139 is SIGSEGV (128 + 11).

  • Reap children or they become zombies; the zombie cannot be killed because it is already dead.
  • Kill the process group or cgroup, not just the PID, to take the children down with the parent.
  • Exit 137 = killed by SIGKILL; 139 = segfault; 143 = SIGTERM.

Windows: `CreateProcess`

Windows

Windows builds a process directly from an executable path: CreateProcess(application, commandLine, …, &startupInfo, &processInfo) creates the process object, its address space, and its first thread in one call, returning handles to both. There is no clone step and so no copy-on-write duplication of the parent; handle inheritance must be requested per handle (bInheritHandles plus a SECURITY_ATTRIBUTES flag on each), and stdin/stdout/stderr are set via fields in STARTUPINFO. The parent waits with WaitForSingleObject(hProcess) and reads the code with GetExitCodeProcess, which is 32-bit, not 8-bit.

There are no zombies in the Unix sense — the process object is released when the last handle to it is closed — but a parent that never closes the handle keeps the object alive in the same way. There is no process tree semantics by default either: killing a parent does not affect children unless they were placed in a Job Object with kill-on-close, which is the Windows equivalent of a cgroup for this purpose. Windows also has no exec: replacing a program means starting a new process and exiting.

What your language does for you

Runtime-specific

Every runtime’s subprocess API is the fork/exec/wait (or CreateProcess/WaitForSingleObject) sequence with the pipe plumbing done for you. Python’s subprocess.run(["ffmpeg", …], capture_output=True, timeout=30) forks (on Linux via posix_spawn or vfork where possible for speed), sets up pipes, execs, reads both pipes concurrently, waits, and raises on timeout after killing the child. Node’s child_process.spawn does the same through libuv, delivering stdout as a stream; exec buffers it; fork is spawn of another Node with an IPC channel. C++ has only std::system, which runs /bin/sh -c (or cmd.exe) and returns the shell’s status — use posix_spawn or the platform API for anything real.

Two bugs recur in every language. First, reading only stdout while the child fills stderr’s pipe: the pipe buffer (64 kB on Linux) fills, the child blocks on write, the parent blocks on read — a deadlock. Libraries that offer communicate() or capture_output read both concurrently for this reason. Second, timeouts that kill the child but not its grandchildren (sh -c "…" spawns the real command): use a process group or a job object, or avoid the shell.

The plumbing, spelled out (CPython on Linux)
1import subprocess, signal, os
2
3proc = subprocess.Popen(
4 ["ffmpeg", "-i", "in.mp4", "out.webm"],
5 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
6 start_new_session=True, # own process group: we can kill the whole tree
7)
8try:
9 out, err = proc.communicate(timeout=60) # reads both pipes concurrently, then waits
10except subprocess.TimeoutExpired:
11 os.killpg(proc.pid, signal.SIGKILL) # kill the group, not just the child
12 proc.wait() # reap: no zombie
13 raise
14print(proc.returncode) # 0 ok; negative = killed by that signal

Key points

  • Unix-style: fork clones the caller with copy-on-write pages, exec replaces the clone’s program keeping PID and descriptors, wait collects the exit status.
  • The fork→exec window is where redirection, chdir, privilege dropping and limits are applied; descriptors without O_CLOEXEC leak into the child.
  • Never fork-without-exec from a multi-threaded process: only one thread survives and locks held by others are frozen.
  • Exited children stay as zombies until reaped; killed parents leave orphans re-parented to PID 1; kill process groups to take trees down.
  • Windows CreateProcess builds the process in one call, inherits handles opt-in, has no exec, and uses Job Objects for tree semantics.
  • subprocess, child_process and std::system are wrappers over these calls; the recurring bugs are unread pipes and unkilled grandchildren.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why fork instead of a "create process from file" call?

Fork makes the child a fully programmable copy of the parent; anything the parent can do to itself — redirect, chdir, drop privileges — it can do in the child before exec, with no special API surface.

Why is fork fast if it copies the whole process?

It copies page tables, not pages; copy-on-write defers the actual copy to the pages that are written, which after an immediate exec is almost none.

Why do zombies exist instead of the exit code being delivered directly?

The parent may not be ready to receive it; storing it in the child’s emptied record until the parent asks is the simplest reliable mailbox.

Why does Windows do it differently?

NT was designed without Unix’s fork semantics; a single creation call with explicit inheritance is cheaper for process-heavy workloads and avoids the fork-in-threaded-program hazard, at the cost of a large options struct.

fork, exec, wait

fork(), exec(), wait()
Unix creates a process by cloning the caller, then optionally replacing the clone's program.
Parent · PID 1200 · zshrunning
text
private
data
private
heap
private
stack
private
about to fork()
Child · PID — · —
does not exist yet
Parent running. The shell (PID 1200) is running and about to call fork(). It has its own text, data, heap and stack pages.
WindowsWindows has no fork(): CreateProcess() builds a new process from an executable in one call, passing the command line and inheritable handles explicitly. The Unix split lets the child tweak descriptors and environment between fork() and exec() — which is how shells implement redirection and pipes.
1/7 · Parent runningUnix-style

How it fails

What the failure looks like from inside real software.

  • ps fills with <defunct> children of a Node or shell-script PID 1 in a container that never reaps; eventually fork: retry: Resource temporarily unavailable.
  • A subprocess.Popen with stdout=PIPE and a chatty stderr hangs forever: pipe full, child blocked writing, parent blocked reading the other pipe.
  • Restarting the server fails with EADDRINUSE: a child spawned earlier inherited the listening socket without O_CLOEXEC and is still holding it.
  • A fork from a multi-threaded Python or C++ program deadlocks in the child on the first malloc or log call: another thread held the allocator or logging lock at fork time.
  • A timeout kills sh -c "ffmpeg …" but ffmpeg keeps running as an orphan at 100% CPU.
  • Exit status 137 in CI: the container hit its memory limit and the runtime sent SIGKILL, not a bug in the program’s exit path.