The report always arrives in the same shape. A service loads something large at startup — an ML model, a geo index, a compiled ruleset, a big config tree — and then forks eight worker processes. The plan was that fork()‘s copy-on-write would let all eight share those pages, so total RSS would be roughly model + 8 * small. What actually happens is that within a few minutes each worker’s RSS has climbed to nearly the full size of the parent, and the box is out of memory.
Nobody wrote a copy. So who did?
Copy-on-write does not survive reference counting
fork() gives the child the parent’s address space with every page marked read-only and copy-on-write. The first write to a page triggers a private copy of that 4 KB (or 16 KB) page.
Now recall what an object header contains: ob_refcnt. Merely reading a Python object — calling a function defined in a module, touching a class attribute, iterating a list of long-lived objects — increments and then decrements its refcount. That is a write. To the kernel it is indistinguishable from mutation.
So a child process that only reads the shared model still dirties every page containing an object it touches. Over a few minutes of serving traffic, that converges on “most of them”.
The garbage collector makes it worse and does so in a burst. A generation-2 collection walks every tracked object in the process, touching the PyGC_Head linked-list pointers of each one as it moves objects between generation lists. One full collection in each child is enough to dirty essentially the entire heap that existed at fork time — which is why the RSS climb often looks like a step function rather than a ramp.
💡A colleague proposes moving the model load *after* the fork, "so each worker owns its own copy and there is no sharing to lose". What does that fix, what does it cost, and when is it the right answer? click to reveal
It fixes the surprise: memory usage becomes what you would predict, n_workers * model_size, with no dependence on GC timing or access patterns. Nothing degrades over the process lifetime because nothing was ever shared.
It costs exactly the thing you were trying to buy — you now pay n_workers copies of the model in real memory rather than one, plus n_workers load times at startup, which for a large model can turn a two-second boot into a minute.
It is the right answer when the model is small relative to the box, when workers need to mutate it, or when you are on a platform where fork is not available or not safe (spawn on macOS and Windows; forkserver, which became the Unix default outside macOS in 3.14, re-executes the interpreter and shares nothing anyway). It is the wrong answer when the whole reason for the pre-fork architecture was that the shared object is enormous.
Notice that the forkserver default change makes this less of a choice than it used to be: if your deployment moved to 3.14 and the start method changed under you, the copy-on-write sharing you were relying on may already be gone. That is worth checking before tuning anything.
gc.freeze()
Added in 3.7, and the documentation states its purpose plainly: it “freezes all the objects tracked by the garbage collector; moves them to a permanent generation and ignores all the future collections”, and “this can be used before a POSIX fork() call to make the gc copy-on-write friendly”.
What it does is move every currently-tracked object into a permanent generation that no future collection will ever walk. Those objects are never traversed, never moved between generation lists, and never have their PyGC_Head pointers rewritten. The collector stops touching them, so the child processes stop dirtying their pages because of the collector.
The pattern is a three-liner and the order is the whole trick:
import gc
load_model() # 1. import and construct everything long-lived
gc.collect() # 2. clean up the garbage produced while doing so
gc.freeze() # 3. move the survivors out of the collector's reach
fork_workers() # 4. only now fork
Step 2 matters: whatever you freeze is permanent, so you want to freeze the survivors, not the debris. Step 3 must be before step 4, or you have frozen nothing that the children share.
A measured data point for scale: a bare gc.freeze() at startup on a modest application moved 13,773 objects — that is just the interpreter’s own imports and module objects, before any of your code’s data.
What freeze() does not do is stop refcount writes. Reading a frozen object still touches its ob_refcnt. Immortalisation (PEP 683, 3.12) removes that cost for the objects touched most often — None, small ints, interned strings, static types — but not for your model’s own objects. So gc.freeze() removes the burst, not the ramp. It is a large win and not a complete one.
gc.unfreeze() moves everything back if you need it, and gc.get_freeze_count() tells you how many objects are in the permanent generation.
The three tuning levers, ranked
Raise the thresholds (gc.set_threshold(20_000, 20, 20)). Fewer, larger collections. Trades peak memory for fewer pauses. Reversible, measurable, low risk. Try this first.
gc.freeze() before fork. Targeted at exactly the copy-on-write problem. Costs nothing on the parent’s steady-state behaviour, because the frozen objects were long-lived anyway and a collector pass over them was never going to free anything. This is the highest-value-per-risk change in the list, if your architecture is pre-fork.
gc.disable(). Almost always wrong.
Why gc.disable() is usually a mistake
Disabling the cycle collector does not disable memory management — reference counting still runs, and anything acyclic is still freed promptly. What it means is: any cycle your process creates is never reclaimed, for the lifetime of the process.
It is defensible in exactly one shape: a short-lived, batch process whose object graph is provably acyclic, where you would rather spend memory than pause time, and where the process exits soon enough that unbounded growth cannot matter.
“Provably acyclic” is much rarer than people believe. Recall the shapes from article 11.3 — parent back-pointers, doubly-linked lists, observer registrations, closures capturing self, functools.partial(self.method) stored on self, an exception retained on the object that raised it. Any dependency you import can introduce one without telling you. A long-running service with gc.disable() is a memory leak with a countdown.
If you disable it, the honest version is to re-enable it at a controlled point rather than never:
gc.disable()
try:
run_latency_critical_phase()
finally:
gc.enable()
gc.collect()
💡You have a request handler that must not pause. Compare gc.disable() for the duration of the request against raising the thresholds globally, and say what you would need to observe to choose.
click to reveal
gc.disable() around the request gives you a hard guarantee for that window: no collection can start inside it. The cost is that all the cyclic garbage the request created is still there afterwards, so re-enabling and collecting at the boundary moves the pause rather than removing it — and if you skip the collect, the debt compounds across requests. It is also process-global, so on the GIL build you have just disabled collection for every other thread’s work that overlaps your window.
Raising thresholds is softer: collections still happen, just less often. You get no guarantee for any individual request, but you get a lower rate of pauses and no accumulation risk, and the change is one line with no lifetime management.
What to observe before choosing: the actual pause distribution, per generation. gc.callbacks lets you register a hook that fires at the start and end of every collection with the generation number, so you can record real durations rather than reason about them. If the tail is dominated by gen-2 passes over a large permanent heap, the answer is probably gc.freeze() rather than either of these — you would be removing the work, not rescheduling it. If the tail is gen-0 and frequent, thresholds. If a single request genuinely cannot tolerate any pause at all and you have measured that it currently does, then the scoped disable, with the finally: gc.enable() that makes it survive an exception.