Fork Then Exec Is a Deliberate Trade-off, Not a Default
Fork duplicates process state cheaply via copy-on-write and shares file descriptors by reference; exec is what makes that copy safe to run a different program.
25 Sept 2026, 17:43 UTC

A server holding an accepted TCP connection wants to hand that connection to a separate program without reopening the socket, and without letting the new program corrupt the parent's heap. On Unix-like systems the answer is two calls used together: fork() duplicates the process, and exec() replaces the duplicate's program image while keeping its open file descriptors. The useful takeaway is that fork is not cloning for its own sake. It is a cheap way to copy execution context and the descriptor table, and exec is the isolation step that makes that copy safe to hand to a different binary.
What fork() actually duplicates
POSIX says the child is a copy of the parent at the moment of the call: same program counter, same stack and heap contents, same signal dispositions, same open file descriptors. Modern kernels implement the memory copy with copy-on-write, where pages are mapped read-only in both processes and copied only when one of them writes. That makes the initial duplication cheap, but the cost is deferred rather than removed. A child that writes to a large working set triggers page faults and private copies.
File descriptors behave differently. They are duplicated by reference, so parent and child share the same underlying open file description: the same file offset, the same socket, the same status flags set with fcntl. That sharing is the feature. It is how a listener can accept a connection and let a child inherit it, and it is also how two processes can interleave writes on the same descriptor with no coordination at all.
The return-value contract is the whole API
fork() returns three distinguishable outcomes, and calling code has to handle all of them:
0in the child,- the child's PID, greater than 0, in the parent,
-1on failure, witherrnoset, commonlyEAGAINwhen a process or PID limit is reached, orENOMEM.
Because the child starts executing at the same instruction as the parent, the return value is the only thing that separates them. An unchecked fork() therefore has two failure modes: a silent failure where no child is created and the work is simply never done, and a branch that treats -1 as a valid PID. Testing pid < 0 first avoids both.
A minimal fork-then-exec skeleton
Compile this on a POSIX system with a C compiler. No special privileges are needed to fork, and executing the target binary requires only normal execute permission. Run it from an ordinary user shell.
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
execl("/usr/bin/true", "true", (char *)NULL);
perror("execl"); /* only reached if exec failed */
_exit(127);
}
int status;
waitpid(pid, &status, 0);
return 0;
}
Expected checks: the program prints nothing on success, because /usr/bin/true produces no output and the parent reaps the child. While the child is alive you can see two entries in ps or top, and the child's parent PID is the parent's PID. To confirm descriptor inheritance, replace the exec target with a program that reads from a descriptor the parent opened before forking. The child reads from the same open file description, so the offset advances for both processes.
Two details in the skeleton matter. _exit() is used rather than exit() after a failed exec, because exit() would flush stdio buffers the child inherited from the parent and could duplicate output. And the parent calls waitpid(). Without a wait, a finished child stays in the process table as a zombie until the parent exits or reaps it.
Where the trade-off bites
Fork is cheap until it is not. Copy-on-write defers page copying, but a child that dirties a large heap pays for it in page faults. The bigger hazard is what fork copies besides memory. In a multi-threaded process, only the calling thread exists in the child, while mutexes and other locks may still be held by threads that no longer exist. POSIX restricts what is safe to call between fork() and exec() in that situation, and the practical rule is to do as little as possible there, then exec immediately.
Resource limits are the other edge. The per-user process limit is visible as ulimit -u in a shell. A program that forks without exec and without reaping will eventually reach it, and fork() then returns -1 with errno set. A useful check is to lower the limit in a throwaway shell, for example ulimit -u 20, and confirm that your error path actually runs instead of assuming success. That changes the limit for that shell only; raise it back or open a new shell afterwards.
Limitations worth stating plainly: copy-on-write is an implementation technique, not a POSIX guarantee, so the performance reasoning above describes modern Linux, BSD and macOS kernels rather than a portable contract. The exact errno values on failure and the precise set of async-signal-safe functions are worth confirming against the man pages of the target system before relying on them.
The closing decision is simple. Use fork-then-exec when you need an exact duplicate of execution context and open descriptors, and exec immediately so the duplicate cannot drift. Check the return value, reap the child, and keep the window between fork and exec as small as you can.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.