Linux System Programming · intermediate · ~12 min
**What you will learn** - Call `fork()` to create a new child process and read its three possible return values (error, child, parent). - Branch each process onto a different code path using the return value, so parent and child do different work. - Explain copy-on-write and why `fork` is cheap even though it duplicates the whole address space. - Use `waitpid()` to collect a finished child and avoid leaving zombie processes behind. - Reason about inherited resources (open file descriptors, stdio buffers) and the bugs they cause. - Recognise and prevent the classic `fork` pitfalls: forgetting to `wait`, double-flushed buffers, and calling `exit` instead of `_exit` in the child.
A process is a running program: its own memory, its own open files, its own slot in the kernel's process table. Until now your programs have been a single process that starts at main (see the prerequisite lesson The main function) and runs straight through. fork() is the system call that lets one process become two.
When you call fork(), the operating system makes a near-exact duplicate of the calling process. The original is now called the parent; the duplicate is the child. Both processes are returned from the same fork() call and both continue running the next line of code. They have separate memory: a variable changed in one is not seen in the other.
The single most important idea is this: fork() is called once but returns twice — once in each process. The return value is how each side knows who it is:
fork returns 0.fork returns the child's PID (a positive number).fork returns -1 and sets errno.This is why fork always appears with a branch right after it. The branch is not optional decoration — it is the mechanism that splits one program into two cooperating processes.
Where is this used? Everywhere on Unix and Linux. Your shell forks to run every command. A web server forks worker processes. Build tools fork to compile files in parallel. fork is one of the oldest and most fundamental pieces of the Unix design, and the next lesson (exec — replacing the process image) shows the other half of the pattern: fork to make a new process, then exec to load a different program into it.
fork is the foundation of process creation on Unix-like systems. If you understand fork, you understand how the system actually launches everything.
Consider what relies on it:
ls, the shell forks a child and the child execs ls. The shell itself keeps running so it can show you the next prompt.make -j8 forks up to eight compiler processes at once.fork/clone.From a robustness standpoint, fork is also where a lot of subtle bugs live: leaked file descriptors, duplicated buffered output, and zombie processes that pile up until the system runs out of process slots. Knowing how the duplication works lets you write process code that is correct and that cleans up after itself.
Definition. fork() is a system call that creates a new process by duplicating the calling process. It is invoked one time but produces a return value in two processes.
How it works. The kernel creates a new entry in the process table, gives it a fresh PID, and sets up its address space as a copy of the parent's. Both processes are then scheduled to run, each resuming at the instruction right after the fork() call. The only observable difference at that instant is the value fork() handed back.
parent (PID 100)
|
fork() called
|
+--------+--------+
| |
returns 101 returns 0
(parent, PID 100) (child, PID 101)
| |
runs parent runs child
branch branch
When to use it. Whenever you need a separate process: to run another program (with exec), to do work concurrently, or to isolate a risky task in its own address space.
When NOT to use it. For lightweight in-process concurrency where shared memory is wanted, threads are usually a better fit. And in a large multi-threaded program, fork is dangerous (only the calling thread survives in the child, and locks held by other threads stay locked forever).
Pitfall. Treating fork() like a function that returns once. The line after fork() runs in both processes unless you branch.
Knowledge check: A program prints
"start\n", callsfork()once, then prints"end\n"with no branch. How many times does each word appear on screen?
Definition. The pattern of inspecting fork()'s return value to decide which code each process runs.
Explanation. Because both processes run the same code, you must split them by hand:
pid_t pid = fork();
if (pid < 0) {
/* error: no child was created */
} else if (pid == 0) {
/* child: pid is 0 here */
} else {
/* parent: pid holds the child's PID */
}
The child can learn its own PID with getpid() and its parent's with getppid(). The parent already knows the child's PID — it is the return value.
Pitfall. Checking if (pid == 0) for the parent, or forgetting the pid < 0 error case entirely. Always handle all three outcomes.
Knowledge check (predict the output): In the snippet above, if
fork()succeeds, which branch does the parent take and what is the value ofpidinside it?
Definition. An optimisation where the parent and child share the same physical memory pages until one of them writes to a page, at which point the kernel copies just that page.
How it works internally. Right after fork, the kernel does not duplicate gigabytes of memory. Instead it marks every shared page read-only and lets both processes point at the same physical pages. The first time either process writes to a page, the CPU traps, the kernel makes a private copy of that one page for the writer, and execution continues. Memory that is only read is never copied.
Before any write (pages shared, marked read-only):
parent page table --\
>--> [ physical page A ]
child page table --/
After child writes to that page (page copied):
parent page table ----> [ physical page A ]
child page table ----> [ physical page A' copy ]
Why it matters. This is what makes fork fast and memory-cheap, especially the common fork-then-exec case where the child immediately discards the copied memory anyway.
Pitfall. Assuming the child sees the parent's later changes (or vice versa). After fork, memory is logically independent — COW is an invisible optimisation, not shared memory.
Definition. The child inherits copies of the parent's open file descriptors, signal dispositions, working directory, and environment.
How it works. A file descriptor in the child refers to the same open file description in the kernel as the parent's. They share the file offset: if the parent reads 10 bytes, the child's descriptor now starts at offset 10 too. Descriptors stay open across fork (and across exec, unless the close-on-exec flag is set).
parent fd 3 --\
>--> [ open file description ] --> file on disk
child fd 3 --/ (shared offset)
When to use / when not. Sharing descriptors is exactly how shell pipelines work (one process's stdout is wired to another's stdin). But leaking descriptors a child does not need is a real bug — close what the child should not hold.
Pitfall. Buffered stdio. printf does not write immediately; it stores bytes in a user-space buffer. If you printf before forking without flushing, both parent and child inherit a copy of the unflushed buffer and both flush it later, so the text prints twice. Call fflush(stdout) before fork, or print only after the branch.
Knowledge check (find the bug): A program does
printf("hi")(no newline), thenfork(), and both children exit normally. Why mighthiappear twice in the output, and what one call beforeforkprevents it?
Definition. A zombie is a child that has terminated but whose exit status has not yet been collected by its parent. wait/waitpid collect that status and let the kernel free the child's last resources.
How it works. When a child exits, the kernel keeps a small record (its PID and exit status) so the parent can ask how it died. Until the parent calls wait/waitpid, that record lingers and the child shows up as <defunct> in process listings. If the parent never waits, zombies accumulate and can exhaust the process table.
Pitfall. Forking in a loop without ever waiting. Always reap your children.
#include <sys/types.h> /* pid_t */
#include <unistd.h> /* fork, getpid, getppid, _exit */
#include <sys/wait.h> /* waitpid, WIFEXITED, WEXITSTATUS */
pid_t pid = fork(); /* called once, returns in BOTH processes */
if (pid < 0) { /* -1: creation failed; errno is set */
perror("fork");
return 1;
} else if (pid == 0) { /* 0: this is the child */
/* do child work, then end the child */
_exit(0); /* _exit, not exit, in a forked child */
} else { /* >0: this is the parent; pid = child's PID */
int status;
waitpid(pid, &status, 0); /* collect the child, avoid a zombie */
}
Key points to notice:
pid_t is a signed integer type just large enough to hold a process ID.waitpid(pid, &status, 0) blocks until that specific child finishes; the macros WIFEXITED(status) and WEXITSTATUS(status) decode how it ended.pid_t fork(void) duplicates the current process. After it returns:
fork returns 0.fork returns the child's PID.fork returns -1.Both processes then continue from the same point in the code.
Their memory is independent. A change made in one process is not visible to the other.
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
printf("Before fork: I am PID %d\n", getpid());
fflush(stdout); /* flush so the line isn't duplicated by COW */
pid_t pid = fork();
if (pid < 0) { /* fork failed */
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
/* ---- child path: fork() returned 0 here ---- */
printf("Child: my PID is %d, my parent is %d\n",
getpid(), getppid());
_exit(EXIT_SUCCESS); /* _exit avoids re-running parent's atexit/buffers */
}
/* ---- parent path: pid holds the child's PID ---- */
printf("Parent: my PID is %d, my child is %d\n", getpid(), pid);
int status;
if (waitpid(pid, &status, 0) < 0) { /* wait for THIS child to finish */
perror("waitpid");
return EXIT_FAILURE;
}
if (WIFEXITED(status)) { /* did the child exit normally? */
printf("Parent: child exited with code %d\n", WEXITSTATUS(status));
}
return EXIT_SUCCESS;
}
What it does. The program prints its PID, forks, and then the two processes take different branches. The child prints its own PID and its parent's PID, then exits with success. The parent prints the child's PID, waits for the child to finish, decodes the exit status, and reports it.
Expected output (PIDs vary on each run, and the two middle lines may appear in either order because the processes run concurrently):
Before fork: I am PID 4120
Child: my PID is 4121, my parent is 4120
Parent: my PID is 4120, my child is 4121
Parent: child exited with code 0
Edge cases. If the system is at its process limit, fork returns -1 with errno == EAGAIN — the perror branch handles it. If the parent finished before the child, the child would be re-parented to init/systemd (PID 1); here waitpid prevents that. Compile with cc -std=c11 -Wall -Wextra fork_demo.c -o fork_demo.
Walkthrough of the key example:
printf("Before fork...") writes the start line into stdout's buffer. fflush(stdout) forces it to the terminal now, so the not-yet-written bytes are not copied into the child.pid_t pid = fork(); is the split point. The kernel creates the child and schedules both processes. Control returns to the next line in each one, with different pid values.if (pid < 0) runs in both processes but is true only when creation failed. On success neither process takes it.if (pid == 0) is true only in the child. The child prints its PID (getpid()) and its parent's PID (getppid()), then calls _exit(EXIT_SUCCESS) — ending the child without flushing the parent's inherited buffers a second time.pid == 0 block (its pid is the child's PID) and prints the child's PID directly from the variable.waitpid(pid, &status, 0) blocks the parent until that child terminates, then fills status.WIFEXITED(status) checks the child ended normally; WEXITSTATUS(status) extracts the 0 it passed to _exit.State trace at the moment just after fork() returns:
| Process | getpid() |
pid variable |
Branch taken |
|---|---|---|---|
| Parent | 4120 | 4121 (child) | else (parent) |
| Child | 4121 | 0 | pid == 0 (child) |
Because pid differs between the two processes, the same if/else sends each one down its own path. That is the whole trick of fork.
Mistake 1 — Forgetting to wait, creating zombies.
/* WRONG */
for (int i = 0; i < 5; i++) {
if (fork() == 0) { do_work(); _exit(0); }
}
/* parent never waits -> five zombies linger */
Why it's wrong: each finished child stays as a <defunct> record until reaped. Fix:
/* RIGHT */
pid_t kids[5];
for (int i = 0; i < 5; i++) {
kids[i] = fork();
if (kids[i] == 0) { do_work(); _exit(0); }
}
for (int i = 0; i < 5; i++)
waitpid(kids[i], NULL, 0); /* reap every child */
Recognise it: ps shows processes marked <defunct>. Prevent it: pair every successful fork with a wait.
Mistake 2 — Duplicated buffered output.
/* WRONG */
printf("log line"); /* no newline, not flushed */
fork(); /* both processes inherit the buffer */
Why it's wrong: the buffered bytes are copied into the child, and both flush at exit, so log line prints twice. Fix: fflush(stdout); before fork, or only print after branching. Recognise it: output lines appear doubled even though one process logged them.
Mistake 3 — exit instead of _exit in the child.
if (pid == 0) { exit(0); } /* WRONG in a forked-but-not-exec'd child */
Why it's wrong: exit() runs atexit handlers and flushes the inherited stdio buffers a second time, which can duplicate output or re-run cleanup that the parent already owns. Fix: use _exit(0) in a forked child that has not called exec.
Mistake 4 — Inverting the branch.
if (pid == 0) { /* code meant for the parent */ } /* WRONG */
Why it's wrong: 0 is the child. Putting parent logic there runs it in the wrong process. Recognise it: the wrong process reports the wrong PID. Prevent it: remember 0 = child, positive = parent.
Compiler errors
implicit declaration of 'fork' / 'getpid' — you forgot #include <unistd.h>.unknown type name 'pid_t' — include <sys/types.h>.implicit declaration of 'waitpid' or WIFEXITED undefined — include <sys/wait.h>.Runtime / logic errors
fork. Add fflush(stdout) before fork.fork returns -1. Check errno: EAGAIN means the process or memory limit was hit; print perror("fork") to see it.wait/waitpid. Reap every child.fork. Use pipes or shared memory to communicate.Tools
gdb: across a fork, choose the side to follow with set follow-fork-mode child (or parent).strace -f ./prog: the -f flag follows forked children so you see both processes' syscalls.pstree -p <pid>: visualise the parent/child tree.ps -el | grep defunct: spot zombies you forgot to reap.Questions to ask when it doesn't work: Did I handle all three return values? Did I flush before forking? Am I waiting for every child? Is my parent/child logic on the correct branch (0 = child)?
fork is memory-safe by design — each process gets its own logical address space — but several undefined-behaviour and robustness traps follow from duplication:
fflush) before fork, or you get duplicated output (a correctness bug, not strictly UB, but it surprises people).fork in multithreaded programs. Only the calling thread exists in the child; mutexes locked by other threads stay locked, so calling anything that takes those locks in the child can deadlock. Between fork and exec, restrict yourself to async-signal-safe functions.General habit: initialise everything before fork, flush buffers, and be explicit about which process owns and frees each resource.
Concrete uses
bash/zsh fork a child for every external command, then the child execs the program while the shell waits.make -jN forks up to N compiler processes for parallel builds.fork/clone.multiprocessing and Ruby's Unicorn server use fork under the hood.Professional best practices
Beginner rules:
-1, 0, >0).fflush buffered output before forking._exit in a forked child that has not exec'd.fork with a wait/waitpid.Advanced habits:
SIGCHLD handler or a wait loop so zombies never accumulate.fork (without immediate exec) in multithreaded programs; if unavoidable, call only async-signal-safe functions before exec.Beginner 1 — Hello from both. Write a program that prints Before fork once, then forks. After the fork, the child prints Hello from child and the parent prints Hello from parent. Requirements: handle the fork() < 0 error; fflush(stdout) before forking. Hint: branch on pid == 0. Concepts: call-once/return-twice, return-value branching.
Beginner 2 — Identify yourself. Extend task 1 so each process prints its own PID and its parent's PID. Example output: Child PID 5012, parent 5011 / Parent PID 5011, child 5012. Hint: use getpid() and getppid() in the child; the parent already has the child's PID from the return value. Concepts: getpid/getppid, return value as PID.
Intermediate 1 — Wait and report. Make the parent waitpid for the child, then print the child's exit code. Have the child exit with a chosen non-zero code (e.g. _exit(7)). Requirements: use WIFEXITED and WEXITSTATUS. Hint: the child's argument to _exit is what you should see decoded. Concepts: waiting, exit status macros, zombie avoidance.
Intermediate 2 — A small pool. Fork 3 children in a loop; each child sleeps a different amount (sleep(i+1)), prints its index, then _exit(i). The parent waits for all three and prints each one's exit code. Requirements: store each child's PID; reap every child. Constraint: the parent must not finish before any child. Hint: keep an array of PIDs and waitpid each. Concepts: fork-in-a-loop, reaping multiple children.
Challenge — Parent/child counter, no shared memory. Fork once. The child counts from 1 to 5, printing each number with a short sleep(1) between them, then _exit(0). The parent waits for the child and then prints Child done; parent never saw the count change. After the child exits, the parent prints its own copy of a variable both initialised to 0 before the fork to demonstrate that the child's increments did not affect the parent's copy. Requirements: show that memory is independent (no pipe, no shared memory). Hint: increment the variable only in the child; observe the parent's copy is still 0. Concepts: copy-on-write / independent memory, waiting.
fork() duplicates the calling process: parent and child both continue from the line after the call, with independent memory.-1 = error (check errno), 0 = you are the child, positive = you are the parent and the value is the child's PID.fork cheap — memory pages are shared until one side writes, then copied.fflush before forking, and decide which process owns each resource.wait (zombies), duplicated buffered output, using exit instead of _exit in the child, and inverting the parent/child branch.fork to make a process, exec to load a new program into it, wait to clean it up. Together they power process creation across all of Unix.