DEV Community

Cover image for How Linux Handles a `fork()` When the Parent Has Gigabytes of Memory
Aditya Sharma
Aditya Sharma

Posted on

How Linux Handles a `fork()` When the Parent Has Gigabytes of Memory

A process with 8 GB of resident memory calls fork(). On a modern Linux system, the call returns in a few milliseconds. No 8 GB copy was made. The child is running.

But something happened. The kernel did real work, and more real work is coming. Where did the cost go?


What fork() Actually Creates

fork() creates a new process with its own process ID, its own file descriptor table, its own signal state, and critically its own virtual address space. The child's address space is initially identical to the parent's: same virtual address ranges, same mappings, same content.

What it does not have is its own copy of every physical memory page the parent mapped. The two processes share the underlying physical pages. This is the mechanism that makes fork() cheap at creation time, and it is called copy-on-write (COW).

The distinction between a virtual address space and the physical memory it references is the key to understanding everything that follows. Processes operate on virtual addresses. The MMU translates those to physical addresses using page tables. Two processes with different virtual address spaces can point at the same physical pages, and neither process needs to know.


What Happens Inside the Kernel

When a process calls fork(), the kernel enters kernel_clone() (the common implementation behind fork(), vfork(), and clone() in Linux 5.x+), which calls copy_process(). This function allocates a new task_struct for the child and copies or shares the parent's resources according to the clone flags.

For memory, the path goes through copy_mm(), which calls dup_mm(). This function allocates a new mm_struct the kernel's per-process memory descriptor and duplicates the parent's virtual memory areas (VMAs) and populated page-table entries.

VMAs are the kernel's records of what is mapped where. Each VMA describes a contiguous range of virtual addresses: its start and end, its permissions, whether it is file-backed or anonymous, and associated flags. A process with a large heap, several shared libraries, a stack, and a few mmap() regions may have dozens or hundreds of VMAs. Duplicating them means copying the metadata, not the pages they describe.

Page tables are the hardware-level translation structures that map virtual addresses to physical page frames. The kernel duplicates only the page-table entries that are actually populated that is, entries for pages that have been faulted in and are resident in physical memory. Virtual address space that has been reserved with mmap() but never accessed has no page-table entries yet, so it contributes nothing to page-table duplication cost at fork() time. The work is proportional to the number of resident, mapped pages, not to the total virtual address space size.

This matters for interpreting fork() latency. A process that has mmap()d several gigabytes but touched very little of it has a small page-table footprint and forks quickly. A process that has touched the bulk of its allocation has a large page-table footprint and forks more slowly, even if the two processes have the same virtual address space size.

During duplication, the kernel also marks all writable private PTEs as read-only in both parent and child. This write-protection is what arms the COW mechanism. A page that neither process writes after fork() stays shared and stays read-only in both page tables.


Copy-on-Write in Detail

After fork() returns, the parent and child share physical pages, and both have read-only PTEs for all private mappings that were writable. The moment either process tries to write to one of those pages, the hardware detects a write to a read-only PTE and raises a page fault.

The kernel's page-fault handler dispatches to do_wp_page() in mm/memory.c. This function checks whether the faulting page is truly shared between multiple processes or whether it can be made writable without copying.

If the page has only one reference because the other process has already gotten its own copy, or has exited do_wp_page() can simply make the PTE writable and return. No copy is made. The page is already exclusive.

If the page is genuinely shared, the handler allocates a new physical page, copies the content of the old page into it, updates the faulting process's PTE to point to the new page with write permission, and decrements the reference count on the old page. The write that triggered the fault then completes normally. The other process's PTE still points at the original page, still read-only.

This is the full COW sequence:

  1. Both PTEs marked read-only at fork() time.
  2. Write attempt triggers a hardware page fault.
  3. Kernel identifies a COW fault in do_wp_page().
  4. If page is still shared: allocate new page, copy content, update PTE, resume.
  5. If page is now exclusive: make PTE writable, resume without copying. The copy happens at write time, not at fork() time. The cost is paid per-page, the first time each page is modified after forking.

Anonymous pages heap allocations, stack, mmap(MAP_ANONYMOUS) behave this way. File-backed mappings (shared libraries, memory-mapped files) are different: they are backed by the page cache. A write to a private file-backed mapping similarly triggers a COW fault, but the resulting private copy is no longer associated with the underlying file.


