QUESTION
Why does memory usage not immediately double after a large process calls fork?
fork creates a new task and mm-related structures, but it does not copy the contents of every anonymous page. It write-protects the parent and child PTEs and makes them refer to the same physical pages; when either side writes, the page fault creates a private copy.
Copy-on-write is not free. It incurs page-table copying, TLB shootdowns, and later write faults and page copies. In a multithreaded process, the functions that the child may call after fork are also restricted.
STRUCTURE
Structure diagram
Parent page table
- VA 0x4000
- PTE: read-only + COW
- mapcount reference
Before write
- Physical page A
- content=1
- shared by parent + child
Child page table
- VA 0x4000
- PTE: read-only + COW
- write fault occurs
After child write
- Parent → page A
- Child → new page B
- content B=2
Immediately after fork, parent and child PTEs refer to the same physical page as read-only/COW. A new page appears only on the child's first write.
CALL PATH
Call path
Distinguish the duplication performed at fork from the duplication performed when the child writes a page. Summing RSS can count shared pages twice and misrepresent actual physical usage.
This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.
SOURCE COORDINATES
Linux 6.18.37 LTS source locations
Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.
| File | Function / structure | What to inspect |
|---|---|---|
| kernel/fork.c | kernel_clone(), copy_process() | Choose the new task and shared/duplicated flags |
| kernel/fork.c | copy_mm(), dup_mm() | CLONE_VM selection and mm_struct lifetime |
| mm/memory.c | copy_page_range(), do_wp_page() | COW page tables and write-protect faults |
COMPLETE PROGRAM
Complete runnable example
The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.
cc -std=c17 -Wall -Wextra -O2 fork_cow.c -o fork_cow01#define _DEFAULT_SOURCE
02#include <stdio.h>
03#include <stdlib.h>
04#include <sys/mman.h>
05#include <sys/wait.h>
06#include <unistd.h>
07
08int main(void)
09{
10 size_t length = 16 * 1024 * 1024;
11 unsigned char *area = mmap(NULL, length, PROT_READ | PROT_WRITE,
12 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
13 if (area == MAP_FAILED)
14 return 1;
15 for (size_t i = 0; i < length; i += 4096)
16 area[i] = 1;
17
18 pid_t pid = fork();
19 if (pid < 0)
20 return 1;
21 if (pid == 0) {
22 for (size_t i = 0; i < length; i += 4096)
23 area[i]++;
24 _exit(area[0] == 2 ? 0 : 2);
25 }
26
27 int status;
28 if (waitpid(pid, &status, 0) < 0)
29 return 1;
30 printf("parent=%u child_status=%d\n", area[0], WEXITSTATUS(status));
31 return munmap(area, length) != 0;
32}
CODE NOTES
Code notes
MAP_PRIVATE | MAP_ANONYMOUSCreates a private mapping unrelated to a file. The pages are initially shared after fork, but a write by one process is not visible to the other.
i += 4096The example writes one byte per typical 4 KiB page to fault in the physical pages beforehand. A real program must obtain the page size with sysconf(_SC_PAGESIZE).
pid_t pid = forkThe parent receives the child PID and the child receives 0; both execution flows start at the same following instruction.
area[i]++;Each first write by the child triggers a write-protect fault and creates a private physical page, which is the event being observed.
_exit(area[0]Uses a syscall-level exit in the post-fork child so it does not flush inherited stdio buffers again.
DETAILS
Detailed behavior
What gets copied is primarily the mapping policy, not page contents
A VMA describes an address range, protections, and file or anonymous backing. fork duplicates the VMA tree and page tables, and treats the PTEs of writable private mappings as read-only on both sides to induce write faults.
MAP_SHARED mappings and genuinely shared memory are not subject to COW; writes remain visible through the same backing page.
Multithreaded fork has a narrow safe interval
Only the thread that calls fork remains in the child. A userspace mutex held by another thread may be copied in the locked state, but no thread remains to unlock it.
Before exec, the child should call only async-signal-safe functions. Complete complex preparation in the parent or consider posix_spawn.
Use PSS to measure memory usage
Simply adding parent and child RSS counts shared COW pages twice. Compare Pss, Private_Dirty, and Shared_Dirty in /proc/PID/smaps_rollup immediately after fork and after the child writes.
When transparent huge pages are enabled, the split or copy granularity of one write fault affects the observed result.
OBJECTS
Objects and lifetimes
| Object | Creation and release | Values to inspect |
|---|---|---|
task_struct | Created in copy_process and finally released in release_task | pid, state, files/mm pointer |
mm_struct | Duplicated at fork and released when the last mm user departs | VMA, page table, mm_users |
COW page | Shared before fork and separated into private pages on a write fault | mapcount, PSS, dirty |
FAILURE PATH
Failure conditions and common misconceptions
| Observed symptom | Likely causes | How to verify |
|---|---|---|
| fork returns ENOMEM/EAGAIN | memory commit, pid/cgroup/user process limit | ulimit -u, pids.current, or overcommit settings |
| The child deadlocks | A userspace lock is held by a thread that disappeared | Inspect pthread_atfork and the list of calls made by the child |
| Latency spikes after fork | Page-table copying and COW faults | perf stat page-faults, smaps_rollup |
LAB
Verify it yourself
- Insert a sleep before the child's write loop and compare Pss in the parent and child smaps_rollup immediately after fork.
- Use perf stat to compare minor-faults with the write loop removed and with it retained.
- Change MAP_PRIVATE to MAP_SHARED and observe how area[0] changes in the parent.
./fork_cowstrace -f -e trace=clone,wait4,mmap,munmap ./fork_cowPRIMARY REFERENCES