Creating Processes: fork, exec, wait

Every program you run from a shell starts as a clone. The kernel duplicates the running process, then the clone replaces itself with a new program. This fork-exec pattern is the foundation of Unix process creation, and understanding it is non-negotiable for systems work.

fork(): Duplicating a Process

fork() creates an almost-exact copy of the calling process. The parent gets the child's PID as the return value; the child gets zero.

/* fork_basic.c */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(void)
{
    printf("Before fork: PID = %d\n", getpid());

    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        /* Child process */
        printf("Child:  PID = %d, parent PID = %d\n", getpid(), getppid());
    } else {
        /* Parent process */
        printf("Parent: PID = %d, child PID = %d\n", getpid(), pid);
    }

    return 0;
}

Compile and run:

$ gcc -o fork_basic fork_basic.c
$ ./fork_basic
Before fork: PID = 1234
Parent: PID = 1234, child PID = 1235
Child:  PID = 1235, parent PID = 1234

The "Before fork" line prints once. After fork(), two processes execute the same code. The return value tells each process which role it plays.

         fork()
           |
     +-----+-----+
     |             |
  Parent          Child
  pid > 0         pid == 0
  (original)      (copy)

Caution: After fork(), the child inherits copies of all open file descriptors. If both parent and child write to the same fd without coordination, output will interleave unpredictably.

What the Child Inherits

The child gets copies of:

  • Memory (stack, heap, data, text segments -- copy-on-write)
  • Open file descriptors
  • Signal dispositions
  • Environment variables
  • Current working directory
  • umask

The child gets its own:

  • PID
  • Parent PID (set to the forking process)
  • Pending signals (cleared)
  • File locks (not inherited)
Parent Memory           Child Memory (after fork)
+------------------+    +------------------+
| text  (shared)   |    | text  (shared)   |
| data             |    | data  (COW copy) |
| heap             |    | heap  (COW copy) |
| stack            |    | stack (COW copy) |
| fd table [0,1,2] |    | fd table [0,1,2] |
+------------------+    +------------------+
         \                      /
          \-----> kernel <-----/
           (same open file descriptions)

Try It: Add a variable int x = 42; before fork(). In the child, set x = 99; and print it. In the parent, sleep one second, then print x. Confirm the parent still sees 42.

exec(): Replacing the Process Image

fork() gives you a clone. exec() replaces that clone with a different program entirely. The exec family includes: execl, execlp, execle, execv, execvp, execvpe. The differences are how you pass arguments and whether PATH is searched.

/* exec_basic.c */
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("About to exec 'ls -la /tmp'\n");

    /* execlp searches PATH for the binary */
    execlp("ls", "ls", "-la", "/tmp", (char *)NULL);

    /* If exec returns, it failed */
    perror("execlp");
    return 1;
}

After a successful exec(), the calling process's code, data, and stack are replaced. The PID stays the same. Open file descriptors without FD_CLOEXEC remain open.

Caution: If exec() returns at all, it has failed. Always follow an exec() call with error handling.

The fork-exec Pattern

This is the standard Unix way to run a new program:

/* fork_exec.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        /* Child: replace self with 'date' */
        execlp("date", "date", "+%Y-%m-%d %H:%M:%S", (char *)NULL);
        perror("execlp");
        _exit(127);  /* Use _exit in child after failed exec */
    }

    /* Parent: wait for child to finish */
    int status;
    waitpid(pid, &status, 0);

    if (WIFEXITED(status)) {
        printf("Child exited with status %d\n", WEXITSTATUS(status));
    }

    return 0;
}

Caution: In the child after a failed exec(), use _exit() instead of exit(). The exit() function flushes stdio buffers -- which are copies from the parent. This can cause duplicated output.

wait() and waitpid(): Reaping Children

When a child process terminates, the kernel keeps a small record of its exit status until the parent retrieves it. Until then, the child is a zombie.

/* wait_status.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        printf("Child running, PID = %d\n", getpid());
        exit(42);
    }

    int status;
    pid_t waited = waitpid(pid, &status, 0);

    if (waited < 0) {
        perror("waitpid");
        return 1;
    }

    if (WIFEXITED(status)) {
        printf("Child %d exited normally, status = %d\n",
               waited, WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("Child %d killed by signal %d\n",
               waited, WTERMSIG(status));
    } else if (WIFSTOPPED(status)) {
        printf("Child %d stopped by signal %d\n",
               waited, WSTOPSIG(status));
    }

    return 0;
}

The status macros decode the packed integer:

MacroMeaning
WIFEXITED(s)True if child exited normally
WEXITSTATUS(s)Exit code (0-255)
WIFSIGNALED(s)True if killed by signal
WTERMSIG(s)Signal that killed it
WIFSTOPPED(s)True if stopped (traced)
WSTOPSIG(s)Signal that stopped it

The Zombie Problem

A zombie is a process that has exited but whose parent has not called wait(). It occupies a slot in the process table.

/* zombie.c -- creates a zombie for 30 seconds */
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child exiting immediately\n");
        exit(0);
    }

    printf("Parent sleeping 30s -- child %d is a zombie\n", pid);
    printf("Run: ps aux | grep Z\n");
    sleep(30);

    /* Never reaps the child -- zombie persists until parent exits */
    return 0;
}

