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;beforefork(). In the child, setx = 99;and print it. In the parent, sleep one second, then printx. 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 anexec()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 ofexit(). Theexit()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:
| Macro | Meaning |
|---|---|
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 Zto see the zombie entry. Note theZ+in the STAT column.
To avoid zombies in long-running servers, either:
- Call
waitpid()periodically or afterSIGCHLD. - Set
SIGCHLDtoSIG_IGN(Linux-specific: auto-reaps children). - 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:
Commandhandles the fork-exec-wait dance, fd cleanup, and error propagation. You never touch raw PIDs. The child is automatically waited on when theChildhandle 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()isunsafein 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. PreferCommandunless 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
cdbuilt-in command. Hint:cdmust be handled by the parent process sincechdir()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
-
What value does
fork()return to the child process? What does the parent receive? -
Why should you call
_exit()instead ofexit()in a child process after a failedexec()? -
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 correspondingwait()orSIGCHLDhandler. 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.