Skip to content

8.2 fork, exec, and wait

Process context lists tasks and shared resources. This lesson connects fork, exec, exit, and waitpid into a lifecycle, while explaining COW, zombies, and orphans.

Shell Startup Commands Need to Complete Three Tasks

The control room receives grep error app.log. In typical Unix shell behavior, it first creates a child process, configures redirections and pipes within the child, then replaces the child's program image with grep. The parent process then decides whether to wait for the foreground task to complete or to record the job as background and continue reading commands.

text
shell process
  └─ fork -> child context
                ├─ dup2/close: configure stdin/stdout/stderr
                └─ exec: load grep

shell --waitpid--> retrieve termination status and reclaim child process

fork is responsible for "creating a new execution thread," exec handles "running a different program within the current process," and wait manages "reading the child's termination status." These three APIs are separated to allow the child process to be configured with file descriptors, credentials, and working directory before the exec stage.

fork One call, returns from two execution flows

After a successful POSIX fork():

  • the parent receives the child's PID;
  • the child receives 0;
  • both processes continue execution after the fork call;
  • failure only occurs when the parent's original execution flow returns -1 and sets errno.

There is no guarantee about which process runs first. Writing the output order into assertions results in intermittent failures.

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        printf("child: pid=%ld parent=%ld\n",
               (long)getpid(), (long)getppid());
        return 7;
    }

    int status;
    pid_t waited;
    do {
        waited = waitpid(child, &status, 0);
    } while (waited < 0 && errno == EINTR);

    if (waited < 0) {
        perror("waitpid");
        return 1;
    }
    if (WIFEXITED(status)) {
        printf("parent: child %ld exited with %d\n",
               (long)child, WEXITSTATUS(status));
        return WEXITSTATUS(status) == 7 ? 0 : 1;
    }
    if (WIFSIGNALED(status)) {
        printf("parent: child terminated by signal %d\n", WTERMSIG(status));
    }
    return 1;
}

Here, the child returns from main, and the C runtime eventually executes the normal process termination sequence. If the child is about to exec and exec fails, it is typically appropriate to call _exit to prevent redundant flushing of the parent's stdio buffer before fork, and to avoid executing parent-specific atexit handlers.

child inherits resource semantics, not a simple memcpy

fork After fork, parent and child have different PID values, separate virtual address spaces, and independent pending signal sets. Many attributes are inherited from the parent: working directory, umask, resource limits, environment variables, and signal disposition, all as defined by POSIX rules.

The file descriptor table contains a copy of the descriptor set, but each entry typically points to the same open file description. As a result, a parent and child reading from the same regular file may advance the file offset in lockstep. If you want to prevent file descriptors from being inherited into subsequent exec calls, set the close-on-exec flag. When creating the child process, using atomic options like O_CLOEXEC or pipe2(O_CLOEXEC) helps eliminate leakage windows that could occur in multithreaded contexts, such as when a thread opens a file and then uses fcntl to modify its behavior.

Mutexes, condition variables, and user memory bytes are also copied with their respective logical states in the child process. In a multithreaded parent, only the thread that calls fork will appear in the child; all other threads are terminated, yet may leave behind locked mutexes in a snapshot. This is one of the most dangerous boundaries in multi-threaded fork scenarios.

POSIX therefore restricts which functions the child can safely call between fork and exec, typically limiting it to asynchronous signal-safe operations. Complex applications are better served by using posix_spawn, or by leveraging platform-specific process-launch APIs.

COW Delayed Copy for Writable Private Page

The kernel does not need to copy the entire physical memory of the parent at fork. For private writable mappings, the parent and child can temporarily point to the same physical page, with the PTE (page table entry) preventing direct writes:

text
parent VPN --read-only COW--+
                             +--> physical page
child VPN  --read-only COW--+

When one side attempts to write, a protection fault is triggered. If the kernel determines that the page is still shared, it allocates a new frame, copies the content, updates the PTE for the writing process, and retries the instruction. The other side continues to see the original value.

It is not the case that "after forking, all pages are marked read-only." Existing read-only code pages do not require changes for COW. MAP_SHARED mappings continue to be shared under the shared semantics, and device mappings and huge pages have their own rules. Additionally, the page-table structure must establish a child view, which consumes both time and memory.

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int value = 42;
    if (fflush(NULL) == EOF) {
        perror("fflush");
        return 1;
    }

    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }
    if (child == 0) {
        value = 100;
        printf("child value=%d address=%p\n", value, (void *)&value);
        return 0;
    }

    int status;
    while (waitpid(child, &status, 0) < 0) {
        if (errno != EINTR) {
            perror("waitpid");
            return 1;
        }
    }
    printf("parent value=%d address=%p\n", value, (void *)&value);
    return value == 42 && WIFEXITED(status) && WEXITSTATUS(status) == 0
         ? 0 : 1;
}

Typically, two processes print the same virtual address but different values. This demonstrates that they have independent address-space semantics; however, the address space itself cannot observe the physical frame. To study actual page sharing, privileged OS instrumentation is required, and considerations must be made for compiler behavior, THP (Transparent Huge Pages), and kernel version.

exec does not return after success

execve(path, argv, envp) Initialize the current process image with a new executable. After success, the old code is not executed, and the caller does not receive a "success return value"; instead, the new program starts from its entry point. Only on failure is -1 returned.

The process PID remains unchanged, but "replace everything" goes too far. Common changes and preservations include:

ProjectAfter exec
virtual address mappings, user stackreplaced with new image and runtime layout
PID, parent relationshipRetained
open fdretained unless FD_CLOEXEC is set
current directory, umaskretained
caught signal dispositionTypically reset to default
ignored signal dispositiontypically remains ignored
signal maskretain
threadsLeave only the thread that calls exec
environmentdetermined by the envp or exec* variant

