Why fork() Doesn't Duplicate Memory Copy-on-Write and the Page Table

Why fork() Doesn’t Duplicate Memory: Copy-on-Write and the Page Table

fork() relies on Copy-on-Write (COW) by duplicating page table hierarchies while marking physical pages read-only. When child or parent processes write to dense memory states, the resulting cascade of page faults exhausts swap and triggers the kernel’s OOM killer.

Why Does fork() Fail to Scale on Large Heap Footprints?

Memory exhaustion during fork() occurs when processes operating large residential heaps trigger extensive memory duplication during state mutation. The kernel does not copy physical frames on invocation; instead, subsequent writes generate millions of micro-allocations that exhaust available system RAM and trigger catastrophic eviction loops under memory pressure.

+-------------------------------------------------------------------------+
|                  fork() / clone(CLONE_VM not set)                       |
+-------------------------------------------------------------------------+
                                     |
                                     v
                 +---------------------------------------+
                 | Duplicate Page Global Directory (PGD) |
                 | Copy P4D -> PUD -> PMD -> PTE Trees   |
                 +---------------------------------------+
                                     |
                                     v
                 +---------------------------------------+
                 | Clear Write Bit in Page Table Entries |
                 |       Set PTE Read-Only (RO)          |
                 +---------------------------------------+
                                     |
                                     v
                 +---------------------------------------+
                 | Increment Frame struct page->_refcount|
                 | Both mm_struct point to same frames   |
                 +---------------------------------------+
                                     |
                                     | [Store Instruction: mov [addr], val]
                                     v
                 +---------------------------------------+
                 | Hardware MMU Detects CR0.WP Violation |
                 |   Generates Vector 14 (#PF Exception) |
                 +---------------------------------------+
                                     |
                                     v
                 +---------------------------------------+
                 | Kernel do_page_fault() -> do_wp_page()|
                 +---------------------------------------+
                                     |
                  +------------------+------------------+
                  |                                     |
        [refcount == 1]                       [refcount > 1]
                  |                                     |
                  v                                     v
       +--------------------+               +-----------------------+
       | Restore Write Bit  |               | Allocate New 4KiB PFN |
       | No Data Copy       |               | memcpy(dst, src, 4096)|
       | Return to Ring 3   |               | Update PTE & Invalidate|
       +--------------------+               +-----------------------+
                                                        |
                                                        v
                                            +-----------------------+
                                            | TLB Shootdown (IPI)   |
                                            | Return to Ring 3      |
                                            +-----------------------+

Engineers operating massive in-memory databases like Redis or key-value caches lean heavily on background persistence engines. These engines rely on the POSIX fork() primitive to generate point-in-time snapshots while the main event loop serves live client requests. The operational assumption is that fork() executes in near-constant time with zero footprint due to Linux Copy-on-Write (COW) mechanics.

This assumption collapses when workloads feature sustained write throughput. When an enterprise dataset spans 64 GB of physical memory and a background snapshot process spawns, every subsequent write from the parent or the child process invalidates the shared physical address space. Rather than a frictionless background job, the system suffers from an avalanche of CPU trap handling, high TLB invalidation thrashing, and uncontrolled memory inflation.

How Does the Kernel Manipulate Page Tables During clone()?

Page table replication occurs when kernel/fork.c:dup_mmap() iterates over the virtual memory areas of a parent process and duplicates the hardware page directory structures into a child address space. The underlying physical frames remain uncopied while their respective table entries are explicitly stripped of write permissions.

At the hardware execution layer, Linux processes own an mm_struct, which references a distinct Page Global Directory (PGD). On x86_64 architectures running four-level paging, the physical address of this top-level directory is loaded into the CR3 control register during a context switch. When sys_clone() or sys_fork() executes, the kernel invokes copy_process(), which calls dup_mm(). Rather than copying memory, dup_mmap() iterates through every vm_area_struct (VMA) linked in the parent’s memory layout.

The kernel traverses the architectural page table tree: the PGD, the Page 4th Directory (P4D), the Page Upper Directory (PUD), the Page Middle Directory (PMD), and the lowest-level Page Table Entry (PTE). For each present PTE, copy_present_pte() executes:

  • It strips the write access bit (_PAGE_RW) from the PTE flags, leaving only read permissions active.
  • It sets the same read-only bit across both parent and child PTEs.
  • It maps both entries to the identical physical Page Frame Number (PFN).
  • It increments the reference count of the corresponding tracking structure (struct page->_refcount).

The operation finishes quickly because it processes only page tables, not the underlying resident sets. For a 64 GB heap using standard 4 KiB pages, this traversal still forces the allocation and manipulation of roughly 134 MB of raw page table pages (64 GB / 4 KiB * 8 bytes per PTE). While fast, this structure constitutes a silent trap.

DimensionStandard fork() with Copy-on-WriteShared Memory via CLONE_VM (vfork/Threads)
Page Table ArchitectureIndependent tree created; all PTEs set to read-onlyShared mm_struct; identical CR3 register state
Write Execution OverheadInitial write incurs page fault, hardware trap, and frame copyDirect store instruction via standard cache hierarchy
Physical Memory BoundaryGuaranteed isolation via lazy physical frame duplicationZero isolation; memory modifications mutate parent space
TLB Invalidation CostHeavy inter-processor interrupts (IPIs) during TLB shootdownsNegligible; TLB tags remain valid across execution
Allocation Failure RiskDeferred Out-of-Memory (OOM) kills during runtime writesImmediate failure at allocation time (ENOMEM)

What Happens When a Process Writes to a Read-Only COW Page?

Page fault exception handling occurs when the processor Memory Management Unit intercepts a store instruction targeting a virtual page whose write permission bit is cleared. The CPU raises hardware Vector 14, forcing the operating system to switch into kernel space and execute the architecture-specific fault handler.

When parent or child attempts to mutate an uncopied page, execution transitions from Ring 3 to Ring 0:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>
#include <sys/wait.h>

#define ALLOCATION_SIZE (1024 * 1024 * 4) /* 4 MiB */

int main(void) {
    /* Step 1: Allocate physical memory and touch it to ensure backing */
    char *shared_region = malloc(ALLOCATION_SIZE);
    if (!shared_region) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }
    memset(shared_region, 'A', ALLOCATION_SIZE);

    printf("[Parent Init] Virtual Address: %p | Initial Value: %c\n",
           (void *)shared_region, shared_region[0]);

    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        free(shared_region);
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        /* Child Context: Reads resolve to the same physical frame */
        printf("[Child Read]  Virtual Address: %p | Read Value: %c\n",
               (void *)shared_region, shared_region[0]);

        /* First write forces the MMU to trigger #PF (Vector 14) */
        printf("[Child Write] Mutating memory...\n");
        shared_region[0] = 'B'; 

        /* The child now operates on a distinct, copied physical frame */
        printf("[Child Post]  Virtual Address: %p | Mutated Value: %c\n",
               (void *)shared_region, shared_region[0]);
        
        free(shared_region);
        _exit(EXIT_SUCCESS);
    }

    /* Parent Context: Wait for child to force physical page replication */
    wait(NULL);

    /* The parent retains the original physical frame and original value */
    printf("[Parent Post] Virtual Address: %p | Parent Value: %c\n",
           (void *)shared_region, shared_region[0]);

    free(shared_region);
    return EXIT_SUCCESS;
}
[Parent Init] Virtual Address: 0x7f83a4200010 | Initial Value: A
[Child Read]  Virtual Address: 0x7f83a4200010 | Read Value: A
[Child Write] Mutating memory...
[Child Post]  Virtual Address: 0x7f83a4200010 | Mutated Value: B
[Parent Post] Virtual Address: 0x7f83a4200010 | Parent Value: A

