Copy-on-Write and fork

Copy-on-write (COW) is the trick that makes fork() cheap: instead of physically duplicating every page of the parent’s address space for the child, the kernel hands the child the same physical pages and marks the page-table entries in both processes read-only. As long as neither side writes, they share one copy. The first write to a shared page faults, and the fault handler quietly allocates a private copy for the writer — “breaking COW” — so the illusion of two independent address spaces is preserved at the cost of one fault and one page-copy per page actually modified (LWN, Patching until the COWs come home, part 1). The whole mechanism reduces to a single question asked at write-fault time — is this page still shared? — and getting that question wrong has produced three separate CVEs across sixteen years: Dirty COW (CVE-2016-5195, exploited in the wild in October 2016), the get_user_pages-after-fork corruption (CVE-2020-29374), and the tmpfs-restricted Dirty COW variant (CVE-2022-2590). The modern kernel stopped inferring the answer from reference counts and started recording it, in a per-page bit called PG_anon_exclusive, merged for Linux 5.19 (commit 78fbe906). This note traces COW from the fork() page-table walk, through the write fault, into the exclusivity tracking and the pinning interaction, and out the other side to the practical consequence people actually hit: forking a process with a large resident set. All code is read from the Linux 6.12 LTS tree; anything from a later release is dated explicitly.

Version pin

Every code excerpt below is from the v6.12 long-term-support tree, read directly from raw.githubusercontent.com/torvalds/linux/v6.12/…. Mainline has since moved to the 7.x series; where a mechanism changed after 6.12 that is called out inline. Historical claims name the release the change landed in and, where it matters, the commit.

Mental Model — Share Until Someone Writes

The right way to think about COW is as a deferred copy. fork() promises the child a private duplicate of the parent’s memory, but it does not deliver the copy up front — it delivers a shared, frozen view plus a promise to make a private copy the instant either party tries to change it. Freezing is done by write-protecting both copies of every page-table entry. The write attempt is the trigger that converts one shared page into two divergent private pages.

Most pages are never written after a fork. The child usually execs a new program almost immediately and throws the entire inherited address space away, so most of those promised copies are never paid for. That is why fork() followed by exec() is fast even for a multi-gigabyte parent: the manual page notes the child is created “using copy-on-write pages,” so “the only penalty that it incurs is the time and memory required to duplicate the parent’s page tables, and to create a unique task structure for the child” (fork(2)).

flowchart TB
  subgraph BEFORE["1. After fork() — one page, two read-only PTEs"]
    PP["Parent PTE<br/>points to page P<br/>W bit = 0"]
    CP["Child PTE<br/>points to page P<br/>W bit = 0"]
    PG["physical page P<br/>refcount = 2<br/>mapcount = 2<br/>PageAnonExclusive = 0"]
    PP --> PG
    CP --> PG
  end
  WF["2. Either side writes<br/>MMU raises a write fault:<br/>write to a present, read-only PTE"]
  Q{"3. do_wp_page asks:<br/>is P still shared?"}
  REUSE["REUSE — wp_page_reuse&#40;&#41;<br/>flip this PTE writable in place,<br/>set PageAnonExclusive.<br/>No allocation, no memcpy."]
  COPY["COPY — wp_page_copy&#40;&#41;<br/>allocate P', memcpy 4 KiB,<br/>repoint the faulter's PTE at P'<br/>as writable + exclusive."]
  BEFORE --> WF --> Q
  Q -->|"no — PageAnonExclusive set,<br/>or refcount proves sole owner"| REUSE
  Q -->|"yes — someone else still holds it"| COPY

The two-state life of a COW page, and the one decision that defines the whole mechanism. What it shows: fork() leaves a page mapped by both processes with both page-table entries (PTEs) write-protected; the first write faults into do_wp_page, which either reuses the page in place or allocates a fresh copy for the writer. The insight to take: the entire correctness of COW reduces to step 3. The pre-5.19 kernel answered it with fragile reference-count heuristics and got it wrong three times, with CVEs to show for it; the modern kernel answers it with a dedicated per-page exclusivity bit, PageAnonExclusive, and falls back on counts only when the bit is clear.

Two vocabulary items are load-bearing for the rest of this note, and conflating them is the single most common way to misread the code:

  • folio_ref_count(folio) — the reference count: how many things anywhere in the kernel are holding this folio alive. Page tables contribute to it, but so does the swap cache, the LRU, a get_user_pages caller, a page-cache lookup in flight, and the local variable in the fault handler itself. A non-zero refcount means “do not free me.”
  • folio_mapcount(folio) — the map count: how many page-table entries point at this folio. A mapcount of 2 means exactly two address spaces (or two addresses) can reach the page through the MMU.

A folio is the kernel’s name for a power-of-two-sized group of physically contiguous pages managed as one unit — a single 4 KiB page is an order-0 folio, a 2 MiB transparent huge page is an order-9 folio. The distinction matters here because wp_can_reuse_anon_folio() refuses to reuse any large folio (see Transparent Huge Pages and Folios and the Page Cache).

Sharing is a mapcount question. Safety is a refcount question. The historic bugs all come from answering the safety question with the sharing count.

What fork() Actually Costs

Before the mechanism, the price list — because “COW makes fork free” is the misconception this section exists to kill. fork() does not copy pages, but it does three things whose cost scales with the parent, and one of them scales badly.

Work itemScales withCost for a 24 GiB resident-set parentDeferred by COW?
Duplicate task_struct, credentials, fd tablenumber of open fdsmicrosecondsn/a
Duplicate the VMA tree (vm_area_struct per mapping)map_count, i.e. lines in /proc/PID/mapstens of µs for a few hundred VMAsno
Copy the page tables (copy_page_range)resident pages~48 MiB of new page-table memory, allocated and writtenno — paid up front
Write-protect every parent PTEresident pagesone RMW per PTE, plus a full TLB flush of the parentno
Copy page contentspages actually written afterwards4 KiB memcpy + fault, per page touchedyes — this is the COW part

The page-table number is Redis’s own arithmetic, and it is worth walking symbol by symbol because it is the number that surprises people. On Linux/AMD64 memory is divided into 4 KiB pages; the last level of the page table stores one 8-byte entry per page. So a process with 24 GiB resident needs 24 GiB ÷ 4 KiB × 8 bytes = 48 MiB of last-level page-table entries, and “when a background save is performed, this instance will have to be forked, which will involve allocating and copying 48 MB of memory” (Redis, Diagnosing latency issues). That 48 MiB is allocated, filled, and touched synchronously inside the fork() syscall, in the parent’s thread. Redis’s measurements put the resulting stall at roughly 9–13 ms per GiB of resident set on physical hardware and modern virtualization, degrading to 239–424 ms per GiB on older Xen-based instances — one to two orders of magnitude worse (same source).

The kernel does have one significant escape hatch, and it is easy to miss. copy_page_range() starts by asking vma_needs_copy() whether the VMA’s page tables need duplicating at all (mm/memory.c, v6.12):

static bool
vma_needs_copy(struct vm_area_struct *dst_vma, struct vm_area_struct *src_vma)
{
	if (userfaultfd_wp(dst_vma))          /* uffd-wp state lives only in the PTEs */
		return true;
	if (src_vma->vm_flags & (VM_PFNMAP | VM_MIXEDMAP))
		return true;                       /* raw-PFN mappings have no page to fault in */
	if (src_vma->anon_vma)                 /* has ever taken an anonymous fault -> copy */
		return true;
	/*
	 * Don't copy ptes where a page fault will fill them correctly.  Fork
	 * becomes much lighter when there are big shared or private readonly
	 * mappings. The tradeoff is that copy_page_range is more efficient
	 * than faulting.
	 */
	return false;
}

Reading it: a VMA whose anon_vma is still NULL has never taken an anonymous page fault, so everything in it is file-backed and re-derivable from the page cache. The child’s page tables for that range are simply left empty, and the child re-faults them in on demand if it ever touches them. The comment states the trade-off honestly — bulk-copying PTEs is cheaper per page than faulting, so this is a bet that the child will touch few of those pages, which is exactly right for the fork-then-exec case. The consequence for the cost table above: the page-table copy scales with anonymous resident memory, not total resident memory. A process whose bulk is a mmaped read-only data file forks far more cheaply than its RSS suggests.

Finally, three things that are not inherited and which people assume are (kernel/fork.c, v6.12, and fork(2)):

  • Memory locks. dup_mmap() executes vm_flags_clear(tmp, VM_LOCKED_MASK) on every copied VMA, so an mlock()ed region in the parent is not locked in the child. A child that needs its secrets kept out of swap must re-mlock.
  • MADV_DONTFORK ranges. A VMA carrying VM_DONTCOPY is skipped entirely — vma_iter_clear_gfp() leaves a hole in the child’s address space.
  • MADV_WIPEONFORK ranges. A VMA carrying VM_WIPEONFORK gets tmp->anon_vma = NULL and copy_page_range() is not called for it at all, so “VM_WIPEONFORK gets a clean slate in the child” (source comment).

The fork() Side — Write-Protect and Share

fork() (and clone() without CLONE_VM) duplicates the parent’s address space by copying its page tables, not its pages. copy_page_range() walks every present PTE of every VMA that vma_needs_copy() approved and, for private mappings, installs the same physical-page reference in the child while write-protecting both entries. The per-PTE work happens in copy_present_ptes()__copy_present_ptes() (mm/memory.c, v6.12):

static __always_inline void __copy_present_ptes(struct vm_area_struct *dst_vma,
		struct vm_area_struct *src_vma, pte_t *dst_pte, pte_t *src_pte,
		pte_t pte, unsigned long addr, int nr)
{
	struct mm_struct *src_mm = src_vma->vm_mm;
 
	/* If it's a COW mapping, write protect it both processes. */
	if (is_cow_mapping(src_vma->vm_flags) && pte_write(pte)) {
		wrprotect_ptes(src_mm, addr, src_pte, nr);   /* the PARENT's live PTE */
		pte = pte_wrprotect(pte);                     /* the value stored into the CHILD */
	}
 
	/* If it's a shared mapping, mark it clean in the child. */
	if (src_vma->vm_flags & VM_SHARED)
		pte = pte_mkclean(pte);
	pte = pte_mkold(pte);                             /* child starts with a cold access bit */
 
	if (!userfaultfd_wp(dst_vma))
		pte = pte_clear_uffd_wp(pte);
 
	set_ptes(dst_vma->vm_mm, addr, dst_pte, pte, nr);
}