Try It: Run the zombie program. In another terminal, run ps aux | grep Z to see the zombie entry. Note the Z+ in the STAT column.

To avoid zombies in long-running servers, either:

  1. Call waitpid() periodically or after SIGCHLD.
  2. Set SIGCHLD to SIG_IGN (Linux-specific: auto-reaps children).
  3. Double-fork (child forks again, middle process exits immediately).

Rust: std::process::Command

Rust's standard library wraps fork-exec-wait into a safe, ergonomic API.

// command_basic.rs
use std::process::Command;

fn main() {
    let output = Command::new("date")
        .arg("+%Y-%m-%d %H:%M:%S")
        .output()
        .expect("failed to execute 'date'");

    println!("stdout: {}", String::from_utf8_lossy(&output.stdout));
    println!("status: {}", output.status);
}

Command::new does not fork immediately. Calling .output() forks, execs, and waits, returning all three streams and the exit status. For streaming output:

// command_stream.rs
use std::process::{Command, Stdio};
use std::io::{BufRead, BufReader};

fn main() {
    let mut child = Command::new("ls")
        .arg("-la")
        .arg("/tmp")
        .stdout(Stdio::piped())
        .spawn()
        .expect("failed to spawn");

    if let Some(stdout) = child.stdout.take() {
        let reader = BufReader::new(stdout);
        for line in reader.lines() {
            let line = line.expect("read error");
            println!("LINE: {}", line);
        }
    }

    let status = child.wait().expect("wait failed");
    println!("Exit status: {}", status);
}

Rust Note: Command handles the fork-exec-wait dance, fd cleanup, and error propagation. You never touch raw PIDs. The child is automatically waited on when the Child handle is dropped (though the drop does not block -- it just detaches).

Rust: Raw fork with the nix Crate

When you need the full power of fork(), the nix crate provides it:

// fork_nix.rs
// Cargo.toml: nix = { version = "0.29", features = ["process", "signal"] }
use nix::unistd::{fork, ForkResult, getpid, getppid};
use nix::sys::wait::waitpid;
use std::process::exit;

fn main() {
    println!("Before fork: PID = {}", getpid());

    match unsafe { fork() }.expect("fork failed") {
        ForkResult::Parent { child } => {
            println!("Parent: PID = {}, child = {}", getpid(), child);
            let status = waitpid(child, None).expect("waitpid failed");
            println!("Child exited: {:?}", status);
        }
        ForkResult::Child => {
            println!("Child: PID = {}, parent = {}", getpid(), getppid());
            exit(0);
        }
    }
}

Rust Note: fork() is unsafe in Rust because it duplicates the process including all threads' memory but only the calling thread. This can leave mutexes in a locked state with no thread to unlock them. Prefer Command unless you need pre-exec setup.

A Minimal Shell in 50 Lines

Putting it all together -- a shell that reads commands and runs them:

/* minishell.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define MAX_ARGS 64
#define MAX_LINE 1024

int main(void)
{
    char line[MAX_LINE];
    char *args[MAX_ARGS];

    for (;;) {
        printf("mini$ ");
        fflush(stdout);

        if (!fgets(line, sizeof(line), stdin))
            break;

        /* Strip newline */
        line[strcspn(line, "\n")] = '\0';
        if (line[0] == '\0')
            continue;

        /* Built-in: exit */
        if (strcmp(line, "exit") == 0)
            break;

        /* Tokenize */
        int argc = 0;
        char *tok = strtok(line, " \t");
        while (tok && argc < MAX_ARGS - 1) {
            args[argc++] = tok;
            tok = strtok(NULL, " \t");
        }
        args[argc] = NULL;

        /* Fork-exec */
        pid_t pid = fork();
        if (pid < 0) {
            perror("fork");
            continue;
        }
        if (pid == 0) {
            execvp(args[0], args);
            perror(args[0]);
            _exit(127);
        }

        int status;
        waitpid(pid, &status, 0);
        if (WIFEXITED(status) && WEXITSTATUS(status) != 0)
            printf("[exit %d]\n", WEXITSTATUS(status));
    }

    printf("\n");
    return 0;
}

Try It: Extend the mini shell to support the cd built-in command. Hint: cd must be handled by the parent process since chdir() in a child only affects the child.

Driver Prep: Kernel modules do not use fork/exec -- the kernel spawns kernel threads with kthread_create(). But user-space driver helpers, udev rules, and firmware loaders all rely on fork-exec. Understanding this pattern is essential for writing device manager daemons.

Knowledge Check

  1. What value does fork() return to the child process? What does the parent receive?

  2. Why should you call _exit() instead of exit() in a child process after a failed exec()?

  3. What is a zombie process, and how do you prevent zombies in a long-running server?

Common Pitfalls

  • Forgetting to wait: Every fork() needs a corresponding wait() or SIGCHLD handler. Otherwise: zombies.

  • Using exit() after failed exec in child: Flushes the parent's buffered stdio. Use _exit().

  • Assuming execution order: After fork(), the scheduler decides who runs first. Do not assume parent runs before child or vice versa.

  • Fork bombs: A loop that calls fork() unconditionally will exhaust the process table. Always guard fork with proper termination logic.

  • Ignoring exec failure: If exec() returns, it failed. Handle it.

  • Sharing file descriptors carelessly: Both parent and child share the same open file descriptions. Close fds you do not need in each process.