Computing History · Unix Lineage
Why fork and exec Are Separate Calls
The two-step process creation model, what fork copies and what copy-on-write defers, and why the split survives despite its cost in a program that only wants to exec.
A program that wants to run another program must first duplicate itself. There is no single call in the original interface that says “replace this process image with that executable”. There is fork, which duplicates, and exec, which replaces, and the gap between them is where the entire process-creation protocol of the system lives. The split looks like an accident of history and is defended as a design, and both readings are partly right.
What fork actually duplicates
fork returns twice: once in the parent, with the child’s process ID, and once in the child, with zero. Everything else is copied or shared according to a rule that differs per resource. The address space is duplicated as a private copy, so writes in either process are invisible to the other. The descriptor table is copied, but the entries point at the same open file descriptions, which is why the offset is shared and is explained in the descriptor article. Signal dispositions are inherited, except that pending signals are cleared in the child, and handlers remain installed. The child gets its own process ID, its own copy of the resource limits, and its own pending-signal set.
The address-space copy is the expensive part in the naive model, and it was genuinely expensive on machines without memory management hardware. Early implementations copied every page. A process with a large data segment paid for the copy twice: once when fork duplicated it and again when exec discarded it in favour of the new image. The second copy is pure waste, and it is the reason for nearly every later refinement.
Copy-on-write removes the waste. fork marks the parent’s pages read-only in both address spaces and lets the child’s page table point at the same physical frames. The first write to a page in either process takes a protection fault, and the fault handler allocates a private copy of that one page and remaps it. Pages that are only read stay shared for the life of both processes. A fork followed immediately by exec therefore copies almost nothing: the child touches a handful of pages on the stack before the exec call, and the rest of the parent’s image is never written.
The accounting still costs. fork must duplicate the page tables, which is proportional to the size of the address space rather than to the amount of data actually modified. A process with a sparse 40 GB address space and a few megabytes resident still has page-table structures covering the whole range, and copying them takes time even if no page is copied. The page table copy is the reason fork on a large process is slower than the size of its resident set suggests.
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
/* child: this process image is about to be replaced */
execlp("date", "date", "+%Y", (char *)NULL);
_exit(127); /* exec failed; do not run atexit handlers */
}
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
printf("child exited %d\n", WEXITSTATUS(status));
return 0;
}
Note _exit rather than exit on the failure path. exit runs atexit handlers and flushes stdio buffers that the child inherited from the parent, so a failed exec in a child that calls exit can duplicate the parent’s buffered output. The distinction between exit and _exit exists because of fork.
Why the split is not an accident
The original design put the split in deliberately, for reasons that a combined spawn call cannot reproduce.
The first is that the child is a copy of the parent for the interval between the calls, and that interval is usable. A shell redirects descriptors between fork and exec: it closes descriptor 1, opens the output file, and relies on open returning the lowest free number, which is 1. It sets the working directory, adjusts resource limits, changes the process group, and clears the signal mask, all in the child, so that the changes apply to the new program and not to the shell. None of this requires an API on the spawn call, because the child is a full process with the full process interface available to it. A spawn call that took a list of options would have to enumerate every process attribute that might need setting before the new image starts, and it would be permanently behind the set of attributes the system supports.
The second is that the parent continues. fork without exec is the primitive for concurrency in a system that predates threads: a server accepts a connection and forks a child to handle it, so that a crash in one handler cannot take down the listener, and so that the handler’s memory is reclaimed by the kernel when it exits. This use is why fork has to produce a genuine copy rather than an empty address space.
The third is that the two calls compose with the descriptor model. The parent’s open descriptions survive exec unless marked close-on-exec, which makes inheritance the default and lets a shell hand a pipe to a pipeline stage without any explicit passing mechanism. The design of the pipeline, described in the pipe article, depends on this.
What the split costs, and the attempts to avoid it
The cost of fork in a program that only wants to exec is not the data copy, which copy-on-write handles, but the page-table copy and the duplication of the parent’s entire kernel-side process state: the file descriptor table, the signal handlers, the memory mappings, the credentials. For a large server process, that duplication is measurable, and it happens on every child.
vfork was the historical answer. It suspends the parent, gives the child the parent’s address space without copying, and requires the child to exec or _exit immediately; any other use, including returning from the function that called vfork, is undefined. The interface is dangerous because the child shares the parent’s stack, and later revisions of POSIX dropped it from the specification while implementations kept it for compatibility. Its remaining use is the fast path in posix_spawn implementations on systems without copy-on-write.
posix_spawn is the portable answer, standardised in POSIX.1-2001. It takes the executable path, an argument vector, an environment, and two opaque attribute objects: one describing file actions to perform in the child before exec, the other describing process attributes such as the process group, the signal mask, and the scheduling policy. The implementation is free to use vfork or a clone with shared memory, because the attribute objects tell it in advance which attributes need changing, so it does not need a general-purpose process to modify them. On Linux, posix_spawn typically uses CLONE_VM and CLONE_VFORK and performs the file actions in the child’s brief shared-memory interval.
The trade is expressiveness for cost. posix_spawn can express the common cases: redirecting the three standard descriptors, closing others, setting the process group. It cannot express an arbitrary sequence of system calls in the child, because the child has no usable stack of its own under vfork. A program that needs to, say, create a pipe and wire both ends into the child’s descriptors before exec can do it with file actions, but a program that needs to acquire a lock or read a configuration file in the child cannot. Those programs keep using fork.
Failure modes that follow from the split
Between fork and exec, the child is running the parent’s code with the parent’s state, and any mistake there is a bug in the parent.
Buffered stdio is the classic. The parent’s FILE buffers are copied into the child by the address-space duplication. If the child calls exit, those buffers are flushed, and any data the parent had buffered but not yet written is written twice. _exit avoids it, and so does flushing before fork, but neither is obvious from the call site.
Locks are the second. A mutex held by another thread at the moment of fork is copied into the child in the held state, with no thread to release it. The child cannot acquire it and will deadlock on first use. POSIX addresses this by specifying that the child of a multithreaded process has only the calling thread, and that only async-signal-safe functions may be called before exec; pthread_atfork handlers exist to reinitialise such locks, and they are easy to get wrong because the prepare handler must lock in the parent, the parent handler must unlock, and the child handler must reinitialise rather than unlock. The safe subset is narrow, and it is the same subset that applies inside a signal handler, discussed in the signals article.
Resource exhaustion is the third. fork fails with EAGAIN when the process limit or the system-wide thread limit is reached, which under load is a normal condition rather than an error. A server that forks per connection must handle the failure by shedding load, and the check must happen before the connection’s work is committed to.
The last is that fork duplicates descriptors that the child will never use, and each duplicate keeps the open file description alive. A parent that opens a listening socket and forks a child which execs an unrelated program hands that program an open socket it does not know about, which prevents the port from being released. The close-on-exec flag exists for exactly this, and its race is covered in the inheritance article.
References
- The Open Group Base Specifications Issue 7, System Interfaces: fork — POSIX.1-2017, IEEE Std 1003.1
- The Open Group Base Specifications Issue 7, System Interfaces: exec — POSIX.1-2017, IEEE Std 1003.1
- The Design and Implementation of the 4.4BSD Operating System — McKusick, Bostic, Karels, Quarterman; Addison-Wesley, 1996
- Linux man-pages: fork(2) — Michael Kerrisk, man-pages project