The underlying pipeline proceeds in several phases:

  • Hardware Trap Generation: The MMU encounters a store instruction targeting a virtual address where the PTE has _PAGE_RW cleared. Because the control register CR0.WP (Write Protect) is asserted, the CPU blocks the write and loads the faulting address into CR2. It then executes the Vector 14 Interrupt Gate, landing in arch/x86/entry/entry_64.S.
  • Page Fault Routing: The kernel enters do_page_fault(), retrieving the architectural error code pushed to the stack. Because the access was a write (FAULT_FLAG_WRITE) targeting an authorized mapping (VM_WRITE), the kernel routes control to handle_mm_fault() and ultimately down to mm/memory.c:do_wp_page().
  • Physical Duplication: Inside do_wp_page(), the kernel checks the underlying struct page reference count. If refcount > 1, another context shares this frame. The kernel allocates a clean physical frame from the buddy allocator via alloc_page_vma(), performs an explicit hardware memory copy using copy_user_highpage(), and writes the child’s new PTE to point to this distinct PFN.
  • PTE Updates and Validation: The write bit (_PAGE_RW) is set on the new PTE, and the old frame’s reference count decrements by 1.
  • TLB Invalidation (TLB Shootdown): The local core’s Translation Lookaside Buffer entry for that address is invalidated via invlpg. If multiple threads or processes run across distinct CPU sockets, the kernel fires an Inter-Processor Interrupt (IPI) to force remote cores to flush their stale TLB entries.

This architectural path introduces significant latency penalties. What should be a single-cycle register-to-memory write transforms into a multi-thousand-cycle journey through kernel interrupt handling, memory allocation, cache-line pollution via memcpy, and inter-core cache serialization. If Transparent Huge Pages (THP) are active, a single write to a 4 KiB slice can force the kernel to allocate and copy a full 2 MiB contiguous chunk, multiplying memory write amplification by a factor of 512.

Systems relying on fork() for state replication operate under a fragile assumption: that runtime write volume remains low enough to amortize page replication overhead. Once high-velocity write pipelines breach this threshold, the resulting memory amplification triggers severe CPU stalls, unpredictable p99 latency spikes, and system destabilization.

References

Technical Troubleshooting FAQ

Why Does Redis Experience Latency Spikes During BGSAVE With “Background saving terminated by signal 9”?

Signal 9 terminations during BGSAVE occur when Linux invokes the Out-of-Memory (OOM) killer to terminate the child dumping process. The host runs out of physical RAM and swap space due to intense write traffic from the parent process during snapshotting, which forces physical duplication of copy-on-write memory pages.

To resolve this issue:

  • Enable memory overcommit by setting sysctl vm.overcommit_memory=1 in /etc/sysctl.conf.
  • Disable Transparent Huge Pages via echo never > /sys/kernel/mm/transparent_hugepage/enabled to prevent 2 MiB page write amplification during COW.
  • Size server RAM so the maximum resident set size (RSS) never exceeds 60% of total host memory capacity.

Why Does fork() Fail With “Cannot allocate memory” (ENOMEM) When Free RAM Exceeds Process Size?

Memory allocation errors occur when strict overcommit accounting blocks the cloning of virtual address spaces that could potentially exceed total physical commitments. The kernel’s memory management heuristics reject the system call because the requested virtual memory space exceeds the commit ceiling configured in system parameters.

To resolve this issue:

  • Verify the current overcommit policy by running sysctl vm.overcommit_memory.
  • If set to 2 (strict non-overcommit), inspect /proc/meminfo for CommitLimit and Committed_AS.
  • Raise the overcommit ratio by setting sysctl vm.overcommit_ratio=80 or temporarily switch to heuristic overcommit via sysctl vm.overcommit_memory=0.

You may also like

See All Posts →