Where the Real Cost Actually Appears

fork() is not free, even before any writes happen.

Page-table duplication is the primary upfront cost for processes with large resident footprints. On x86-64, a four-level page table tree must be allocated and populated for the child, covering all pages that are resident in the parent. This is real memory allocation and copying work. A process with a large working set -- many pages actually touched and resident has a proportionally larger page-table footprint. VMA metadata also scales with the number of distinct mapped regions: a process with many small, fragmented mappings is slower to fork than one with the same RSS organized into fewer, larger regions.

The practical consequence is that fork() latency is more tightly coupled to the number of resident, populated pages than to the total virtual address space size or the total allocated memory. A 32 GB process that has only touched 200 MB of its allocation may fork faster than a 4 GB process that has fully populated its working set.

Memory pressure increases after fork() because the child's page tables themselves consume physical RAM immediately. For a process with a large resident footprint, page tables can occupy a meaningful amount of memory that tools like top or htop do not surface directly. The Pss and Size fields in /proc/<pid>/smaps give a clearer picture.

Post-fork writes bring the deferred cost home. If the child modifies a large fraction of the inherited pages, it triggers a COW fault for each one. Each fault allocates a new physical page and copies its content. Modify enough pages and the total work approaches what a full upfront copy would have cost, distributed across the child's execution as latency spikes rather than paid at creation time.

Transparent Huge Pages (THP) can change the per-fault cost, but the behavior is not as simple as "one write copies 2 MB." When a COW fault occurs on a huge-page-backed anonymous mapping, the kernel may try to preserve the huge-page mapping by allocating a new 2 MB huge page and copying the original. If a suitable huge page is not available, or if the fault affects only part of a huge page, the kernel may instead split the huge page into regular 4 KB pages and handle the fault on the relevant small page alone. The outcome depends on memory availability, the kernel version, and the configuration. The net effect is that THP can increase per-fault cost compared to small pages when a full huge-page copy does occur, which is worth checking with grep AnonHugePages /proc/<pid>/smaps if you are diagnosing unexpectedly high post-fork memory growth.


A Reproducible Experiment

This C program allocates and touches a large anonymous region before fork(), measures the fork() call in isolation, and compares a read-heavy child against a write-heavy child.

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define REGION_SIZE (1UL << 30)  /* 1 GB */
#define PAGE_SIZE   4096

static long elapsed_us(struct timespec *a, struct timespec *b) {
    return (b->tv_sec - a->tv_sec) * 1000000L +
           (b->tv_nsec - a->tv_nsec) / 1000L;
}

int main(int argc, char *argv[]) {
    int write_heavy = (argc > 1 && argv[1][0] == 'w');

    /* Allocate and touch every page to make them all resident before fork() */
    char *mem = malloc(REGION_SIZE);
    if (!mem) { perror("malloc"); return 1; }
    for (size_t i = 0; i < REGION_SIZE; i += PAGE_SIZE)
        mem[i] = 1;

    printf("Region resident. Forking...\n");
    fflush(stdout);

    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);
    pid_t child = fork();
    clock_gettime(CLOCK_MONOTONIC, &t1);

    if (child < 0) { perror("fork"); return 1; }

    if (child == 0) {
        /* Child: read all pages or write all pages */
        volatile char sink = 0;
        if (write_heavy) {
            for (size_t i = 0; i < REGION_SIZE; i += PAGE_SIZE)
                mem[i] = 2;
        } else {
            for (size_t i = 0; i < REGION_SIZE; i += PAGE_SIZE)
                sink += mem[i];
        }
        (void)sink;

        struct rusage ru;
        getrusage(RUSAGE_SELF, &ru);
        /* Use fflush + _exit to ensure output is written without running
           stdio cleanup (which could allocate and trigger additional faults) */
        printf("Child (%s): majflt=%ld minflt=%ld\n",
               write_heavy ? "write" : "read",
               ru.ru_majflt, ru.ru_minflt);
        fflush(stdout);
        _exit(0);
    }

    /* Parent: report fork latency */
    printf("fork() latency: %ld us\n", elapsed_us(&t0, &t1));

    int status;
    waitpid(child, &status, 0);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Compile and run both modes:

gcc -O2 -o fork_test fork_test.c
./fork_test r   # read-heavy child
./fork_test w   # write-heavy child
Enter fullscreen mode Exit fullscreen mode

