Ch. 21 · Operating Systems

Copy-on-Write Memory and fork

How fork shares pages until a write, measured on macOS: 16,517 pages copied on write, few on read, and Python copying pages just by reading.

~6 min readadvancedupdated Oct 19, 2026

fork() creates a new process that’s a copy of the caller: same code, same heap, same stack, same open files. If the kernel literally copied every byte, forking a 4 GB process would take seconds and double memory use instantly. It doesn’t, because of copy-on-write (COW). The child gets its own page tables pointing at the same physical pages as the parent, with those pages marked read-only in both. As long as nobody writes, nothing is copied. The first write to a page causes a fault, the kernel copies that one page, and the writer gets a private copy.

That’s the textbook version, and interviewers expect it. The follow-ups are where you show you’ve used it: what does COW cost later, why do memory dashboards lie about forked processes, and why do some languages lose most of the benefit?

What fork copies and what it defers

At fork(), the kernel duplicates the process’s bookkeeping: the address-space description, file descriptor table, signal dispositions and page table entries (or, depending on the kernel, enough information to rebuild them lazily). Writable private pages are marked read-only in both processes and flagged as copy-on-write. Shared mappings (MAP_SHARED, shared memory) stay shared for real; read-only pages such as program text never need copying at all.

I measured this on an Apple M1 running macOS, where the page size is 16 KiB (os.sysconf('SC_PAGE_SIZE') returns 16384). A Python parent allocated a 256 MiB bytearray, wrote one byte to every page so the memory was really resident, and then forked:

page size 16384 bytes, buffer 256 MiB = 16384 pages
fork() returned in child after 0.88 ms
Text

Under a millisecond for a process holding 256 MiB, because no data moved. The cost was deferred, not removed.

Reads share, writes copy

To see where the cost goes, the child read every page, then wrote one byte to every page. Around each phase it recorded its own page faults (ru_minflt from getrusage) and the system-wide “Pages copy-on-write” counter that macOS’s vm_stat prints:

c0, f0 = cow_pages(), faults()
buf.count(b'\x01')                                   # read every page
c1, f1 = cow_pages(), faults()
for i in range(0, len(buf), PAGE): buf[i] = 2        # write one byte per page
c2, f2 = cow_pages(), faults()
python
read all 16384 pages : faults  16570, system COW copies    166
write all 16384 pages: faults  16573, system COW copies  16517
Text

Both phases took about 16,500 faults, which surprises people. On macOS the child’s first touch of each page faults even for a read, because the mapping is filled in lazily. But the read faults only map the shared page: the system-wide COW counter barely moved (the 166 copies are background activity, including the vm_stat processes the script itself starts). The writes are different: 16,517 copies, one per page, in about 45 to 63 ms across runs. That’s 256 MiB of new memory the child now owns.

Linux behaves similarly in principle. Linux copies the parent’s page table entries at fork for most mappings, so read-only access typically doesn’t fault at all, and writes fault and copy. I didn’t run the Linux variant for this note. On Linux you can see the split per mapping in /proc/<pid>/smaps (Shared versus Private, and PSS) and globally in /proc/vmstat.

Gotcha

With transparent huge pages enabled on Linux, a single write into a 2 MiB huge page can copy all 2 MiB rather than 4 KiB. Databases that fork for snapshots, such as Redis, recommend disabling THP for this reason.

Why RSS misleads after fork

Resident set size (RSS) is the amount of physical memory mapped into a process. A shared page counts fully in each process that maps it. Fork a 2 GB server into eight workers, and ps can show each worker at about 2 GB of RSS, totalling 16 GB, on a machine with 8 GB of RAM that isn’t swapping. Nothing is wrong; the pages are counted eight times.

Two better measures exist. Proportional set size (PSS) splits each shared page’s cost among the processes sharing it, so the per-process numbers add up to the real total. Unique set size (USS) counts only the pages private to that process, which is what you’d free by killing it. Linux reports both through smaps_rollup; tools like smem summarise them. On macOS, Activity Monitor’s memory column and footprint focus on private dirty memory, which is closer to USS.

