Loading content...
Every time you type a command in a terminal, double-click an application, or run a program from code, a dance occurs between two system calls: fork() and exec(). This two-step process creation model is the foundation of Unix process management and has remained virtually unchanged since 1969.
The fork-exec pattern might seem unnecessarily complex at first glance—why not just have a single call that creates a new process running a different program? But as we'll see, this separation provides extraordinary flexibility that a single-call model simply cannot match. Understanding why Unix chose this design illuminates fundamental principles of operating system architecture.
By the end of this page, you will understand why the fork-exec separation exists, master the standard implementation patterns, see how shells use fork-exec to execute commands, explore variations for different use cases, and learn about modern alternatives like posix_spawn().
Many operating systems have a single "spawn" or "CreateProcess" function that creates a new process running a specified program. Unix deliberately split this into two operations. This wasn't a limitation—it was a design choice that enables remarkable flexibility.
The key insight: Between fork() and exec(), the child process exists but hasn't yet transformed into the new program. During this window, the child can manipulate its own state—file descriptors, environment, working directory, process group, signal masks—and those changes become the starting conditions for the new program.
This window of opportunity is what makes Unix process control so powerful:
command > file redirects stdout by opening a file and dup2'ing it to fd 1 before execcmd1 | cmd2 works by setting up pipe file descriptors between fork and execInstead of having CreateProcess() with dozens of parameters for every possible setup option, Unix provides two simple primitives. You combine them with standard file and process operations (open, close, dup2, chdir, setpgid, setrlimit) to achieve any setup. This is the Unix philosophy: simple tools that compose powerfully.
Let's start with the fundamental pattern and then build up complexity.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h> int main() { printf("Parent: PID = %d", getpid()); pid_t pid = fork(); if (pid < 0) { // Fork failed perror("fork failed"); exit(1); } else if (pid == 0) { // ======== CHILD PROCESS ======== // This code runs in the child only printf("Child: PID = %d, about to exec ls", getpid()); // Replace child's process image with 'ls' execlp("ls", "ls", "-la", NULL); // Only reached if exec fails perror("exec failed"); _exit(127); // Use _exit in child after fork } else { // ======== PARENT PROCESS ======== // pid contains the child's PID printf("Parent: Created child with PID %d", pid); // Wait for child to complete int status; waitpid(pid, &status, 0); if (WIFEXITED(status)) { printf("Parent: Child exited with status %d", WEXITSTATUS(status)); } } return 0;}Critical points about this pattern:
fork() returns twice — Once in the parent (with child's PID) and once in the child (with 0)
Check fork() result — Negative means failure; handle it!
Child uses _exit(), not exit() — exit() runs atexit handlers and flushes stdio buffers, which can cause double-output or corruption. _exit() exits immediately.
Parent waits for child — Prevents zombie processes and collects exit status
exec() doesn't return on success — The code after exec() is only for error handling
If exec() fails in the child, use _exit(), not exit(). The exit() function runs cleanup handlers registered by the parent (atexit), flushes stdio buffers (causing duplicate output), and closes file descriptors (potentially corrupting parent's I/O). _exit() avoids all these issues.
One of the most common uses of the fork-exec window is I/O redirection. When you type ls > file.txt in a shell, this is what happens:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <fcntl.h>#include <sys/wait.h> // Implements: command > output.txtvoid redirect_stdout_to_file(char *command, char *output_file) { pid_t pid = fork(); if (pid == 0) { // Child: set up redirection before exec // 1. Open the output file (creates or truncates) int fd = open(output_file, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { perror("open"); _exit(1); } // 2. Duplicate fd to stdout (fd 1) // Now stdout points to our file dup2(fd, STDOUT_FILENO); // 3. Close the original fd (no longer needed) if (fd != STDOUT_FILENO) { close(fd); } // 4. Execute the command - its stdout goes to our file! execlp(command, command, NULL); perror("exec"); _exit(127); } // Parent: wait for child int status; waitpid(pid, &status, 0);} // Implements: command < input.txtvoid redirect_stdin_from_file(char *command, char *input_file) { pid_t pid = fork(); if (pid == 0) { // Child: redirect stdin int fd = open(input_file, O_RDONLY); if (fd < 0) { perror("open"); _exit(1); } dup2(fd, STDIN_FILENO); if (fd != STDIN_FILENO) { close(fd); } execlp(command, command, NULL); perror("exec"); _exit(127); } int status; waitpid(pid, &status, 0);} // Implements: command >> output.txt (append)void redirect_stdout_append(char *command, char *output_file) { pid_t pid = fork(); if (pid == 0) { int fd = open(output_file, O_WRONLY | O_CREAT | O_APPEND, 0644); if (fd < 0) { perror("open"); _exit(1); } dup2(fd, STDOUT_FILENO); close(fd); execlp(command, command, NULL); perror("exec"); _exit(127); } int status; waitpid(pid, &status, 0);} // Implements: command 2>&1 (redirect stderr to stdout)void redirect_stderr_to_stdout(char *command) { pid_t pid = fork(); if (pid == 0) { // Duplicate stdout to stderr // Now both fd 1 and fd 2 point to the same place dup2(STDOUT_FILENO, STDERR_FILENO); execlp(command, command, NULL); perror("exec"); _exit(127); } int status; waitpid(pid, &status, 0);} int main() { redirect_stdout_to_file("ls", "/tmp/ls_output.txt"); printf("Wrote ls output to /tmp/ls_output.txt"); return 0;}dup2(oldfd, newfd) makes newfd refer to the same file as oldfd. If newfd was already open, it's silently closed first. After dup2(fd, STDOUT_FILENO), any write to stdout goes to the file. The new program (after exec) sees this because file descriptors are inherited.
Pipelines like ls | grep foo | wc -l are implemented using the same fork-exec window, with pipes connecting the processes.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h> // Implements: cmd1 | cmd2void simple_pipeline(char *cmd1, char *cmd2) { int pipefd[2]; // Create the pipe if (pipe(pipefd) < 0) { perror("pipe"); exit(1); } // pipefd[0] = read end // pipefd[1] = write end // Fork first child (cmd1) pid_t pid1 = fork(); if (pid1 == 0) { // Child 1: stdout → pipe write end dup2(pipefd[1], STDOUT_FILENO); // Close both ends (we have stdout now) close(pipefd[0]); close(pipefd[1]); execlp(cmd1, cmd1, NULL); _exit(127); } // Fork second child (cmd2) pid_t pid2 = fork(); if (pid2 == 0) { // Child 2: stdin ← pipe read end dup2(pipefd[0], STDIN_FILENO); // Close both ends (we have stdin now) close(pipefd[0]); close(pipefd[1]); execlp(cmd2, cmd2, NULL); _exit(127); } // Parent: close pipe ends (children have copies) close(pipefd[0]); close(pipefd[1]); // Wait for both children waitpid(pid1, NULL, 0); waitpid(pid2, NULL, 0);} // Longer pipelines: generalized approachvoid execute_pipeline(char **commands, int n) { int prev_pipe = -1; // Read end from previous command for (int i = 0; i < n; i++) { int pipefd[2] = {-1, -1}; // Create pipe for all but last command if (i < n - 1) { if (pipe(pipefd) < 0) { perror("pipe"); exit(1); } } pid_t pid = fork(); if (pid == 0) { // Connect to previous command's output (if any) if (prev_pipe != -1) { dup2(prev_pipe, STDIN_FILENO); close(prev_pipe); } // Connect to next command's input (if any) if (pipefd[1] != -1) { dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); } execlp(commands[i], commands[i], NULL); _exit(127); } // Parent: bookkeeping if (prev_pipe != -1) close(prev_pipe); if (pipefd[1] != -1) close(pipefd[1]); prev_pipe = pipefd[0]; // Save read end for next iteration } // Close final read end (if any) if (prev_pipe != -1) close(prev_pipe); // Wait for all children for (int i = 0; i < n; i++) { wait(NULL); }} int main() { // ls | grep .c simple_pipeline("ls", "grep .c"); // ls | grep .c | wc -l char *cmds[] = {"ls", "grep", "wc"}; // (simplified - real version would handle arguments) return 0;}A common bug is forgetting to close unused pipe ends. If the write end of a pipe isn't closed by all processes that don't need it, the reader will wait forever (pipe never reaches EOF). Close pipe ends you're not using in both parent and children.
Every shell—bash, zsh, fish, dash—operates on the same fundamental loop using fork-exec. Here's a simplified shell implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <sys/wait.h>#include <signal.h>#include <fcntl.h> #define MAX_LINE 1024#define MAX_ARGS 64 // Parse a command line into argv arrayint parse_command(char *line, char **argv) { int argc = 0; char *token = strtok(line, " \t"); while (token != NULL && argc < MAX_ARGS - 1) { argv[argc++] = token; token = strtok(NULL, " \t"); } argv[argc] = NULL; return argc;} // Execute a single commandvoid execute_command(char **argv, int background) { pid_t pid = fork(); if (pid == 0) { // Child process // If background, detach from terminal if (background) { setpgid(0, 0); // New process group } // Restore default signal handling signal(SIGINT, SIG_DFL); signal(SIGQUIT, SIG_DFL); // Execute execvp(argv[0], argv); // Exec failed fprintf(stderr, "%s: command not found", argv[0]); _exit(127); } else if (pid > 0) { // Parent process if (background) { printf("[%d] %d", 1, pid); // Job number and PID } else { // Foreground: wait for child int status; waitpid(pid, &status, 0); } } else { perror("fork"); }} // Handle built-in commandsint builtin_command(char **argv) { if (argv[0] == NULL) return 1; if (strcmp(argv[0], "exit") == 0) { exit(0); } if (strcmp(argv[0], "cd") == 0) { if (argv[1] == NULL) { chdir(getenv("HOME")); } else { if (chdir(argv[1]) != 0) { perror("cd"); } } return 1; // Was a builtin } if (strcmp(argv[0], "pwd") == 0) { char cwd[1024]; if (getcwd(cwd, sizeof(cwd))) { printf("%s", cwd); } return 1; } return 0; // Not a builtin} // Main shell loopint main() { char line[MAX_LINE]; char *argv[MAX_ARGS]; // Ignore SIGINT in shell (pass to children) signal(SIGINT, SIG_IGN); while (1) { // Print prompt printf("mysh> "); fflush(stdout); // Read input if (fgets(line, MAX_LINE, stdin) == NULL) { printf(""); break; // EOF (Ctrl+D) } // Check for background execution int background = 0; char *amp = strchr(line, '&'); if (amp) { background = 1; *amp = '\0'; // Remove & } // Parse command int argc = parse_command(line, argv); if (argc == 0) continue; // Empty line // Try builtin first if (builtin_command(argv)) continue; // External command execute_command(argv, background); } return 0;}What real shells add beyond this:
cmd1 | cmd2 > file 2>&1 syntaxCommands like 'cd', 'export', and 'exit' are shell builtins—they run inside the shell process itself, not as child processes. This is necessary because they modify the shell's own state. If 'cd' ran as a child, the child's directory would change, then it would exit, and the parent shell would remain unchanged!
Different situations call for different variations of the fork-exec pattern.
123456789101112131415161718192021222324
// When you don't need to wait for the childvoid launch_background(char *program) { pid_t pid = fork(); if (pid == 0) { // Detach from controlling terminal setsid(); // Close standard file descriptors close(STDIN_FILENO); close(STDOUT_FILENO); close(STDERR_FILENO); // Optionally redirect to /dev/null open("/dev/null", O_RDONLY); // stdin open("/dev/null", O_WRONLY); // stdout open("/dev/null", O_WRONLY); // stderr execl(program, program, NULL); _exit(127); } // Parent continues immediately // Child becomes orphan (adopted by init) // No zombie accumulation because parent doesn't wait}Use this pattern for daemon-style launches where you don't care about the result. The child becomes fully detached from the parent.
Fork-exec has multiple failure points. Production code must handle all of them correctly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h>#include <errno.h>#include <string.h> typedef enum { EXEC_OK = 0, EXEC_FORK_FAILED, EXEC_EXEC_FAILED, EXEC_SIGNALED, EXEC_NONZERO_EXIT, EXEC_WAIT_FAILED} ExecResult; ExecResult robust_exec(char *program, char **argv, int *exit_code) { // Flush output buffers before forking // (prevents double output) fflush(stdout); fflush(stderr); pid_t pid = fork(); if (pid < 0) { // Fork failed fprintf(stderr, "fork failed: %s", strerror(errno)); return EXEC_FORK_FAILED; } if (pid == 0) { // Child process // Reset signals to default struct sigaction sa; sa.sa_handler = SIG_DFL; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGINT, &sa, NULL); sigaction(SIGQUIT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); // Execute execvp(program, argv); // If we're here, exec failed // Write error to stderr (goes to same place as parent's) fprintf(stderr, "exec '%s' failed: %s", program, strerror(errno)); // Exit with a specific code to indicate exec failure // Convention: 126 = command found but not executable // 127 = command not found _exit(errno == ENOENT ? 127 : 126); } // Parent process int status; pid_t waited; // Handle EINTR (interrupted by signal) do { waited = waitpid(pid, &status, 0); } while (waited == -1 && errno == EINTR); if (waited == -1) { fprintf(stderr, "waitpid failed: %s", strerror(errno)); return EXEC_WAIT_FAILED; } // Analyze exit status if (WIFEXITED(status)) { int code = WEXITSTATUS(status); if (exit_code) *exit_code = code; if (code == 126 || code == 127) { // exec failed in child return EXEC_EXEC_FAILED; } if (code != 0) { return EXEC_NONZERO_EXIT; } return EXEC_OK; } if (WIFSIGNALED(status)) { int sig = WTERMSIG(status); fprintf(stderr, "Process killed by signal %d (%s)", sig, strsignal(sig)); if (exit_code) *exit_code = 128 + sig; // Shell convention return EXEC_SIGNALED; } // Stopped or continued (shouldn't happen with waitpid) return EXEC_OK;} // Usage exampleint main() { char *argv[] = {"ls", "-la", "/nonexistent", NULL}; int code; ExecResult result = robust_exec("ls", argv, &code); switch (result) { case EXEC_OK: printf("Success!"); break; case EXEC_FORK_FAILED: printf("Failed to fork"); break; case EXEC_EXEC_FAILED: printf("Failed to execute command"); break; case EXEC_SIGNALED: printf("Process was killed by signal"); break; case EXEC_NONZERO_EXIT: printf("Process exited with code %d", code); break; default: printf("Unknown result"); } return 0;}By convention: 0 = success, 1-125 = application errors, 126 = command found but not executable, 127 = command not found, 128+N = killed by signal N. Following these conventions makes your programs behave predictably in shell scripts.
POSIX introduced posix_spawn() as a more efficient and less error-prone alternative to fork+exec for simple cases. It combines process creation and program execution in a single call.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
#include <stdio.h>#include <stdlib.h>#include <spawn.h>#include <sys/wait.h> extern char **environ; // Basic posix_spawn usagevoid basic_spawn() { pid_t pid; char *argv[] = {"ls", "-la", NULL}; int status = posix_spawn(&pid, "/bin/ls", NULL, // file_actions (no redirection) NULL, // attrp (default attributes) argv, environ); if (status != 0) { // Unlike fork, failure is returned directly fprintf(stderr, "posix_spawn failed: %d", status); return; } // pid contains new process ID waitpid(pid, NULL, 0);} // posix_spawn with I/O redirectionvoid spawn_with_redirection() { pid_t pid; posix_spawn_file_actions_t actions; // Initialize file actions structure posix_spawn_file_actions_init(&actions); // Add redirection: stdout → file posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, "/tmp/output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); // Add close action for unneeded fd // posix_spawn_file_actions_addclose(&actions, some_fd); char *argv[] = {"ls", "-la", NULL}; int status = posix_spawn(&pid, "/bin/ls", &actions, NULL, argv, environ); // Clean up posix_spawn_file_actions_destroy(&actions); if (status == 0) { waitpid(pid, NULL, 0); }} // posix_spawn with attributesvoid spawn_with_attributes() { pid_t pid; posix_spawnattr_t attr; posix_spawnattr_init(&attr); // Set flags for what attributes to use posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP | // Set process group POSIX_SPAWN_SETSIGDEF); // Reset signals // Set process group to new group posix_spawnattr_setpgroup(&attr, 0); // Set signals to reset to default sigset_t default_sigs; sigfillset(&default_sigs); posix_spawnattr_setsigdefault(&attr, &default_sigs); char *argv[] = {"myprogram", NULL}; int status = posix_spawnp(&pid, "myprogram", // Note: posix_spawnp for PATH search NULL, &attr, argv, environ); posix_spawnattr_destroy(&attr); if (status == 0) { waitpid(pid, NULL, 0); }}Use posix_spawn() for straightforward process launches where you only need standard redirections and attribute settings. Stick with fork-exec when you need complex setup (chroot, capabilities, custom file descriptor manipulation) or when you need to do conditional logic between fork and exec.
Fork-exec performance was historically a concern, but modern systems optimize it heavily.
Copy-on-Write (CoW):
Modern kernels don't actually copy the parent's memory during fork(). Instead, they mark all pages as shared and read-only. Only when either process tries to write does the kernel copy just that page. Since the child immediately calls exec() (which replaces all memory anyway), almost no copying occurs.
The fork() Myth:
"fork() is slow because it copies all memory"
This was true in the 1970s. Today, fork() of a multi-gigabyte process typically takes microseconds because nothing is actually copied until needed.
Legitimate Performance Concerns:
Page table copying — Even with CoW, the kernel must copy the page table entries. For a process with many memory mappings, this can take milliseconds.
Memory locking — If pages are locked in memory (mlock), they must be physically duplicated.
Huge pages — May not benefit from CoW in all cases.
Fork in multi-threaded programs — Must handle thread cancellation, lock state, etc.
| Method | Typical Time | Notes |
|---|---|---|
| fork() + exec() | ~1-5 ms | With CoW, highly optimized |
| vfork() + exec() | ~0.5-2 ms | Slightly faster, very restricted |
| posix_spawn() | ~1-3 ms | Similar to fork+exec on Linux |
| pthread_create() | ~50-200 µs | Much lighter than forking |
| Windows CreateProcess() | ~5-20 ms | More expensive than Unix fork+exec |
Unless you're spawning thousands of processes per second, fork-exec performance is rarely a bottleneck. Focus on correctness first. If profiling shows process creation is slow, consider thread pools, process pools, or connection-based architectures (like FastCGI) rather than micro-optimizing fork.
We've comprehensively explored the fork-exec pattern—the foundation of Unix process creation. Let's consolidate the key concepts:
Module Complete:
You've now mastered the exec() family:
With this knowledge, you understand how Unix creates processes—a foundational concept that underlies every program you run, every shell command you type, and every daemon that serves your infrastructure.
Congratulations! You've completed the exec() Family module. You now understand every aspect of how Unix transforms processes: the exec variants, process image replacement, argument and environment passing, and the fork-exec pattern. These concepts are fundamental to Unix systems programming and will serve you throughout your career.