Features like set-user-ID, capabilities, tracing, timers, and shared-memory attachment have additional rules. Security-sensitive programs must check execve(2) and cannot rely on a single entry table to cover all cases.

execlp/execvp searches according to PATH, execve uses an explicit path and explicitly passes in environment. When running in an untrusted environment, inheritation of attacker-controlled PATH, loader variables, and unexpected file descriptors should be avoided.

Walk through fork + exec + waitpid in the Control Room

Now let the shell execute a real command: parent creates child, child organizes file descriptors and replaces the program image, and finally the parent retrieves the termination status. Each of the three phases must be independently verified through output and return values.

The following program launches /bin/sh -c 'exit 7', with the parent fully decoding the status. In real applications that don't require shell syntax, it's better to directly execv the target program, avoiding extra parsing and command-injection risks.

c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

static int run_and_wait(char *const arguments[]) {
    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return -1;
    }
    if (child == 0) {
        execv(arguments[0], arguments);
        static const char message[] = "execv failed\n";
        (void)write(STDERR_FILENO, message, sizeof message - 1);
        _exit(127);
    }

    int status;
    pid_t waited;
    do {
        waited = waitpid(child, &status, 0);
    } while (waited < 0 && errno == EINTR);

    if (waited < 0) {
        perror("waitpid");
        return -1;
    }
    if (WIFEXITED(status)) {
        return WEXITSTATUS(status);
    }
    if (WIFSIGNALED(status)) {
        fprintf(stderr, "child terminated by signal %d\n",
                WTERMSIG(status));
    }
    return -1;
}

int main(void) {
    char *arguments[] = {"/bin/sh", "-c", "exit 7", NULL};
    int result = run_and_wait(arguments);
    printf("decoded exit status=%d\n", result);
    return result == 7 ? 0 : 1;
}

A shell pipeline creates multiple children, connects pipe endpoints, closes file descriptors not used by each process, and separately reclaims the entire job. Missing a write end file descriptor could leave the reader waiting forever for EOF, an issue far more common than fork itself.

zombie is a terminated process not yet collected by its parent

When a child process terminates, most runtime resources like address space and file descriptors are released. The kernel retains only the PID, termination status, and accounting information for the parent to wait. This residual state is known as a zombie; it does not execute instructions, does not consume the original user memory, yet still occupies limited system resources such as entries in the process table and PID space.

The parent should:

  • Call waitpid on known child processes and properly handle EINTR;
  • When managing multiple children, loop through and reclaim all terminated processes;
  • An event loop can integrate with SIGCHLD, pidfd, or platform-specific mechanisms, but standard signals are subject to signal merging, thus, a single handler cannot reliably wait for one child at a time;
  • If the termination status is not required, configure SIGCHLD or SA_NOCLDWAIT according to platform or POSIX semantics and verify portability.

Installing a single empty SIGCHLD handler does not automatically reclaim the child process. For long-running services, the safest approach is to maintain clear child ownership and treat reaping as part of resource management.

Orphaned processes are handed off to the reaper; no guarantee that the new parent is PID 1

When the parent terminates, any surviving child processes are reparented. In traditional systems, these children are adopted by init (PID 1). On Linux, subreapers, PID namespaces, and service managers can instead designate another ancestor as the reaper. Therefore, the example statement "the PPID must be 1 after 5 seconds" does not hold universally.

Reparenting addresses the question of who is responsible for the final wait, but it does not automatically configure daemons correctly. Modern services are better served by explicit lifecycle management from tools like systemd, container init, or a supervisor, tools that clearly handle process lifecycle, signal forwarding, and logging.

The double-fork pattern was historically used to detach from terminals and sessions and ensure descendant processes were adopted by init. However, in service manager environments, this pattern is often unnecessary and can actually complicate a supervisor's ability to track the actual worker processes.

Exit status is not a simple integer return value

waitpid The status written is an encoded result and must be checked first:

  • WIFEXITED before reading WEXITSTATUS;
  • WIFSIGNALED after that, WTERMSIG can be read;
  • When using WUNTRACED/WCONTINUED, it's also possible to observe stop or continue signals;
  • Shells commonly map signal termination to 128 + signal, which is a shell convention, not the original format of waitpid.

The return value of a C main function or the exit parameter ultimately retains only the platform-defined status range. Packing arbitrary 32-bit business error codes directly into the process exit status results in information loss. Complex results should instead be returned via pipes, files, sockets, or shared memory.

Walk Through the Lifecycle

  1. Add a random sleep in the first example to demonstrate that the output order of parent and child processes is not guaranteed by PID size.
  2. Use a file descriptor to show how parent and child share the open-file offset, then compare the behavior using two independent open.
  3. Enhance testing for run_and_wait to distinguish between the conventional exit code 127 and a target program that explicitly exits with 127.
  4. Construct a scenario where the child is terminated by SIGTERM, verifying that direct calls to WEXITSTATUS are not safe.
  5. Build a two-stage pipeline and list the pipe endpoints that must be closed by the parent and each of the two children.
  6. In a multi-threaded parent, explain the state left behind in the child when a fork occurs while another thread holds a lock, and evaluate posix_spawn.

Shared Address Space Turns Problems from Copying to Synchronization

Processes are isolated by default, while threads execute concurrently within the same address space. The next chapter explores Threads and Synchronization: starting with the C memory model's definition of data races, then discussing mutexes, condition variables, and atomic operations, rather than treating "appearing to work" as a sufficient guarantee of concurrency correctness.

Built with VitePress | Software Systems Atlas