The opposite trap exists too. A forked worker that starts with tiny private memory grows steadily as it writes to inherited pages, so its memory “leaks” for an hour and then levels off once it has copied everything it touches. That curve is copy-on-write, not a leak.

Languages that write while reading

COW assumes that code which only reads data doesn’t write to its pages. Some runtimes break that assumption. In CPython, every object carries a reference count in its header, and simply iterating over objects increments and decrements those counts. Those are writes, so each touched page gets copied.

I built a list of one million small strings in the parent, forked, and had the child do nothing but sum their lengths and run one garbage collection:

n = sum(len(s) for s in objs)      # code that only reads the strings
gc.collect()
python
gc.freeze()=False: child read 1M strings, system COW copies 4137
Text

About 4,100 pages, roughly 65 MiB, copied by code that never assigned to anything. Calling gc.freeze() in the parent before forking, a feature added to CPython so pre-fork servers keep objects out of later collections, only brought it down to 4,093 in my runs, because it addresses the garbage collector’s writes, not reference counting. Gunicorn and uWSGI preforking still save memory for code and immutable data, but don’t count on sharing a large Python data structure between workers. For that, use shared memory, an mmap’d file, or a separate process the workers query.

Garbage collectors with mark bits in object headers (some JVM and Ruby configurations historically) had the same issue. Ruby 2.0 moved its mark bits out of objects for exactly this reason.

Latency, fork and exec

Fork’s own cost isn’t zero either. Copying page tables is proportional to the size of the mapped address space, so forking a process with tens of gigabytes mapped can take milliseconds to tens of milliseconds on Linux, and during that time the parent is stopped. Redis logs this as fork latency during background saves. After the fork, the parent pays for every page it writes while the child still shares it, which shows up as a latency bump on the parent’s write-heavy requests.

When the child is only going to call exec() to run another program, all of that is wasted: the new program replaces the address space immediately. posix_spawn() and vfork() exist to skip it, and most language runtimes use posix_spawn for subprocesses today.

Interview exercise: the snapshot that ran out of memory

A cache server uses 6 GB of an 8 GB machine. Every hour it forks a child that writes a snapshot of its data to disk while the parent keeps serving writes. Most hours this works, but during traffic peaks the machine runs out of memory and the kernel kills the server. Explain why, and what you’d change.

Answer

The fork itself is cheap because the child shares the parent’s 6 GB through copy-on-write. But while the child is writing the snapshot, which may take a minute or more, every page the parent modifies has to be copied so the child keeps seeing the old version. At normal traffic only a fraction of pages change during a snapshot, so the extra memory fits in the remaining 2 GB. At peak, the parent touches far more pages, potentially approaching another 6 GB in the worst case, and the machine runs out. If transparent huge pages are on, each small write copies 2 MiB instead of 4 KiB, which makes it much worse. I’d check by watching the parent’s private memory (or PSS) during a snapshot. Fixes: disable THP for the process, leave headroom sized for the write rate during a snapshot, schedule snapshots away from peaks, or replace fork-based snapshots with an append-only log or replication to a replica that does the snapshotting. Linux’s overcommit settings decide whether the fork is refused up front or the kernel kills a process later, which is worth mentioning, but the root cause is copy-on-write growth.

More in Operating Systems

read ✓Operating Systems · easy

Paging, Page Faults and Memory Access

Minor, major and invalid page faults, with real counts: 4,096 faults for 64 MiB on an M1, and a cold mmap read 34 times slower than warm.

~6 min readread →
read ✓Operating Systems · easy

Virtual Memory and Address-Space Isolation

Why two processes can hold different values at the same address, how page tables translate it, and why a 32 GiB mapping can cost almost nothing.

~6 min readread →
read ✓Operating Systems · hard

Operating Systems: CPU Cache Locality

Measured on an M1: column-order matrix walks up to 30x slower, shuffled lists at 97 ns a node, and false sharing slowing two counters 4x.

~6 min readread →
esc