Forking a process feels like it should be proportional to the size of the process. It isn't — and that's both the reason Redis can snapshot a multi-gigabyte dataset from a single-threaded event loop, and the reason the bill for that snapshot shows up somewhere you're not looking: hundreds of milliseconds after fork() returns, one page fault at a time.

What fork() actually copies

The fork(2) man page is precise about this. The child is "an exact duplicate of the parent," and "the entire virtual address space of the parent is replicated in the child" — but replicated does not mean copied:

Under Linux, fork() is implemented 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.

Both processes see identical memory. Physically, they share every page, with the shared pages mapped read-only. Only when either side writes to a page does the kernel fault, copy that one page, and give the writer its own private version. A 24 GB process forks without copying 24 GB of anything.

What it does copy is the page table. On Linux/AMD64 with 4 kB pages, mapping 24 GB takes at least one entry (8 bytes) per page:

24 GB / 4 kB × 8 bytes = 48 MB of page table

That math comes straight from the Redis latency documentation, because for Redis this is not trivia — the fork runs on the main thread, and every client waits while the kernel builds those 48 MB of mappings.

How long that takes depends on your hypervisor more than your dataset

Redis exposes the duration of its last fork as latest_fork_usec in INFO, and the project published measurements across environments. The spread is startling:

EnvironmentRSSFork timePer GB
Physical machine (Xeon @ 2.27 GHz)6.9 GB62 ms9 ms
VM on VMware6.0 GB77 ms12.8 ms
EC2, modern HVM instance (Xen)1 GB10 ms10 ms
VM on Linode (Xen)0.9 GB382 ms424 ms
EC2, old instance types (Xen)6.1 GB1460 ms239.3 ms

Same operation, same kernel API, and a one-to-two-order-of-magnitude difference — old Xen paravirtualized instances made page-table duplication catastrophically slow, which is why the Redis docs flatly tell EC2 users to use HVM-based instances. This is a useful general lesson: fork() cost is a property of the memory-management stack you run on, not just of your process. The persistence docs are honest about the failure mode: fork "may result in Redis stopping serving clients for some milliseconds or even for one second if the dataset is very big and the CPU performance is not great."

The deferred bill: fork took 6 ms, the spike was 300 ms

Here's the part that breaks people's mental model. In a widely-read investigation, antirez chased ~300 ms latency spikes during BGSAVE on EC2 — while latest_fork_usec showed the fork itself taking about 6 ms. The fork wasn't the spike. The spike came after, from copy-on-write doing exactly what it's designed to do.

Linux doesn't even eagerly copy all page table entries at fork. A comment in the kernel's memory-management code explains the optimization:

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.

So the work you thought happened inside fork() is smeared across the milliseconds and seconds that follow, as the parent keeps serving writes. Every write to a not-yet-copied page traps into the kernel, allocates a page, copies 4 kB, and fixes up mappings. Individually invisible; in aggregate, a latency cliff that no profiler attributes to fork() because fork() already returned.

This is also why memory usage grows during a snapshot. The Redis administration docs warn that a write-heavy instance "can use up to 2 times the memory normally used" during an RDB save or AOF rewrite, with the extra "proportional to the number of memory pages modified by writes during the saving process." Not proportional to dataset size — proportional to write locality during the snapshot window. A mostly-idle instance duplicates almost nothing; a hot one duplicates its working set.

Transparent huge pages: the 512× amplifier

Copy-on-write granularity is the page size. Enable transparent huge pages and the unit of copying becomes 2 MB instead of 4 kB: one byte written, 2 MB copied.

The Redis latency docs describe the failure sequence: fork creates two processes sharing huge pages; on 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."

Antirez measured this concretely on bare metal. With jemalloc (whose allocations were being backed by AnonHugePages), the worst BGSAVE spike was 339 ms. With libc malloc, which wasn't getting huge pages, the same workload's worst spike was 21 ms. Same machine, same Redis, same fork — a 16× difference from page granularity alone. The fix that both antirez and the official docs converge on:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

THP is a genuinely good optimization for TLB pressure in most long-running processes. It is specifically hostile to the fork-and-diverge pattern, because it multiplies the cost of every divergence.

Overcommit: when fork() refuses to run at all

There's one more trap. From the accounting perspective, forking a 24 GB process asks the kernel to commit another 24 GB of address space — even though CoW means almost none of it will ever be physically copied. The kernel's overcommit-accounting documentation lists "account and check on fork" as part of the heuristic mode's job, and describes the three vm.overcommit_memory modes:

  • 0 (heuristic, default): "Obvious overcommits of address space are refused."
  • 1 (always): never refuse, appropriate for workloads "relying on the virtual memory consisting almost entirely of zero pages" — or, equivalently, on CoW pages that will never diverge.
  • 2 (strict): total commit capped at "swap + a configurable amount (default is 50%) of physical RAM," visible as CommitLimit vs Committed_AS in /proc/meminfo.

Under modes 0 and 2, a Redis instance using more than half your RAM can fail its background save with a fork error — not because memory ran out, but because the accounting says the worst case wouldn't fit. This is why the Redis administration docs' very first Linux setup instruction is vm.overcommit_memory = 1. You're telling the kernel: I know the worst case can't happen; the child is a snapshot reader that exits.

The pattern, and the checklist

Fork-based snapshotting is a beautiful trick — the persistence docs note that the parent "will never perform disk I/O" for RDB saves, and AOF rewriting "uses the same copy-on-write trick." The child gets a frozen, consistent view of the heap for free, with no locks and no stop-the-world pause proportional to data size. But the costs are real; they're just relocated:

  1. Watch latest_fork_usec, and treat per-GB fork time as an environment property worth benchmarking.
  2. Budget memory for CoW divergence — up to 2× under heavy writes, and size maxmemory with headroom.
  3. Disable transparent huge pages on fork-heavy services, or your 4 kB copies become 2 MB copies.
  4. Set vm.overcommit_memory = 1 so accounting doesn't veto a fork that CoW would have made cheap.

The general lesson outlives Redis: whenever a system forks to snapshot — and this trick appears anywhere someone needs a consistent view of mutable memory — the interesting costs are not in the syscall. They're in the page faults after it, the page size multiplying them, and the accountant deciding whether to allow the fork at all.

References