Line by line. is_cow_mapping() is defined in include/linux/mm.h as (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE — a mapping is a COW mapping exactly when it is not shared and it is at least potentially writable. wrprotect_ptes() clears the writable bit on the parent’s existing, live PTE; this is the half people forget, and it is the half that makes the scheme correct. If only the child’s copy were protected, the parent could keep writing freely and the child would observe those writes through a mapping it believes is private. pte_wrprotect(pte) then clears the same bit on the value about to be stored in the child. pte_mkold() clears the accessed bit so the child’s pages start cold for reclaim purposes. The nr argument is a batch count: since the 6.x large-anon-folio work, copy_present_ptes() calls folio_pte_batch() to detect a run of consecutive PTEs mapping consecutive pages of the same folio and copies them in one go.

The accompanying reverse-mapping update is where exclusivity tracking lives. For an anonymous page the kernel calls folio_try_dup_anon_rmap_pte(), and this is the function that clears the exclusivity bit, because the page is about to become shared between two address spaces (include/linux/rmap.h, v6.12):

/* __folio_try_dup_anon_rmap(), RMAP_LEVEL_PTE, small folio: */
if (!folio_test_large(folio)) {
	if (PageAnonExclusive(page))
		ClearPageAnonExclusive(page);   /* page is now shared parent <-> child */
	atomic_inc(&folio->_mapcount);      /* one more page table references it */
	break;
}

So the rule that holds the whole system together is: on fork, an anonymous page that was exclusive becomes non-exclusive. The bit means “exactly one process maps this, and it may therefore be written without further checks”; sharing it with a child makes that false, so the bit is cleared. Symmetrically, a freshly faulted anonymous page is created with the bit set — do_anonymous_page() adds the rmap with the RMAP_EXCLUSIVE flag (see The Zero Page and Lazy Allocation for that allocation path).

One more piece of machinery is armed around the whole walk. For COW mappings, copy_page_range() brackets the page-table copy in a sequence counter on the source mm:

	if (is_cow) {
		mmu_notifier_invalidate_range_start(&range);
		vma_assert_write_locked(src_vma);
		raw_write_seqcount_begin(&src_mm->write_protect_seq);
	}
	/* ... walk and copy every pgd/p4d/pud/pmd/pte ... */
	if (is_cow) {
		raw_write_seqcount_end(&src_mm->write_protect_seq);
		mmu_notifier_invalidate_range_end(&range);
	}

write_protect_seq is odd for the duration of the fork. Lockless GUP-fast readers check it to detect “a fork is write-protecting my PTEs right now” and bail out to the slow, locked path. It is the synchronization that makes the pin-versus-fork race in the next sections tractable at all — and folio_needs_cow_for_dma() carries a VM_BUG_ON asserting the counter is odd, i.e. that it may only be called from inside this window.

The geometry, drawn

Page-table sharing is pointer-and-counter geometry, and prose is a bad medium for it. Mermaid cannot draw a four-level radix tree with per-node counters legibly, so this one is an ASCII box diagram.

 (a) BEFORE fork -- parent alone, page freshly faulted in
                                            page P (order-0 folio)
  parent mm                                +--------------------------+
   pgd -> p4d -> pud -> pmd -> [PTE_A]---->| refcount   = 1           |
                               W=1 A=1 D=1 | mapcount   = 1           |
                                           | AnonExclusive = 1        |
                                           +--------------------------+
        writable, exclusive: a store hits the page directly, no fault


 (b) AFTER fork -- page tables duplicated, contents shared
  parent mm                                 page P
   pgd -> ... -> [PTE_A]------------------>+--------------------------+
                  W=0  <-- cleared by      | refcount   = 2           |
                          wrprotect_ptes() | mapcount   = 2           |
                                           | AnonExclusive = 0  <-- cleared by
  child mm                                 |                    folio_try_dup_
   pgd -> ... -> [PTE_B]------------------>|                    anon_rmap_pte()
                  W=0  <-- cleared by      +--------------------------+
                          pte_wrprotect()
        ~48 MiB of NEW page-table nodes for a 24 GiB parent: paid now.
        0 bytes of page CONTENT copied: deferred.


 (c) AFTER the parent writes -- COW broken for this one page
  parent mm                                 page P'  (fresh allocation)
   pgd -> ... -> [PTE_A]------------------>+--------------------------+
                  W=1 D=1                  | refcount = 1             |
                                           | mapcount = 1             |
                                           | AnonExclusive = 1        |
                                           +--------------------------+
  child mm                                  page P
   pgd -> ... -> [PTE_B]------------------>+--------------------------+
                  W=0  (still read-only!)  | refcount = 1             |
                                           | mapcount = 1             |
                                           | AnonExclusive = 0 <-- STALE
                                           +--------------------------+
        The child's PTE is untouched. When the child later writes, it faults
        too -- but now refcount == 1, the reuse check passes, and it takes
        page P in place. Two processes -> one extra page, not two.

The three states of one COW page, with the counters that drive every decision. What it shows: fork multiplies page-table nodes but not page contents; the write fault splits one page into two and leaves the non-faulting side alone. The insight to take: panel (c) is the part that is easy to get wrong. Breaking COW does not fix up the other process’s PTE or its AnonExclusive bit — the child’s bit stays 0 even though it is now the sole owner. That staleness is deliberate and harmless: the bit is a one-way “definitely safe” hint, never a “definitely shared” claim, so a stale 0 costs at most one extra trip through the slow wp_can_reuse_anon_folio() check. A stale 1 would be a memory-corruption bug, which is why the bit is only ever set by the code that just proved exclusivity.

The Write-Fault Side — do_wp_page Breaks COW

When a process writes to one of these write-protected pages, the MMU raises a fault. The fault handler sees a write to a present but read-only PTE inside a writable VMA — a combination that means “the permission bits are a kernel-imposed lie, not a program error” — and routes it to do_wp_page() (“write-protect page”). Its job is to decide between reuse (the page is no longer actually shared; just make it writable in place) and copy (the page is still shared; the writer needs its own).

sequenceDiagram
    autonumber
    participant CPU as CPU / MMU
    participant ARCH as do_user_addr_fault<br/>arch layer
    participant HMF as handle_mm_fault<br/>handle_pte_fault
    participant WP as do_wp_page
    participant REUSE as wp_page_reuse
    participant COPY as wp_page_copy
    participant PA as page allocator

    CPU->>ARCH: page-fault trap, error code<br/>says WRITE and PRESENT
    ARCH->>ARCH: find VMA, access_error&#40;&#41;<br/>VM_WRITE is set, so this is<br/>a COW fault, not a SIGSEGV
    ARCH->>HMF: handle_mm_fault&#40;FAULT_FLAG_WRITE&#41;
    HMF->>HMF: PTE present, !pte_write,<br/>FAULT_FLAG_WRITE set
    HMF->>WP: do_wp_page&#40;vmf&#41; with the<br/>page-table lock held
    WP->>WP: vm_normal_page&#40;&#41; gives struct page<br/>page_folio&#40;&#41; gives struct folio
    alt VM_SHARED mapping
        WP->>WP: wp_page_shared&#40;&#41; — never copies;<br/>calls page_mkwrite, marks dirty
    else private anon, PageAnonExclusive set
        WP->>REUSE: fast path, no further checks
        REUSE-->>CPU: ptep_set_access_flags:<br/>PTE now W=1 D=1 A=1<br/>count_vm_event&#40;PGREUSE&#41;
    else private anon, bit clear
        WP->>WP: wp_can_reuse_anon_folio&#40;&#41;
        alt refcount proves sole owner
            WP->>WP: SetPageAnonExclusive&#40;page&#41;
            WP->>REUSE: reuse in place
            REUSE-->>CPU: PTE now writable
        else still shared
            WP->>WP: folio_get&#40;old&#41;, drop PT lock
            WP->>COPY: wp_page_copy&#40;vmf&#41;
            COPY->>COPY: delayacct_wpcopy_start&#40;&#41;
            COPY->>PA: folio_prealloc&#40;&#41; — may sleep,<br/>may reclaim, may fail with OOM
            PA-->>COPY: fresh folio P'
            COPY->>COPY: __wp_page_copy_user&#40;&#41; — the 4 KiB memcpy
            COPY->>COPY: retake PT lock, pte_same&#40;&#41; recheck
            COPY->>COPY: ptep_clear_flush&#40;&#41; then set_pte_at&#40;&#41;<br/>then folio_remove_rmap_pte&#40;old&#41;
            COPY-->>CPU: PTE points at P', W=1,<br/>RMAP_EXCLUSIVE<br/>delayacct_wpcopy_end&#40;&#41;
        end
    end

The COW write fault end to end, from the hardware trap to the resolved PTE. What it shows: the fault does not go anywhere near the filesystem or swap — it is pure page-table and allocator work — and there are four distinct outcomes, only one of which allocates. The insight to take: the expensive branch (wp_page_copy) drops the page-table lock, may sleep in the allocator, and therefore must re-validate with pte_same() when it comes back. That re-check is not paranoia: another thread of the same process can resolve the identical fault while this one is sleeping, in which case this fault silently discards its freshly allocated page and returns. Every step marked with a delayacct_wpcopy_* bracket is visible in per-task delay accounting, which is how you measure COW cost in production.

The v6.12 decision itself is a single, terse condition (mm/memory.c, v6.12):

	/*
	 * Private mapping: create an exclusive anonymous page copy if reuse
	 * is impossible. We might miss VM_WRITE for FOLL_FORCE handling.
	 *
	 * If we encounter a page that is marked exclusive, we must reuse
	 * the page without further checks.
	 */
	if (folio && folio_test_anon(folio) &&
	    (PageAnonExclusive(vmf->page) || wp_can_reuse_anon_folio(folio, vma))) {
		if (!PageAnonExclusive(vmf->page))
			SetPageAnonExclusive(vmf->page);
		if (unlikely(unshare)) {
			pte_unmap_unlock(vmf->pte, vmf->ptl);
			return 0;               /* GUP unshare: exclusive is all it wanted */
		}
		wp_page_reuse(vmf, folio);  /* flip the PTE writable, done */
		return 0;
	}
	/*
	 * Ok, we need to copy. Oh, well..
	 */
	if (folio)
		folio_get(folio);
	pte_unmap_unlock(vmf->pte, vmf->ptl);
	return wp_page_copy(vmf);

The fast path is the first disjunct: if PageAnonExclusive(vmf->page) is already set, the kernel “must reuse the page without further checks” — the bit is a hard guarantee that nobody else maps this page, so a copy would be pure waste. Note the word must, not may. This is not an optimization; it is an invariant. If the bit is set, some other subsystem — a FOLL_PIN holder in particular — is relying on the physical page under this PTE never being swapped out from under it, and copying would break exactly that promise.

wp_page_reuse() is correspondingly tiny — it flips bits and never allocates:

static inline void wp_page_reuse(struct vm_fault *vmf, struct folio *folio)
{
	VM_BUG_ON(!(vmf->flags & FAULT_FLAG_WRITE));
	VM_WARN_ON(is_zero_pfn(pte_pfn(vmf->orig_pte)));   /* never reuse the shared zero page */
	if (folio) {
		VM_BUG_ON(folio_test_anon(folio) && !PageAnonExclusive(vmf->page));
		/* the NUMA-balancing hint belongs to a now-unrelated process */
		folio_xchg_last_cpupid(folio, (1 << LAST_CPUPID_SHIFT) - 1);
	}
	flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
	entry = pte_mkyoung(vmf->orig_pte);
	entry = maybe_mkwrite(pte_mkdirty(entry), vma);
	if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
		update_mmu_cache_range(vmf, vma, vmf->address, vmf->pte, 1);
	pte_unmap_unlock(vmf->pte, vmf->ptl);
	count_vm_event(PGREUSE);
}

Three details worth extracting. The VM_WARN_ON(is_zero_pfn(...)) guards a genuine hazard: the shared zero page is mapped read-only into every process that has read-faulted untouched anonymous memory, so reusing it would hand one process write access to a page the whole system shares — it must always take the copy path. ptep_set_access_flags() is the architecture hook for relaxing a PTE’s permissions, and it returns whether the entry actually changed, because on some architectures relaxing permissions needs no TLB shootdown at all (a stale, more-restrictive TLB entry just causes one spurious re-fault). And count_vm_event(PGREUSE) means reuses are counted — grep pgreuse /proc/vmstat tells you how many COW faults were resolved without a copy, which is the cheap half of the ledger.

wp_page_copy() is the expensive half, and its most interesting property is an ordering constraint that the source documents at length:

		ptep_clear_flush(vma, vmf->address, vmf->pte);          /* (1) clear old PTE + TLB flush */
		folio_add_new_anon_rmap(new_folio, vma, vmf->address, RMAP_EXCLUSIVE);
		folio_add_lru_vma(new_folio, vma);
		BUG_ON(unshare && pte_write(entry));
		set_pte_at(mm, vmf->address, vmf->pte, entry);          /* (2) install the copy */
		if (old_folio)
			folio_remove_rmap_pte(old_folio, vmf->page, vma);   /* (3) only now drop mapcount */

The comment above step (3) explains why it cannot move earlier: “Only after switching the pte to the new page may we remove the mapcount here. Otherwise another process may come and find the rmap count decremented before the pte is switched to the new page, and ‘reuse’ the old page writing into it while our pte here still points into it and can be read by other threads.” That is the failure mode in one sentence. If the mapcount dropped first, the other process’s concurrent do_wp_page would observe a refcount of 1, conclude it was the sole owner, take the reuse path, and start writing into a page this process is still reading through a not-yet-replaced PTE. The ptep_clear_flush() in step (1) — which includes the TLB shootdown — plus the implicit barrier inside folio_remove_rmap_pte()’s atomic are what serialize the two CPUs.

And when reuse is denied, the arithmetic works out in the reader’s favour: wp_page_copy installs the copy for the faulter and drops one reference on the original. The other process keeps the original, and when it later writes, it finds itself the last mapper, the reuse check passes, and it takes the page in place with no second copy. Two processes sharing one COW page cost one extra page, not two.

Reuse or Copy — folio_ref_count versus folio_mapcount

This is the hard part of COW, and it is worth slowing down for, because every historic bug in this area is a version of the same mistake: the kernel used the map count to answer a question that only the reference count could answer.

folio_mapcount() counts page-table entries. folio_ref_count() counts everything that will keep the page alive. In v6.12 they are separate fields with separate atomics (include/linux/mm.h, v6.12):

static inline int folio_mapcount(const struct folio *folio)
{
	int mapcount;
	if (likely(!folio_test_large(folio))) {
		mapcount = atomic_read(&folio->_mapcount) + 1;   /* biased by -1 */
		if (page_mapcount_is_type(mapcount))
			mapcount = 0;                                 /* special page type, not a mapping */
		return mapcount;
	}
	return folio_large_mapcount(folio);
}

The + 1 is the classic _mapcount bias: the field stores mappings - 1, so -1 means “not mapped anywhere” and 0 means “one mapping”. Forgetting that bias is a perennial source of off-by-one confusion when reading mm/ code.

Here is who contributes to each counter for a plain anonymous 4 KiB page:

Reference holderfolio_ref_countfolio_mapcountVisible to a mapcount-based COW check?
A PTE in the parent+1+1yes
A PTE in the child (after fork)+1+1yes
The swap cache, if the page has a swap slot+10no
get_user_pages() (a plain reference)+10no
pin_user_pages() (FOLL_PIN)+1024 (GUP_PIN_COUNTING_BIAS)0no
A page-cache / LRU isolation in flight+10no
The fault handler’s own folio_get()+10n/a — it holds it deliberately

Who holds a page, and what a mapcount check can see. What it shows: four of the seven reference-holders are entirely invisible to mapcount. The insight to take: a COW decision made on mapcount alone is not “slightly imprecise” — it is structurally blind to the kernel’s own references, which is precisely the class of reference that pins pages for hardware DMA. That blindness is CVE-2020-29374.

The v6.12 slow path therefore asks the refcount question, carefully, and it is worth reading in full because every line is defending against something (mm/memory.c, v6.12):

static bool wp_can_reuse_anon_folio(struct folio *folio,
				    struct vm_area_struct *vma)
{
	/*
	 * We could currently only reuse a subpage of a large folio if no
	 * other subpages of the large folios are still mapped. However,
	 * let's just consistently not reuse subpages even if we could
	 * reuse in that scenario, and give back a large folio a bit
	 * sooner.
	 */
	if (folio_test_large(folio))
		return false;                                   /* (a) */
 
	/* KSM doesn't necessarily raise the folio refcount. */
	if (folio_test_ksm(folio) || folio_ref_count(folio) > 3)
		return false;                                   /* (b) */
	if (!folio_test_lru(folio))
		lru_add_drain();                                /* (c) */
	if (folio_ref_count(folio) > 1 + folio_test_swapcache(folio))
		return false;                                   /* (d) */
	if (!folio_trylock(folio))
		return false;                                   /* (e) */
	if (folio_test_swapcache(folio))
		folio_free_swap(folio);                         /* (f) */
	if (folio_test_ksm(folio) || folio_ref_count(folio) != 1) {
		folio_unlock(folio);
		return false;                                   /* (g) */
	}
	/*
	 * Ok, we've got the only folio reference from our mapping
	 * and the folio is locked, it's dark out, and we're wearing
	 * sunglasses. Hit it.
	 */
	folio_move_anon_rmap(folio, vma);                       /* (h) */
	folio_unlock(folio);
	return true;
}

(a) Large folios are never reused, full stop. The comment explains the reasoning: reuse would only be safe if no other subpage of the folio is still mapped elsewhere, and rather than track that, the kernel copies and lets the large folio be freed sooner. This has a real performance consequence with multi-size transparent huge pages — see Transparent Huge Pages — because it means a COW fault on an mTHP-backed anonymous page always allocates.

(b) A cheap bail-out before taking any locks. folio_ref_count > 3 means there is no realistic hope. Kernel Samepage Merging pages are excluded outright with the comment “KSM doesn’t necessarily raise the folio refcount” — a KSM-merged page is deduplicated across unrelated processes and must never be written in place (KSM and Memory Overcommit for VMs).

(c) lru_add_drain() flushes this CPU’s pending LRU batch. A folio sitting in a per-CPU pagevec, not yet on the LRU list proper, holds a reference that would defeat the == 1 test below purely as an artifact of batching. Draining converts a spurious “still shared” verdict into a correct “sole owner” one — a pure performance fix that avoids a needless 4 KiB copy.

(d) The pre-lock refcount test, allowing exactly one extra reference if the folio is in the swap cache. folio_test_swapcache() returns 0 or 1 and is being used as an arithmetic term here, which reads oddly the first time.

(e) folio_trylock, not folio_lock. The page-table lock is held; blocking here would invert lock order. A failed trylock means “give up and copy” — correctness is never sacrificed, only the optimization.

(f) If the folio is in the swap cache, that stale swap slot is the only thing keeping the refcount above 1. folio_free_swap() releases it. This is why a process that swapped, swapped back in, then forked and wrote can still hit the reuse path.

(g) The authoritative re-test under the folio lock. folio_ref_count(folio) != 1 — anything at all still referencing this folio, from any subsystem, disqualifies reuse. This single comparison is what CVE-2020-29374 was missing.

(h) folio_move_anon_rmap() re-homes the folio onto this VMA’s anon_vma. After a fork the page’s rmap still points at the parent’s anon_vma chain; once we have proven this process is the sole owner, the page is re-parented so that future reverse-map walks (for reclaim, migration, or memory failure) find only this mapping.

flowchart TD
    START(["do_wp_page: write fault on a<br/>present, read-only PTE"])
    SHARED{"vm_flags has<br/>VM_SHARED or VM_MAYSHARE?"}
    WPS["wp_page_shared&#40;&#41; / wp_pfn_shared&#40;&#41;<br/>no COW at all: call page_mkwrite,<br/>mark dirty, make writable.<br/>Writes go to the shared object."]
    ANON{"folio exists and<br/>folio_test_anon&#40;&#41;?"}
    EXCL{"PageAnonExclusive<br/>set?"}
    LARGE{"folio_test_large&#40;&#41;?"}
    KSM{"KSM page, or<br/>folio_ref_count &gt; 3?"}
    DRAIN["lru_add_drain&#40;&#41;<br/>flush per-CPU LRU batches"]
    REF1{"folio_ref_count ==<br/>1 + is_swapcache?"}
    LOCK{"folio_trylock&#40;&#41;<br/>succeeded?"}
    FREESWAP["folio_free_swap&#40;&#41;<br/>drop the stale swap slot"]
    REF2{"under folio lock:<br/>folio_ref_count == 1?"}
    MOVE["folio_move_anon_rmap&#40;&#41;<br/>SetPageAnonExclusive"]
    REUSE(["REUSE — no allocation<br/>vmstat: pgreuse++"])
    COPY(["COPY — wp_page_copy&#40;&#41;<br/>alloc + 4 KiB memcpy<br/>delayacct: WPCOPY"])

    START --> SHARED
    SHARED -->|yes| WPS
    SHARED -->|no| ANON
    ANON -->|no: file-backed private,<br/>or the shared zero page| COPY
    ANON -->|yes| EXCL
    EXCL -->|yes: hard guarantee,<br/>MUST reuse| REUSE
    EXCL -->|no| LARGE
    LARGE -->|yes| COPY
    LARGE -->|no| KSM
    KSM -->|yes| COPY
    KSM -->|no| DRAIN
    DRAIN --> REF1
    REF1 -->|no| COPY
    REF1 -->|yes| LOCK
    LOCK -->|no| COPY
    LOCK -->|yes| FREESWAP
    FREESWAP --> REF2
    REF2 -->|no| COPY
    REF2 -->|yes| MOVE
    MOVE --> REUSE

The complete reuse-versus-copy decision tree of do_wp_page in v6.12. What it shows: there are eight distinct ways to end up copying and only two ways to end up reusing, and the tests get progressively more expensive from top to bottom — flag check, then unlocked count, then LRU drain, then a trylock, then a locked count. The insight to take: the tree is deliberately asymmetric in its failure direction. Every uncertain answer routes to COPY. Copying when you did not have to costs one page and one memcpy; reusing when you should not have costs a memory-corruption CVE. The PageAnonExclusive fast path exists precisely so that the common case never has to walk this tree at all.

The Life of One PTE, Across fork, Write, and Unshare

Three orthogonal bits travel together and are constantly confused: the PTE’s write bit (hardware permission), the PTE’s dirty bit (hardware modification record), and the folio’s PageAnonExclusive bit (software ownership claim). They are set and cleared by different code at different times, and a mental model that tracks only one of them will mispredict the kernel’s behaviour. The state machine below tracks all three.

stateDiagram-v2
    direction TB
    [*] --> NoPTE: mmap&#40;MAP_PRIVATE&#124;MAP_ANONYMOUS&#41;

    NoPTE: <b>No PTE</b><br/>pte_none&#40;&#41;<br/>nothing allocated
    ZeroRO: <b>Shared zero page</b><br/>W=0, AnonExclusive=0<br/>every reader in the system<br/>maps the same page
    ExclRW: <b>Exclusive, writable</b><br/>W=1 D=1, AnonExclusive=1<br/>refcount=1 mapcount=1<br/>stores go straight through
    SharedRO: <b>COW-shared</b><br/>W=0, AnonExclusive=0<br/>mapcount=N&gt;1<br/>any write faults
    ExclRO: <b>Exclusive but read-only</b><br/>W=0, AnonExclusive=1<br/>reliable FOLL_PIN target

    NoPTE --> ZeroRO: read fault<br/>do_anonymous_page&#40;&#41;<br/>maps ZERO_PAGE
    NoPTE --> ExclRW: write fault<br/>do_anonymous_page&#40;&#41; allocates<br/>rmap added RMAP_EXCLUSIVE
    ZeroRO --> ExclRW: write fault<br/>do_wp_page calls wp_page_copy&#40;&#41;<br/>the zero page is never reused

    ExclRW --> SharedRO: <b>fork&#40;&#41;</b><br/>wrprotect_ptes&#40;&#41; on parent +<br/>pte_wrprotect&#40;&#41; on child +<br/>ClearPageAnonExclusive&#40;&#41;
    ExclRO --> SharedRO: fork&#40;&#41;<br/>&#40;unless the folio may be<br/>DMA-pinned: then -EBUSY,<br/>copy for the child&#41;

    SharedRO --> ExclRW: write fault, reuse path<br/>refcount proves sole owner<br/>SetPageAnonExclusive + W=1
    SharedRO --> ExclRW: write fault, copy path<br/>brand-new folio for the faulter<br/>&#40;the OTHER side stays in SharedRO&#41;
    SharedRO --> ExclRO: <b>FAULT_FLAG_UNSHARE</b><br/>GUP wants a reliable R/O pin<br/>exclusivity granted, W stays 0

    ExclRO --> ExclRW: write fault<br/>PageAnonExclusive set =><br/>MUST reuse, just flip W=1

    ExclRW --> SharedRO: mprotect&#40;PROT_READ&#41;,<br/>KSM merge, uffd-wp,<br/>soft-dirty reset
    ExclRW --> NoPTE: munmap&#40;&#41;, MADV_DONTNEED,<br/>swap-out &#40;exclusivity is<br/>LOST on swap-out&#41;

The states one anonymous PTE can occupy, and every transition that moves it. What it shows: fork() is only one of several ways into the COW-shared state, and there are two distinct exits from it — one that costs nothing and one that costs a page. The insight to take: the ExclRO state is the one that did not exist before Linux 5.19, and its absence is what made reliable read-only pinning impossible. A pinning caller used to have only two options for a possibly-shared page: pin it and hope, or force a full write-fault COW break it did not want. FAULT_FLAG_UNSHARE created a third: break the sharing without granting write access, leaving W=0 but AnonExclusive=1.

Two transitions in that diagram deserve their own paragraph because they are the ones that surprise people.

Exclusivity is lost on swap-out, not preserved. The PG_anon_exclusive design note enumerates the page-table entry types and what each does with the bit (commit 78fbe906): “Present: PG_anon_exclusive applies. Swap: the information is lost. PG_anon_exclusive was cleared. Migration: the entry holds this information instead. Device private: applies. Device exclusive: applies. HW Poison: PG_anon_exclusive is stale and not changed.” So a page that gets swapped out and faulted back in returns as non-exclusive, and the first write after that takes the slow wp_can_reuse_anon_folio() path even though nothing was ever shared. Migration entries, by contrast, carry the bit through in the swap-entry encoding, because migration is a kernel-internal move that must not perturb user-visible semantics.

A pinned page’s exclusivity is sticky, deliberately. The same design note: “If the page may be pinned (FOLL_PIN), clearing PG_anon_exclusive is not allowed and the flag will stick around until the page is freed and folio->mapping is cleared.” And: “We won’t be clearing PG_anon_exclusive on destructive unmapping (i.e., zapping) of page table entries… Letting information about exclusivity stick around will be an important property when adding sanity checks to unpinning code.” In other words the bit outlives the mapping on purpose, so that unpin_user_page() can assert that what it is unpinning is still the thing that was pinned. A BUG there is much easier to debug than silent corruption discovered hours later.

FAULT_FLAG_UNSHARE itself is documented in the fault-flag enum (include/linux/mm_types.h, v6.12):

 * @FAULT_FLAG_UNSHARE: The fault is an unsharing request to break COW in a
 *                      COW mapping, making sure that an exclusive anon page is
 *                      mapped after the fault.
 * ...
 * The combination FAULT_FLAG_WRITE|FAULT_FLAG_UNSHARE is illegal.
 * FAULT_FLAG_UNSHARE is ignored and treated like an ordinary read fault when
 * applied to mappings that are not COW mappings.

The flag is 1 << 10, and it did not exist in v5.18 — a direct existence check confirms it appears in include/linux/mm_types.h from v5.19 onward and is absent in v5.18.

Where the bit physically lives

PG_anon_exclusive costs nothing in memory, because it does not have a bit of its own. Page flags are a scarce resource — there are only __NR_PAGEFLAGS of them in a single unsigned long — so the 5.19 work overloaded a flag that is meaningless for anonymous pages. The merge commit explains the choice: “Most pageflags already have semantics for anonymous pages, however, PG_mappedtodisk should never apply to pages in the swapcache, so let’s reuse that flag” (commit 78fbe906).

By v6.12 the aliasing has been made explicit and symmetric — both names are now defined in terms of a neutral third name rather than one being defined as the other (include/linux/page-flags.h, v6.12):

	/*
	 * Depending on the way an anonymous folio can be mapped into a page
	 * table (e.g., single PMD/PUD/CONT of the head page vs. PTE-mapped
	 * THP), PG_anon_exclusive may be set only for the head page or for
	 * tail pages of an anonymous folio. For now, we only expect it to be
	 * set on tail pages for PTE-mapped THP.
	 */
	PG_anon_exclusive = PG_owner_2,
 
	/*
	 * Set if all buffer heads in the folio are mapped.
	 * Filesystems which do not use BHs can use it for their own purpose.
	 */
	PG_mappedtodisk = PG_owner_2,

The accessors are hand-written rather than generated by the usual PAGEFLAG() macro, precisely so they can carry sanity checks that the macro cannot express — SetPageAnonExclusive() opens with VM_BUG_ON_PGFLAGS(!PageAnon(page) || PageKsm(page), page), refusing outright to mark a KSM page exclusive, and PageAnonExclusive() redirects to the head page for hugetlb folios because hugetlb stores the information there while THP keeps it per page. A note written against a pre-5.19 kernel will not find any of this; a note written against 5.19 will find the bit named as a direct alias of PG_mappedtodisk. In v6.12 the spelling is PG_owner_2, and the two names are siblings.

Dirty COW (CVE-2016-5195) — Losing Track of Whether COW Happened

The oldest of the three COW CVEs is also the most famous, and it is not a fork bug at all. It is a bug in how get_user_pages() remembered that it had already broken COW.

The setup: a private, read-only file mapping — mmap(..., PROT_READ, MAP_PRIVATE, fd, 0) on a file you can read but not write, such as /usr/bin/passwd. Writing to that mapping through a normal store is impossible; the VMA lacks VM_WRITE. But the kernel offers a back door for debuggers: FOLL_FORCE, reachable from userspace through /proc/self/mem and ptrace(PTRACE_POKEDATA). FOLL_FORCE lets a tracer write into a read-only private mapping, and the semantics are supposed to be that the write lands in a private COW copy which is never written back to the file.

The pre-fix faultin_page() implemented “we already did the COW” by removing the write requirement from its own flags for the retry (commit 19be0eaf, Linus Torvalds, 2016-10-13):

	/* BEFORE the fix, in faultin_page(): */
	if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
		*flags &= ~FOLL_WRITE;      /* "we COWed; stop demanding a writable PTE" */

That is the “FOLL_WRITE game” the commit title refers to, and the window it opens is the whole vulnerability.

sequenceDiagram
    autonumber
    participant T1 as Thread 1<br/>write&#40;&#41; to /proc/self/mem
    participant GUP as __get_user_pages
    participant PT as page tables
    participant T2 as Thread 2<br/>madvise&#40;MADV_DONTNEED&#41;
    participant PC as page cache<br/>&#40;the real file&#41;

    T1->>GUP: FOLL_WRITE &#124; FOLL_FORCE<br/>on a PROT_READ MAP_PRIVATE page
    GUP->>PT: follow_page_pte&#40;&#41; — PTE not writable
    GUP->>GUP: faultin_page&#40;&#41; calls handle_mm_fault<br/>with FAULT_FLAG_WRITE
    GUP->>PT: do_wp_page calls wp_page_copy&#40;&#41;<br/>private anon copy P' installed, dirty
    GUP->>GUP: <b>*flags &= ~FOLL_WRITE</b><br/>&#40;the bug: forget we needed a write&#41;
    Note over GUP,PT: --- race window: GUP retries the walk ---
    T2->>PT: madvise&#40;MADV_DONTNEED&#41;<br/>zaps the private copy P'
    PT->>PC: next read fault re-maps the<br/>ORIGINAL page-cache page, read-only
    GUP->>PT: retry follow_page_pte&#40;&#41;<br/>FOLL_WRITE is gone, so<br/>"not writable" is no longer an error
    PT-->>GUP: returns the page-cache page
    GUP-->>T1: here is your "writable" page
    T1->>PC: <b>writes directly into the file's page cache</b><br/>root-owned binary modified

The Dirty COW race, step by step. What it shows: the exploit never defeats a permission check — it makes the kernel forget that a permission check was ever pending, then swaps the target page underneath the retry. The insight to take: the bug is a lost state bug, not a missing-check bug. GUP had to remember “I already broke COW for this address” across a dropped lock, and it encoded that memory by mutating the very flag that expressed the requirement. Once state and requirement share one variable, any interleaving that invalidates the state silently relaxes the requirement.

The MITRE record is precise about scope and severity: “Race condition in mm/gup.c in the Linux kernel 2.x through 4.x before 4.8.3 allows local users to gain privileges by leveraging incorrect handling of a copy-on-write (COW) feature to write to a read-only memory mapping, as exploited in the wild in October 2016, aka ‘Dirty COW’” (CVE-2016-5195). “2.x through 4.x” is not hyperbole — Torvalds’s commit message notes he had attempted a fix eleven years earlier in commit 4ceb5db9757a and had it reverted for breaking s390.

The 2016 fix separated the state from the requirement: keep FOLL_WRITE set, add a distinct internal FOLL_COW marker meaning “yes, we already did a COW”, and validate that marker against the hardware dirty bit:

/*
 * FOLL_FORCE can write to even unwritable pte's, but only
 * after we've gone through a COW cycle and they are dirty.
 */
static inline bool can_follow_write_pte(pte_t pte, unsigned int flags)
{
	return pte_write(pte) ||
		((flags & FOLL_FORCE) && (flags & FOLL_COW) && pte_dirty(pte));
}

The pte_dirty() term is the clever part: a COW-broken private copy is always dirty (wp_page_copy sets pte_mkdirty), whereas a freshly re-faulted page-cache page in a read-only mapping is clean. So if MADV_DONTNEED swaps the private copy for the original, the dirty bit is clean, can_follow_write_pte() returns false, and GUP faults again instead of handing back the file’s page.

The sequel: CVE-2022-2590, “Dirty COW restricted to tmpfs”

That fix held for six years and then broke, because the dirty bit turned out not to be a reliable proxy for “this is a private copy”. Userfaultfd’s UFFDIO_CONTINUE operation, combined with a change that “unconditionally set pte dirty in mfill_atomic_install_pte”, let unprivileged userspace map a shmem page read-only but with the PTE dirty — satisfying can_follow_write_pte() without any COW having occurred. David Hildenbrand’s fix describes it exactly: “This can be used by unprivileged user space to modify tmpfs/shmem file content even if the user does not have write permissions to the file, and to bypass memfd write sealing — Dirty COW restricted to tmpfs/shmem (CVE-2022-2590)” (commit 5535be30). Red Hat’s CVE description agrees: “A race condition was found in the way the Linux kernel’s memory subsystem handled the copy-on-write (COW) breakage of private read-only shared memory mappings. This flaw allows an unprivileged, local user to gain write access to read-only memory mappings” (CVE-2022-2590). Only x86-64 and aarch64 were affected, because only those select CONFIG_HAVE_ARCH_USERFAULTFD_MINOR.

The fix deleted FOLL_COW entirely and re-expressed the whole condition in terms of the 5.19 exclusivity bit. The v6.12 version of can_follow_write_pte() is a chain of five refusals (mm/gup.c, v6.12):

static inline bool can_follow_write_pte(pte_t pte, struct page *page,
					struct vm_area_struct *vma,
					unsigned int flags)
{
	if (pte_write(pte))                                return true;   /* trivially fine */
	if (!(flags & FOLL_FORCE))                         return false;
	if (vma->vm_flags & (VM_MAYSHARE | VM_SHARED))     return false;  /* FOLL_FORCE never
	                                                                     applies to shared */
	if (!(vma->vm_flags & VM_MAYWRITE))                return false;  /* truly read-only */
	if (vma->vm_flags & VM_WRITE)                      return false;  /* just take a write fault */
	/*
	 * See can_change_pte_writable(): we broke COW and could map the page
	 * writable if we have an exclusive anonymous page ...
	 */
	if (!page || !PageAnon(page) || !PageAnonExclusive(page))
		return false;
	if (pte_needs_soft_dirty_wp(vma, pte))             return false;
	return !userfaultfd_pte_wp(vma, pte);
}

Note what replaced pte_dirty(): PageAnonExclusive(page). The commit spells out the reasoning — “in a COW mapping, we really only broke COW if we have an exclusive anonymous page. If we have something else mapped, or the mapped anonymous page might be shared (!PageAnonExclusive), we have to trigger a write fault to break COW.” A dirty shmem page fails PageAnon() immediately and never reaches the exclusivity test. The commit also adds the missing soft-dirty and userfaultfd-write-protect checks, so that “a write() via /proc/self/mem to a uffd-wp-protected range has to fail instead of silently granting write access and bypassing the userspace fault handler.”

FOLL_COW is present in include/linux/mm.h at v5.19 and absent at v6.0 — a direct existence check across tags places its removal in Linux 6.0 (commit 5535be30, authored 2022-08-09).

The GUP-after-fork Saga — Two Years of Fixing the Wrong Half

The second CVE is the one that reshaped the code you read above, and its two-year repair history is the best available argument for why the exclusivity bit had to exist.

get_user_pages() — universally “GUP” — is the family of functions that let kernel code obtain struct page * handles for a user process’s memory, so a driver or syscall can read or write it directly. Direct I/O, RDMA, vmsplice(), and video-capture drivers all use it (see get_user_pages and Page Pinning). A GUP reference raises the reference count. It does not raise the map count, because GUP does not add a page-table entry.

The pre-fix do_wp_page() decided “is this page still shared?” by consulting page_mapcount(). That is the exact blind spot.

The exploit

Jann Horn’s Project Zero proof-of-concept, reproduced in LWN’s write-up (Patching until the COWs come home, part 1), is fifteen lines:

static void *data;
posix_memalign(&data, 0x1000, 0x1000);
strcpy(data, "BORING DATA");
if (fork() == 0) {                     /* child */
    int pipe_fds[2];
    struct iovec iov = { .iov_base = data, .iov_len = 0x1000 };
    char buf[0x1000];
    pipe(pipe_fds);
    vmsplice(pipe_fds[1], &iov, 1, 0); /* GUP: takes a REFERENCE on the page */
    munmap(data, 0x1000);              /* drops the MAPPING, keeps the reference */
    sleep(2);
    read(pipe_fds[0], buf, 0x1000);    /* read the page back out of the pipe */
    printf("read string from child: %s\n", buf);
} else {                               /* parent */
    sleep(1);
    strcpy(data, "THIS IS SECRET");    /* write fault -> reuse decision */
}

Walking it: the parent allocates and writes one anonymous page, then forks, so the page becomes COW-shared with mapcount 2. The child vmsplice()s the page into a pipe — a zero-copy transfer that GUPs the page, taking a reference — then munmaps it, dropping mapcount back to 1 while the pipe keeps the reference. The parent then writes. LWN states the consequence precisely: “page_mapcount() at this point in the PoC’s execution includes only the parent’s mapping, because the child has already called munmap() on that page. This function does not take into account the fact that the child can still access the parent’s page through the pipe; it ignores the elevated page reference count.” The fault handler concludes the page is unshared, reuses it in place, and writes "THIS IS SECRET" into the very page the child is about to read out of the pipe.

Three fixes, two of them wrong

timeline
    title The COW and GUP repair history, 2020 to 2022
    section Break COW at GUP time
        2020-05, Linux 5.8, commit 17839856fd58 : gup — document and work around "COW can break either way" : Any GUP of a COW-shared page forces a private copy immediately : Backported to 5.7.3; CVE-2020-29374 assigned in December
        2020-08, first regression : Peter Xu reports userfaultfd hangs : a read-only GUP now looks like a write and fires an unexpected uffd write fault : DAX plus strace also broken, bisecting to the same commit
    section Break COW at fault time, on refcount
        2020-09, Linux 5.9-rc5, commit 09854ba94c6a : mm — do_wp_page simplification : Reverts the GUP-side approach; do_wp_page reuses only when the refcount is exactly 1 : Simpler and faster — Torvalds's preference over Arcangeli's objection
        2020-09, second regression : Jason Gunthorpe reports RDMA self-tests broken one day later : pin before fork, child exits, parent writes — the pin's own refcount now TRIGGERS a COW break, moving the page out from under the DMA
    section Copy pinned pages at fork time
        2020-10, Linux 5.9 final : fork copies a page immediately if it may be pinned : MMF_HAS_PINNED skips the check entirely for processes that never pinned : the test is inexact, and false positives cost one extra copy
        2020-12, an unwanted holiday present : Nadav Amit reports a uffd self-test failure : the more aggressive copying exposes an old missing TLB flush, and soft-dirty has the same race
        2021-03, still not fixed : Arcangeli shows the vmsplice PoC still works when the page is a THP : do_huge_pmd_wp_page still used page_trans_huge_mapcount — mapcount, again
    section Record exclusivity instead of inferring it
        2022-05, Linux 5.19, commits 78fbe906 and c89357e27f20 : PG_anon_exclusive, a per-page bit that records the answer : FAULT_FLAG_UNSHARE lets GUP demand exclusivity without demanding write access : THP and hugetlb converted to the same bit, closing the mapcount hole
        2022-08, Linux 6.0, commit 5535be309971 : FOLL_COW deleted and can_follow_write_pte rewritten on PageAnonExclusive : this also fixes CVE-2022-2590

Two years of repairs, and what each one broke. What it shows: the first two fixes each solved one direction of the problem and created the other. Fix 1 (break COW when GUP happens) protects a pin taken after fork but destroys a pin taken before it. Fix 2 (break COW when the refcount is elevated) is the mirror image. The insight to take: this is not a story about sloppy patches — Torvalds, Xu, Arcangeli, Gunthorpe and Dickins were all arguing carefully from real workloads. It is a story about a missing piece of information. As long as the kernel had to infer “is this page exclusive?” from counters that meant something else, every fix could only trade one wrong answer for another. The 5.19 rework works because it stops inferring.

Blocked source

Verify: the design discussion in the cover letter of the reliable-COW series (lore.kernel.org/all/20220428083441.37290-1-david@redhat.com/), which the individual commit messages reference via their Link: trailers. Reason: lore.kernel.org is fronted by the Anubis proof-of-work challenge and returns HTTP 403 to a plain curl and HTTP 500 with a JavaScript challenge page when given a browser User-Agent — the mailing-list archive could not be read for this note. To resolve: fetch the thread through a JavaScript-capable client, or read the same rationale from the per-patch commit messages on git.kernel.org, which are reachable and which are what every quotation in this section is actually taken from. uncertain

Two details from that timeline are worth pulling out.

MMF_HAS_PINNED makes the pin check nearly free. Checking every page at fork time for possible pinning would tax every fork() in the system to protect the handful of processes that use RDMA. Instead, pin_user_pages() sets a per-mm flag the first time it pins anything, and the fork-time test short-circuits on it (include/linux/mm.h, v6.12):

static inline bool folio_needs_cow_for_dma(struct vm_area_struct *vma,
					  struct folio *folio)
{
	VM_BUG_ON(!(raw_read_seqcount(&vma->vm_mm->write_protect_seq) & 1));
 
	if (!test_bit(MMF_HAS_PINNED, &vma->vm_mm->flags))
		return false;                      /* this mm has never pinned anything */
 
	return folio_maybe_dma_pinned(folio);
}

The VM_BUG_ON asserts the write_protect_seq counter is odd — i.e. that this is only ever called from inside copy_page_range()’s write-protect window, which is what makes the answer meaningful.

The pin test is intentionally inexact, and biased safe. folio_maybe_dma_pinned() for a small folio does not consult a dedicated pin counter at all; it reads the refcount and compares it against the bias:

static inline bool folio_maybe_dma_pinned(struct folio *folio)
{
	if (folio_test_large(folio))
		return atomic_read(&folio->_pincount) > 0;   /* large folios have a real counter */
	return ((unsigned int)folio_ref_count(folio)) >= GUP_PIN_COUNTING_BIAS;
}

GUP_PIN_COUNTING_BIAS is (1U << 10), i.e. 1024. pin_user_pages() adds 1024 to the refcount instead of 1, so that the top bits of one counter carry the pin count while the low bits carry ordinary references. The header explains the choice: “By making GUP_PIN_COUNTING_BIAS a power of two, debugging of page reference counts with respect to pin_user_pages() and unpin_user_page() becomes simpler, due to the fact that adding an even power of two to the page refcount has the effect of using only the upper N bits… This means that the lower bits are left for the exclusive use of the original code that increments and decrements by one.” The consequence is a deliberate false-positive: a page that genuinely accumulates 1024 ordinary references is indistinguishable from a pinned one, and gets needlessly copied at fork. LWN puts the trade-off plainly — the test “is not exact and may have false-positive results if the page has a significantly increased reference count for other reasons, but copying a few more pages during fork() than is strictly needed should not hurt performance” (part 2). Errors are one-directional: toward more copying, never toward less.

Resolving the old open question

An earlier revision of this note flagged uncertainty about whether the 5.8/5.9 mitigations or the 5.19 rework constitute the “real” fix for CVE-2020-29374. The primary sources now settle it, and the answer is both, for different scopes:

  • The CVE record itself names commit 17839856fd58 as the fix and scopes the vulnerability to “the Linux kernel before 5.7.3” — that commit was backported into the 5.7.3 stable release, and the record cites the 5.7.3 changelog directly (CVE-2020-29374). By the CVE’s own accounting, it was fixed in 5.7.3 / mainline 5.8.
  • The underlying class of bug was not. LWN documents Arcangeli demonstrating in 2021 that the same vmsplice() proof-of-concept still worked against a 5.12-rc2 kernel if the target page was a transparent huge page, because do_huge_pmd_wp_page() still used page_trans_huge_mapcount() — the mapping count — rather than the reference count (part 2).
  • That hole is closed in v6.12. Reading do_huge_pmd_wp_page() in the 6.12 tree shows the PMD path now uses exactly the same logic as the PTE path: an early if (PageAnonExclusive(page)) goto reuse;, then folio_ref_count(folio) > 1 + folio_test_swapcache(folio) * folio_nr_pages(folio) as the bail-out, then folio_ref_count(folio) == 1 under the folio lock before SetPageAnonExclusive() (mm/huge_memory.c, v6.12). page_trans_huge_mapcount() is gone.

So: the CVE was closed in 5.7.3/5.8; the huge-page variant of the same attack stayed open until the 5.19 exclusivity rework converted every COW path — base pages, THP, and hugetlb — onto one shared, reference-count-and-flag-based decision.

The Pin-Aware fork Path in v6.12

The 5.9 “copy pinned pages at fork” behaviour survives in the 6.12 tree, and it is worth seeing where it actually lives, because it is not in fork() — it is buried in the reverse-map duplication helper, expressed as an error return.

/* __folio_try_dup_anon_rmap(), include/linux/rmap.h, v6.12 */
	maybe_pinned = likely(!folio_is_device_private(folio)) &&
		       unlikely(folio_needs_cow_for_dma(src_vma, folio));
 
	switch (level) {
	case RMAP_LEVEL_PTE:
		if (unlikely(maybe_pinned)) {
			for (i = 0; i < nr_pages; i++)
				if (PageAnonExclusive(page + i))
					return -EBUSY;      /* caller: copy now, do not share */
		}
		if (!folio_test_large(folio)) {
			if (PageAnonExclusive(page))
				ClearPageAnonExclusive(page);
			atomic_inc(&folio->_mapcount);
			break;
		}
		/* large folio: per-subpage mapcounts, plus the folio-wide one */
		do {
			if (PageAnonExclusive(page))
				ClearPageAnonExclusive(page);
			atomic_inc(&page->_mapcount);
		} while (page++, --nr_pages > 0);
		atomic_add(orig_nr_pages, &folio->_large_mapcount);
		break;
	case RMAP_LEVEL_PMD:
		if (PageAnonExclusive(page)) {
			if (unlikely(maybe_pinned))
				return -EBUSY;
			ClearPageAnonExclusive(page);
		}
		atomic_inc(&folio->_entire_mapcount);
		atomic_inc(&folio->_large_mapcount);
		break;
	}
	return 0;

The function’s own comment states the contract: “If this folio may have been pinned by the parent process, don’t allow to duplicate the mappings but instead require to e.g., copy the subpage immediately for the child so that we’ll always guarantee the pinned folio won’t be randomly replaced in the future on write faults.”

Note the exact condition for refusing. It is not “the folio is pinned” — it is maybe_pinned && PageAnonExclusive(page). If the page is already non-exclusive, it was already shared before this fork, so a pin on it could never have been a reliable pin in the first place, and there is nothing left to protect. -EBUSY propagates back into copy_present_ptes(), which converts it into an immediate eager copy:

	folio_get(folio);
	if (folio_test_anon(folio)) {
		/*
		 * If this page may have been pinned by the parent process,
		 * copy the page immediately for the child so that we'll always
		 * guarantee the pinned page won't be randomly replaced in the
		 * future.
		 */
		if (unlikely(folio_try_dup_anon_rmap_pte(folio, page, src_vma))) {
			folio_put(folio);
			err = copy_present_page(dst_vma, src_vma, dst_pte, src_pte,
						addr, rss, prealloc, page);
			return err ? err : 1;
		}
		rss[MM_ANONPAGES]++;
		VM_WARN_ON_FOLIO(PageAnonExclusive(page), folio);
	}
flowchart TD
    F(["fork&#40;&#41; calls copy_page_range&#40;&#41;<br/>write_protect_seq is now ODD"])
    NEED{"vma_needs_copy&#40;&#41;?<br/>anon_vma set, PFNMAP,<br/>MIXEDMAP or uffd-wp"}
    SKIP(["Skip the VMA entirely.<br/>Child re-faults from the<br/>page cache on demand."])
    WALK["Walk every present PTE"]
    ANON{"anonymous folio?"}
    FILE["folio_dup_file_rmap_pte&#40;&#41;<br/>bump mapcount, share.<br/>Page cache pages are already<br/>shared by design."]
    HASPIN{"MMF_HAS_PINNED set<br/>on the source mm?"}
    PINNED{"folio_maybe_dma_pinned&#40;&#41;<br/>refcount &ge; 1024, or<br/>_pincount &gt; 0"}
    EXCL{"PageAnonExclusive<br/>on any page of the batch?"}
    EAGER(["EAGER COPY — copy_present_page&#40;&#41;<br/>child gets its own page NOW.<br/>Parent's pin stays valid forever."])
    SHARE(["COW-SHARE — clear AnonExclusive,<br/>bump mapcount, write-protect<br/>both PTEs. The normal path."])

    F --> NEED
    NEED -->|no| SKIP
    NEED -->|yes| WALK --> ANON
    ANON -->|no| FILE
    ANON -->|yes| HASPIN
    HASPIN -->|"no — the overwhelming<br/>majority of processes"| SHARE
    HASPIN -->|yes| PINNED
    PINNED -->|no| SHARE
    PINNED -->|"yes &#40;may be a<br/>false positive&#41;"| EXCL
    EXCL -->|no: already shared,<br/>nothing to protect| SHARE
    EXCL -->|yes| EAGER

What fork() decides, per page, in v6.12. What it shows: four filters stand between a page and the eager-copy path, and the first of them — MMF_HAS_PINNED — is a single bit test that the vast majority of processes fail, so the pin machinery costs them nothing. The insight to take: the “COW” in copy-on-write is not universal even at fork time. Pinned pages are copied eagerly (correctness), file-backed pages are shared without any COW bookkeeping (they were never private), and whole VMAs with no anon_vma are skipped entirely (laziness). Only private anonymous pages that have actually been faulted in take the classic COW path.

The one remaining subtlety is folio_is_device_private(folio) in the maybe_pinned computation. Device-private pages live in device memory and are represented by special non-present entries; they cannot be DMA-pinned in the ordinary sense, so they are excluded from the check up front rather than being forced down the eager-copy path.

Huge Pages, KSM, and the Other Sources of COW

fork() is the archetypal COW producer but not the only one. Anything that write-protects a private anonymous page and shares it creates work for do_wp_page().

Source of write-protectionWhat breaking COW doesReuse possible?Notes
fork() / clone() without CLONE_VMcopy or reuse, per pageyesthe subject of this note
KSM (MADV_MERGEABLE)always copiesneverfolio_test_ksm() is an unconditional bail in wp_can_reuse_anon_folio(); a KSM page is deduplicated across unrelated processes (KSM and Memory Overcommit for VMs)
THP / mTHP (order > 0 anon folio)always copiesneverif (folio_test_large(folio)) return false; — see below
mprotect(PROT_READ) then PROT_WRITEreuse, usuallyyescan_change_pte_writable() uses the same PageAnonExclusive test to avoid a fault entirely
soft-dirty (/proc/PID/clear_refs)reuseyeswrite-protects every PTE to observe the next write
userfaultfd write-protect (UFFDIO_WRITEPROTECT)handled before COWn/ado_wp_page() checks userfaultfd_pte_wp() first and hands the fault to the monitor
GUP read-only pin (FAULT_FLAG_UNSHARE)copy or claim exclusivityyesnever grants write access; BUG_ON(unshare && pte_write(entry)) enforces that

Everything that can land you in do_wp_page(), and whether the cheap path is available. What it shows: two of the seven sources can never take the cheap path. The insight to take: if your workload uses KSM or transparent huge pages, “COW faults” and “page copies” are the same number — the reuse optimization is switched off for you, structurally.

The huge-page case deserves its own paragraph because the cost asymmetry is enormous and it is the single most common cause of surprising memory growth after a fork.

A PMD-mapped transparent huge page is 2 MiB. When a COW write fault lands on one, do_huge_pmd_wp_page() gets the same two chances the base-page path does — PageAnonExclusive(page) on the head page, then a locked folio_ref_count(folio) == 1 check — and if both fail it takes the fallback label, which splits the PMD into a page table of 512 PTEs and re-runs the fault as a base-page fault. So a single 8-byte store into a shared huge page costs a PMD split plus a 4 KiB copy, and repeated stores scattered across the 2 MiB region eventually copy all 512 pages. Redis’s documentation describes exactly this pathology and the resulting operational advice (Redis, Diagnosing latency issues):

Fork is called, two processes with shared huge pages are created. In a busy instance, a few event loops runs will cause commands to target a few thousand of pages, causing the copy on write of almost the whole process memory. This will result in big latency and big memory usage.

Their remedy is echo never > /sys/kernel/mm/transparent_hugepage/enabled. The full argument for and against that setting — including the fact that never no longer means never, thanks to MADV_COLLAPSE — belongs to Transparent Huge Pages; the COW-specific point is just this: the granularity of a COW copy is the granularity of the mapping, and a huge mapping makes every copy 512 times more expensive.

The multi-size THP (mTHP) situation is subtler and worth flagging. wp_can_reuse_anon_folio()’s folio_test_large() bail is not about PMD mappings — it fires for any large folio, including a 16 KiB or 64 KiB PTE-mapped mTHP. The comment is explicit that this is a simplification rather than a necessity: “We could currently only reuse a subpage of a large folio if no other subpages of the large folios are still mapped. However, let’s just consistently not reuse subpages even if we could reuse in that scenario, and give back a large folio a bit sooner.” So on a kernel with mTHP enabled, a post-fork write to an mTHP-backed page always allocates, even when the writing process is provably the sole owner.

Uncertain

Verify: whether the blanket folio_test_large() refusal in wp_can_reuse_anon_folio() has been relaxed in a release after v6.12, given that the source comment frames it as a deliberate simplification rather than a correctness requirement. Reason: this note reads only the v6.12 LTS tree, and mTHP is an area of rapid churn. To resolve: re-read wp_can_reuse_anon_folio() on current mainline and check git log -- mm/memory.c for follow-up work on large-folio COW reuse. uncertain

Controlling COW Across fork — the madvise Knobs

Userspace is not obliged to accept the default. Three madvise(2) advices change what the child inherits, and each solves a problem that the kernel’s heuristics can only approximate (madvise(2)).

AdviceSinceEffect on the childUndone byCleared by execve?
MADV_DONTFORK (VM_DONTCOPY)Linux 2.6.16the range is absent from the child’s address spaceMADV_DOFORKno — the VMA flag persists
MADV_WIPEONFORK (VM_WIPEONFORK)Linux 4.14the range is present but zero-filled in the childMADV_KEEPONFORKyes
MADV_MERGEABLE (KSM)Linux 2.6.32pages may be deduplicated system-wide; COW always copiesMADV_UNMERGEABLEn/a

The three fork-relevant advices. What it shows: DONTFORK and WIPEONFORK differ in whether the child sees a hole or sees zeroes — a distinction that decides whether the child segfaults or silently reads zeros. The insight to take: these are the two ways to opt a range out of COW entirely, one for correctness (DMA) and one for security (secrets), and they are much stronger guarantees than anything the kernel’s pin heuristic can offer.

MADV_DONTFORK is the clean answer to the DMA problem. The manual page states the motivation directly: “Do not make the pages in this range available to the child after a fork(2). This is useful to prevent copy-on-write semantics from changing the physical location of a page if the parent writes to it after a fork(2). (Such page relocations cause problems for hardware that DMAs into the page.)” This is what the whole RDMA half of the GUP saga was working around. A buffer marked MADV_DONTFORK is excluded from the child at dup_mmap() time — vma_iter_clear_gfp() punches it out — so no COW break can ever relocate it, no pin heuristic needs to fire, and no false positive costs an extra copy. LWN’s account of the RDMA regression notes precisely this: the self-test could have been fixed with MADV_DONTFORK, and “that change would make the test more robust” — but the kernel could not require it, because “it is not easy to fix every RDMA (or page-pinning in general) user, even when one wants to” (part 2).

MADV_WIPEONFORK is a security primitive, not a performance one. The manual page: “Present the child process with zero-filled memory in this range after a fork(2). This is useful in forking servers in order to ensure that sensitive per-process data (for example, PRNG seeds, cryptographic secrets, and so on) is not handed to child processes.” It “can be applied only to private anonymous pages”, the setting “remains in place” in the child so that grandchildren are also protected, and it “is cleared during execve(2)” so that a fresh program gets ordinary memory. The kernel side is two lines in dup_mmap(): the child VMA gets anon_vma = NULL and copy_page_range() is skipped, so the child’s first read faults in the shared zero page rather than the parent’s data.

The canonical use is a pre-fork server that keeps a userspace CSPRNG state or a session key in a fixed buffer. Without WIPEONFORK, every forked worker inherits an identical RNG state — the classic fork-safety bug that makes two workers generate the same “random” nonces. With it, each child reads zeros and is forced to re-seed.

/* Pattern: a forking server protecting per-process secrets and a DMA buffer. */
uint8_t *rng_state = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
                          MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
madvise(rng_state, 4096, MADV_WIPEONFORK);   /* children see zeros, must re-seed */
 
void *dma_buf = mmap(NULL, 1 << 20, PROT_READ|PROT_WRITE,
                     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
madvise(dma_buf, 1 << 20, MADV_DONTFORK);    /* children do not see it at all;
                                                 the NIC's pin can never be
                                                 invalidated by a COW break */
ibv_reg_mr(pd, dma_buf, 1 << 20, IBV_ACCESS_LOCAL_WRITE);  /* pins via FOLL_PIN */

Line by line: rng_state stays in the child’s address space — the child can read and write it without faulting on an unmapped address — but it contains zeros, so a correctly written child detects the un-seeded state and re-seeds. dma_buf is removed from the child, so a child that touches it takes a SIGSEGV; that is a deliberate, loud failure, and it is the right trade for a buffer a network card is writing into asynchronously.

One more fork behaviour that trips people up, verified in both the manual page and the source: memory locks are not inherited. dup_mmap() runs vm_flags_clear(tmp, VM_LOCKED_MASK) on every copied VMA (kernel/fork.c, v6.12), so an mlock()ed region — a private key buffer kept out of swap, say — is unlocked in the child and can be paged out. A child that inherits secrets and cares about swap must call mlock() again itself.

Failure Modes and Common Misunderstandings

fork() is free.” It is cheap, not free, and what it is not free in is page tables. Re-read the cost table above: a 24 GiB Redis instance pays ~48 MiB of page-table allocation and copying inside the syscall, measured at 9–13 ms per GiB on decent hardware and up to 424 ms per GiB on old Xen (Redis). That stall is synchronous, in the calling thread, and for a single-threaded server it is a full stop-the-world pause. The symptom is a latency spike exactly correlated with BGSAVE/BGREWRITEAOF, and INFO’s latest_fork_usec field names the cost directly.

COW amplification: fork succeeds, then the machine OOMs. This is the failure that costs people production incidents. The memory accounting at fork() is optimistic — under the default vm.overcommit_memory=0 heuristic the kernel does not reserve memory for the copies COW might later demand (Memory Overcommit and Accounting). The allocations happen lazily, one page at a time, in wp_page_copy(). So a fork of a 20 GiB process on a 32 GiB machine succeeds instantly, and then, if the parent keeps writing across its whole heap while the child lives, the two processes converge toward 40 GiB of distinct pages and the OOM killer arrives. The characteristic shape:

 memory
   used
    ^
40G |                                          ,-- OOM killer
    |                                     ,---'
30G |                          ,---------'
    |               ,---------'          <- every parent write to a shared page
20G |______________/                        allocates a NEW page: RSS climbs
    |              ^                         with no allocation in the program
10G |              fork() returns in 250 ms
    |              (page tables only; 0 bytes of data copied)
  0 +--------------------------------------------------> time
                   |<----- the child's lifetime ----->|

Why a successful fork() can still kill you. What it shows: the memory cost of a fork is not paid at the fork; it is paid gradually afterwards, in proportion to the parent’s write rate, not its allocation rate. The insight to take: the danger window is the child’s lifetime, and the worst case is bounded by the parent’s resident set, not by anything the program allocates. A write-heavy parent with a long-lived child is the pathological combination — which is exactly a Redis instance under write load doing a slow BGSAVE to a slow disk.

Mistaking shared file mappings for COW. Everything in this note concerns private mappings — MAP_PRIVATE, anonymous heap and stack. A MAP_SHARED mapping is genuinely shared after fork: writes are visible to both processes and go to the backing object. do_wp_page() routes those to wp_page_shared(), which calls the filesystem’s ->page_mkwrite and marks the page dirty. It never copies. See Anonymous vs File-Backed Memory.

Assuming page_mapcount() still decides COW. Any explanation written before 5.19 — including a great deal of still-circulating material and several textbooks — describes the mapcount-based reuse heuristic. The 6.12 kernel uses PageAnonExclusive plus folio_ref_count, and page_trans_huge_mapcount() has been deleted outright. Reading old explanations onto a modern kernel produces confidently wrong predictions, particularly about when a copy will happen.

Expecting PageAnonExclusive to be accurate in both directions. It is not, and the asymmetry is deliberate. Set means “definitely exclusive, reuse is mandatory.” Clear means “unknown — go check.” The bit is left stale-clear after a COW break (the non-faulting side keeps a clear bit despite becoming the sole owner) and is lost entirely across a swap-out. So pgreuse counts in /proc/vmstat will show slow-path reuses that a naive model of the flag would not predict.

RSS double-counting after fork. Immediately after a fork, ps shows both processes with the parent’s full RSS, because RSS counts mapped pages regardless of sharing. The total is not 2× the memory; it is 1× plus page tables. To see the truth, read /proc/PID/smaps_rollup and look at Private_Dirty (pages this process alone has dirtied — the real incremental cost) against Shared_Clean (still-shared COW pages). Watching Private_Dirty climb in the child of a BGSAVE is watching COW amplification happen in real time.

The fork() that returns ENOMEM under strict overcommit. With vm.overcommit_memory=2 (strict), dup_mmap() calls security_vm_enough_memory_mm() for every VM_ACCOUNT VMA, so the fork must reserve commit charge for the entire address space up front. A 20 GiB Redis on a 32 GiB machine with strict overcommit will simply fail to fork. This is the documented reason Redis recommends vm.overcommit_memory=1.

Alternatives and When to Choose Them

If the goal is “run another program”, fork() is the wrong tool in 2026 — it copies an address space you are about to throw away.

MechanismAddress spacePage tables copied?Parent blocks?Use when
fork()private copy, COWyes — all anon VMAsnothe child needs the parent’s memory (snapshots, pre-fork servers, BGSAVE)
vfork()shared with the parentnoyes, until execve/_exitimmediate exec, and you can guarantee the child touches nothing
clone(CLONE_VM|CLONE_VFORK)sharednoyeswhat posix_spawn actually uses
posix_spawn()shared (glibc ≥ 2.24)nobrieflythe default choice for “run a program”
clone(CLONE_VM) without CLONE_VFORKshared, both runnablenonothreads — see Virtual Memory Areas
Pre-forked worker poolprivate, COW at startuponce, at startupnoamortize the cost: fork early, when RSS is small

Process-creation mechanisms ranked by what they do to the address space. What it shows: every alternative to fork() avoids the page-table copy by sharing the address space instead of duplicating it, and pays for that with a constraint on what the child may do. The insight to take: the page-table copy is the only cost fork() cannot defer, so the only way to avoid it is to not create a new address space at all. Everything in rows 2–5 is a different way of saying “borrow the parent’s mm until exec replaces it.”

vfork() is the sharp instrument. It “is used to create new processes without copying the page tables of the parent process” and “differs from fork(2) in that the calling thread is suspended until the child terminates… or it makes a call to execve(2). Until that point, the child shares all memory with its parent, including the stack” (vfork(2)). Sharing the stack is what makes it dangerous: the child must not return from the calling function or call exit(3), only _exit(2), because returning would corrupt the parent’s stack frame and exit() would run the parent’s atexit handlers and flush the parent’s stdio buffers. The manual page is candid that “some consider the semantics of vfork() to be an architectural blemish”, quoting 4.2BSD’s own promise to eliminate it — and equally candid about why it survives: some applications need the last few microseconds, and it works on MMU-less systems where fork() cannot be implemented at all.

posix_spawn() is the answer most programs want. POSIX specified it “to provide a standardized method of creating new processes on machines that lack the capability to support the fork(2) system call”, and it “provide[s] the functionality of a combined fork(2) and exec(3), with some optional housekeeping steps in the child process before the exec(3)” (posix_spawn(3)). Crucially, the implementation detail is documented: “Since glibc 2.24, the posix_spawn() function commences by calling clone(2) with CLONE_VM and CLONE_VFORK flags.” No page tables are copied. The file-descriptor manipulation and signal-mask work that you would normally do between fork and exec — and that is exactly what makes vfork unusable by hand — is expressed declaratively through posix_spawn_file_actions_t and posix_spawnattr_t and performed by the library in the child, safely.

This is not theoretical advice; major runtimes made the switch. OpenJDK’s Unix ProcessImpl enumerates three launch mechanisms and defaults to the middle one (OpenJDK 21, ProcessImpl.java):

    private static enum LaunchMechanism {
        // order IS important!
        FORK,
        POSIX_SPAWN,
        VFORK
    }
 
    private static LaunchMechanism launchMechanism() {
        String s = GetPropertyAction.privilegedGetProperty("jdk.lang.Process.launchMechanism");
        if (s == null) {
            return LaunchMechanism.POSIX_SPAWN;   // the default on Unix
        }
        ...
    }

The reason a JVM cares is the one this note has been building toward. A JVM with a 64 GiB heap that calls Runtime.exec() to run /bin/ls would, under the FORK mechanism, duplicate roughly 128 MiB of page tables and then immediately discard the result — and under strict overcommit, would fail outright with ENOMEM because the fork must reserve commit charge for the whole heap. POSIX_SPAWN reduces that to a clone(CLONE_VM|CLONE_VFORK) plus an execve of a small helper binary (jspawnhelper). The property remains settable to FORK or VFORK for anyone who needs the old behaviour, and VFORK is rejected on macOS and AIX.

Pre-forking is the structural answer for servers. If workers must inherit parent state, fork them early, before the parent’s resident set has grown, and keep them alive. This converts an O(RSS) cost paid per request into an O(small RSS) cost paid once at startup. It is why nginx, Apache’s prefork MPM, PHP-FPM, Gunicorn, and Unicorn all fork at boot and never again during steady-state request handling.

Production Notes — Measuring and Surviving COW

COW is unusual among memory-management mechanisms in that it is directly measurable from userspace, through four independent instruments.

What you want to knowWhere to lookInterpretation
How many COW faults were resolved without a copygrep pgreuse /proc/vmstatincremented by count_vm_event(PGREUSE) in wp_page_reuse(); the cheap half of the ledger
How much time a task lost to COW copiesWPCOPY row of getdelays -d -p <pid>delay accounting, added in Linux 5.19 (commit 662ce1dc); needs CONFIG_TASK_DELAY_ACCT and kernel.task_delayacct=1
How much memory a forked child has actually divergedPrivate_Dirty in /proc/PID/smaps_rolluppages this process alone has dirtied — the true incremental cost of the fork
How much is still sharedShared_Clean in the same fileCOW pages not yet broken; this number falls as amplification proceeds
How long the last fork tookRedis: latest_fork_usec in INFOthe page-table copy, in microseconds

Four independent instruments for the same mechanism. What it shows: the reuse path, the copy path, the memory outcome, and the latency outcome are each separately observable. The insight to take: pgreuse versus WPCOPY is the diagnostic pair. Lots of COW faults with a high pgreuse and a near-zero WPCOPY means the reuse fast path is working and you are paying almost nothing. A large WPCOPY delay total means real copies, and the fix is upstream of the kernel: fork less, fork earlier, or stop using huge pages.

Delay accounting is the instrument most people do not know exists. It was added specifically to make COW cost visible: “Delay accounting does not track the delay of write-protect copy. When tasks trigger many write-protect copys (include COW and unsharing of anonymous pages), it may spend a amount of time waiting for them. To get the delay of tasks in write-protect copy, could help users to evaluate the impact of using KSM or fork() or GUP” (commit 662ce1dc). The delayacct_wpcopy_start() / delayacct_wpcopy_end() bracket sits directly around the body of wp_page_copy(), so the number is the real thing — allocator time, reclaim time, and memcpy time included. The kernel’s own example output shows a task with 3,635 write-protect copies totalling 271 ms (Documentation/accounting/delay-accounting.rst).

# 1. Is COW reuse working, or is every fault a copy?
grep -E '^(pgfault|pgreuse|thp_fault_alloc)' /proc/vmstat
 
# 2. Per-task COW copy delay (needs CONFIG_TASK_DELAY_ACCT)
sysctl -w kernel.task_delayacct=1
./getdelays -d -p $(pidof redis-server)     # tools/accounting/getdelays.c in the kernel tree
#   WPCOPY   count    delay total   delay average
#             3635     271567604          0.074ms
 
# 3. Watch COW amplification happen during a BGSAVE
watch -n1 'grep -E "Private_Dirty|Shared_Clean|Rss" /proc/$(pidof redis-server)/smaps_rollup'
 
# 4. How long does this process take to fork? (the page-table copy)
redis-cli INFO stats | grep latest_fork_usec
 
# 5. Kill the huge-page amplification factor
cat /sys/kernel/mm/transparent_hugepage/enabled   # brackets mark the active value
echo never > /sys/kernel/mm/transparent_hugepage/enabled
 
# 6. Let the optimistic fork succeed (Redis's documented requirement)
sysctl -w vm.overcommit_memory=1

The Redis case, from Redis’s own documentation

Redis is the canonical COW-amplification story because its persistence model is built on fork(): BGSAVE and BGREWRITEAOF fork a child that walks the (now frozen) dataset and writes it out, while the parent keeps serving writes. Every key the parent modifies during that window breaks COW on the page holding it.

Redis’s administration guide states the memory consequence and the reason for it in one paragraph (Redis, Redis administration):

If you are using Redis in a write-heavy application, while saving an RDB file on disk or rewriting the AOF log, Redis can use up to 2 times the memory normally used. The additional memory used is proportional to the number of memory pages modified by writes during the saving process, so it is often proportional to the number of keys (or aggregate types items) touched during this time.

“Proportional to the number of keys touched, not the number of bytes written” is the operationally important half. A workload that updates a 4-byte counter in each of a million scattered keys dirties a million pages — up to 4 GiB of new memory — even though it wrote four megabytes of data. Small writes with poor locality are the worst case, because COW’s granularity is the page, not the write.

The same guide gives the two settings that follow from this: “Set the Linux kernel overcommit memory setting to 1”, because the optimistic fork must be allowed to succeed; and disable transparent huge pages, because a 2 MiB COW granularity turns the amplification factor from bad to catastrophic. Redis’s latency guide is blunt about the mechanism: after a fork with THP enabled, “a few event loops runs will cause commands to target a few thousand of pages, causing the copy on write of almost the whole process memory.”

The JVM case

The JVM hits the other fork cost — page-table duplication rather than content amplification — and hits it for a reason that has nothing to do with the JVM’s own memory management. A large-heap JVM that shells out to a subprocess via Runtime.exec() or ProcessBuilder used to fork(), duplicating page tables proportional to the whole heap, only to exec a small program microseconds later. On a 64 GiB heap that is roughly 128 MiB of page tables built and thrown away per subprocess launch; under vm.overcommit_memory=2 it does not merely cost time but fails with ENOMEM, because dup_mmap() must charge security_vm_enough_memory_mm() for every VM_ACCOUNT VMA before the fork can proceed (kernel/fork.c, v6.12).

The fix was to stop forking. OpenJDK’s Unix process implementation now defaults to POSIX_SPAWN, which is clone(CLONE_VM|CLONE_VFORK) under glibc — the child borrows the parent’s address space, runs the jspawnhelper binary, and never touches a page table (OpenJDK 21 ProcessImpl.java; posix_spawn(3)). If you are debugging subprocess-launch latency or ENOMEM on a large-heap JVM, -Djdk.lang.Process.launchMechanism= is the knob, and FORK is the value that reproduces the old behaviour.

Uncertain

Verify: the specific JDK release in which POSIX_SPAWN became the default launch mechanism on Linux (as opposed to being merely available). Reason: this note reads the jdk-21+35 source, which shows POSIX_SPAWN as the default, but does not establish when that default changed; the older jdk.lang.Process.launchMechanism documentation described FORK as the default on Linux. To resolve: bisect ProcessImpl.java across JDK release tags for the change to launchMechanism()’s fallback, or find the corresponding JBS issue. uncertain

A runbook for “my fork is expensive”

  1. Measure the fork itself. latest_fork_usec, or strace -T -e trace=clone,clone3 on the parent. If the number scales with RSS, you are paying for page tables — the fix is a different process-creation mechanism, not a COW tweak.
  2. Measure the aftermath. WPCOPY delay total, and Private_Dirty growth in both parent and child. If these are large, you are paying for content copies.
  3. Check the multiplier. AnonHugePages in /proc/PID/smaps_rollup. Anything non-zero means some COW faults cost 2 MiB of split-plus-copy instead of 4 KiB.
  4. Check for KSM. grep -c . /sys/kernel/mm/ksm/run and MADV_MERGEABLE usage. KSM pages never take the reuse path.
  5. Then choose the structural fix. Fork earlier (pre-fork pool), fork less (posix_spawn for exec-only children), or fork nothing (threads, io_uring, in-process work queues). Tuning the kernel will not save a design that forks a 20 GiB process per request.

See Also