Interpreting minflt. The write-heavy child will show substantially more minor faults than the read-heavy child. Each COW fault appears as a minor fault (no disk I/O required). However, minflt counts all minor faults, including those triggered by stdio, stack growth, and other kernel activity not exclusively COW copies. It is a useful signal that COW activity is occurring, but not an exact count of pages physically copied. The read-heavy child should show a much smaller minor-fault count because reads against shared COW pages do not trigger faults.

You can also inspect memory after the child is running (before it exits) using:

grep -A 6 "Private_Dirty\|Shared_Clean\|AnonHugePages" /proc/<child_pid>/smaps | head -60
Enter fullscreen mode Exit fullscreen mode

Private_Dirty shows pages the child has copied and modified. Shared_Clean shows pages still shared with the parent. After a write-heavy run, Private_Dirty grows significantly. After a read-heavy run, it stays near zero.

What this experiment establishes. It shows that fork() latency for a fully-resident 1 GB allocation is distinct from post-fork memory access cost, and that write-heavy children incur significantly more minor faults than read-heavy ones. It does not establish universal numbers: results vary with kernel version, architecture, THP configuration, system memory pressure, and CPU cache state. Treat the output as a starting point for understanding your specific system's behavior, not as a benchmark to generalize from.


Common Misconceptions

"fork() instantly copies the entire process." No. Physical page contents are not copied at fork() time. Page-table entries for resident pages are duplicated and write-protected. Physical pages are copied lazily on write.

"Copy-on-write makes fork() free." No. Duplicating page-table entries for a large resident working set takes measurable time and consumes kernel memory. The work is real, just smaller than a full physical copy.

"The child immediately consumes additional RAM equal to the parent's RSS." No. Immediately after fork(), the child shares physical pages with the parent. Private memory grows gradually as COW faults occur. The child's page tables do consume additional physical RAM right away, but that is typically much smaller than the RSS.

"Every write after fork() copies a page." No. If the page has become exclusive because the other process already triggered a COW fault and got its own copy, or has exited the kernel can make the PTE writable without copying.

"Virtual address space size alone predicts fork() latency." No. The cost at fork() time is driven by the number of resident, populated page-table entries, not by the total virtual address space or the total amount allocated. A large sparsely-touched allocation forks cheaply.


Engineering Implications

Prefork servers (Apache's prefork MPM, Gunicorn, many Ruby application servers) fork worker processes from a parent that has already loaded application code and warmed data structures. The intent is for workers to share that memory via COW. It works well as long as workers read more than they write. Workers that heavily modify inherited data structures mutating caches, updating per-request counters in shared structures progressively diverge from shared pages and consume additional physical memory proportional to the pages they dirty.

Diagnosing unexpected memory growth after fork() starts with /proc/<pid>/smaps. Compare Shared_Clean, Private_Clean, and Private_Dirty across the parent and a worker over time. Growing Private_Dirty in workers indicates COW divergence. If AnonHugePages is significant and workers show high Private_Dirty, THP may be increasing per-fault copy cost; madvise(MADV_NOHUGEPAGE) on specific regions can disable THP selectively for those areas if that is the bottleneck.

posix_spawn() avoids duplicating the parent's page tables altogether. It creates a new process running a new executable directly. For patterns where fork() is immediately followed by exec(), posix_spawn() can reduce overhead because there is no COW setup to pay for and immediately discard. Whether this matters depends on the parent's resident footprint and spawn frequency; for many applications the difference is not significant, but it becomes relevant when forking frequently from a large process.

If fork() latency is a bottleneck, measure it in isolation (as the experiment above does), inspect page-table overhead with pmap -x or /proc/<pid>/smaps, and consider whether the process's resident working set can be reduced or whether large contiguous mappings can replace many fragmented ones.


The Core Principle

Linux defers the cost of copying process memory. It does not eliminate it. fork() shifts physical page copying from process creation time to the first write to each page in the child. The page-table duplication cost is paid upfront and scales with the number of resident, populated pages. The physical copy cost is paid on demand and scales with the number of pages the child actually modifies.

These two costs respond to different things and are diagnosed with different tools. Understanding the distinction is what separates a developer who knows that fork() "uses copy-on-write" from one who can explain why their prefork server's workers are consuming hundreds of additional megabytes after a few minutes of traffic.

Top comments (0)