# fork() and Copy-on-Write: Duplicating a Process for Free

> fork clones a 10 GB process in under a millisecond by copying nothing — just page tables, marked read-only. The first write to any page pays the real cost. Elegant, and the source of Redis's most famous operational trap.

URL: https://harshith.in/blog/fork-and-copy-on-write/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: linux, kernel, memory, systems

`fork()` makes a complete copy of your process — address space, file
descriptors, everything. Called on a 10 GB process, it returns in well under a
millisecond. Those two sentences can't both be true naively, and the trick
that reconciles them — copy-on-write — is one of those kernel ideas that pays
compound interest: it explains why fork+exec isn't absurd, how Redis snapshots
without pausing, why forking servers "share" memory until they don't, and one
famous way production machines run out of memory *without allocating any*.

## Copy the Map, Not the Territory

What fork actually duplicates is the **page tables** — the map of the address
space, kilobytes-to-megabytes even for huge processes — not the memory. Both
processes' entries now point at the *same physical frames*, and every writable
page is downgraded to **read-only** in both:

```text
  parent PTEs ──▶ [frame A][frame B][frame C] ◀── child PTEs
                        all marked read-only, refcount 2
```

Reads proceed at full speed, indefinitely shared. A *write* — by either
process — traps: the page fault handler sees a write to a read-only page in a
writable VMA, recognizes the COW case, copies **that one 4 KB page**, points
the writer's PTE at the copy (writable), and restarts the instruction. The
process pays for its divergence page by page, on demand, and pages nobody
writes are never copied at all.

The design bet is workload-shaped, and it's usually right: the most common
thing to do after fork is `exec`, which throws the whole address space away —
eager copying would be pure waste. (History corroborates: pre-COW Unix really
did copy, which is why `vfork` — "borrow the parent's memory, parent freezes
until exec" — existed. Modern `posix_spawn` bundles fork+exec for the
spawn-a-program case and is what glibc actually optimizes; use it and skip the
folklore.)

## Where the Bill Comes Due

COW turns "how much memory does fork cost" into a question about *write
patterns*, and that has sharp production edges.

**The Redis case is the canonical one.** BGSAVE forks; the child serializes a
frozen snapshot of the dataset — consistency for free via COW, no locks, the
parent keeps serving. But every write the parent handles during the dump
copies a page. A write-hot 20 GB Redis can approach 2× memory during a save —
and the failure is *spiky*: fine for months, then a traffic surge coincides
with BGSAVE and the OOM killer arrives. The `vm.overcommit_memory=1` advice in
every Redis deployment guide is exactly this: the kernel must be willing to
promise memory that COW will probably never claim, or fork itself fails with
ENOMEM at the worst time.

The same physics, quieter: forking web workers (classic Apache prefork,
gunicorn) start out sharing nearly everything — then runtime writes erode the
sharing. The infamous version: CPython's reference counting writes into
*every object it touches*, so even read-only workloads in forked Python
workers un-share memory steadily (`Instagram once patched CPython's GC —
gc.freeze — specifically to stop refcount/GC writes from shredding COW`).
Measure reality with `smaps_rollup`: `Shared_Clean` vs `Private_Dirty` is the
COW ledger, and PSS is the honest per-worker cost.

Two more inherited-by-COW facts worth filing: the child gets a *copy* of file
descriptor numbers but **shares the file objects** — offsets move for both
(why you `fflush` before fork, lest buffered output print twice); and
`fork` in a multithreaded process copies only the calling thread, with every
lock frozen exactly as some now-nonexistent thread left it — the reason
"fork only then exec, or fork from a thread that owns nothing" is a rule and
pthread_atfork is mostly an apology.

## One Mechanism, Many Costumes

Once you see COW as "share until write, fault to diverge," it shows up
everywhere: `mmap(MAP_PRIVATE)` of a file (your process's view of libc is COW
over the page cache), memory deduplication in VMs (KSM merges identical guest
pages, un-merges on write), Btrfs/ZFS snapshots and `cp --reflink` (same idea
over disk blocks), and fuzzer fork-servers (AFL forks per test case precisely
because COW makes each iteration's memory state disposable). It's the same
trade every time: cheap logical copies, deferred physical cost, metadata
tracking who diverged.

## Takeaways

- fork copies page tables and marks pages read-only; the first write to each
  page pays for a 4 KB copy via the fault handler.
- Cost is proportional to *writes after fork*, not process size — which is why
  fork+exec is cheap and snapshot-fork (Redis) is a memory-spike machine.
- Refcounting/GC runtimes (CPython) write everywhere and quietly defeat COW
  sharing in forked workers; audit with Private_Dirty/PSS.
- After fork: fds share offsets, only the calling thread survives, and frozen
  locks make fork-without-exec hazardous in threaded programs.
- COW is a general systems pattern — MAP_PRIVATE, KSM, reflink snapshots,
  fork-servers — not a fork-specific trick.
