A long-lived fork() child makes the parent pay a copy-on-write fault on every page it writes afterwards; fork+exec pays far less
I wanted a worker process to consume records from a parent that keeps writing its own memory. On Linux x86-64 I measured three variants, each moving the same 35.8M 16-byte records through a shared ring, n=1 per arm, wall contended. The parent had a 256 MiB resident heap and dirtied one 4 KiB page per cycle, round-robin, so every page was touched once.
| worker | wall through join | total CPU | vs thread | |---|---|---|---| | thread | 81 ms | 0.148 s | baseline | | fork(), child kept alive | 286 ms | 0.524 s | +0.376 CPU-s | | fork()+exec | 160 ms | 0.287 s | +0.139 CPU-s |
Both fork variants showed 65,536 more parent minor faults than the thread, which is 256 MiB / 4 KiB. After fork() every private page is write-protected in both processes, so the parent's first write to each page faults. While the child is still alive and holds the old page, that fault also copies the 4 KiB. After exec the child drops its mappings: the parent still takes the protection fault, but there is little or nothing to copy, so it is cheaper.
With no large heap, the three variants were within about 3 ms of each other.
Takeaways: - The extra cost scales with resident pages the parent writes after the fork, not with record count. It is a one-off cost per page, not a per-cycle cost. - If the worker doesn't need the parent's memory, use fork+exec or posix_spawn, or a thread. - Fork the worker early, before the parent builds a large heap, to cut the cost further. - Minor-fault counts are not bytes copied; profile the kernel if you need the split.
Follow-up, same harness (glibc 2.34, x86-64, n=1, 256 MiB heap, one page dirtied per cycle), with posix_spawn added as a fourth arm: thread 0.151 CPU-s / 128 parent minor faults; retained fork 0.531 s / 65,664; fork+exec 0.284 s / 65,664; posix_spawn 0.142 s / 128. So posix_spawn avoided the 65,536 first-write faults that fork+exec still paid, and its worker was ready in 1.6 ms vs 27 ms for fork+exec. Steady-state transfer after startup was the same as the thread (70.5 vs 69.9 ms), so the win is startup only. If the worker does not need the parent memory, posix_spawn is the cheapest way to get a separate process.