I’d like to discuss extending gc.freeze() with an opt-in keyword —
gc.freeze(seal_pages=True) (default False, fully backward compatible) — that,
in addition to the existing move-to-permanent-generation behaviour, “seals” the
object allocator’s current state: it removes every currently partially-filled pool
from the allocation free lists so that subsequent allocations start from fresh
pools/arenas, leaving the existing pages untouched by any future allocation.
The goal is to close the last remaining source of copy-on-write (CoW) page
dirtying for preforking servers, after gc.freeze() and immortalization have
dealt with the other two — and to do it at the call site people already use for
fork preparation.
Background: the three sources of CoW dirtying
Preforking servers (gunicorn --preload, uWSGI prefork, Celery’s prefork pool,
plain multiprocessing) load shared state in a parent and fork() workers,
relying on CoW to share the read-only-in-practice parent heap. Three things write
to pages holding long-lived parent objects and so break that sharing:
-
GC header writes. Collection shuffles the generation linked lists whose
PyGC_Headlives with the object, dirtying pages. Solved bygc.freeze()
(3.7), which moves the objects to the permanent generation so collections skip
them. -
Refcount writes. Every
Py_INCREF/Py_DECREFwritesob_refcntin the
object header. Addressed by immortal objects (PEP 683), per-object
PyUnstable_Object_EnableDeferredRefcount()(3.14), andPyUnstable_IsImmortal()
for introspection; a general setter is in progress
(gh-143300). -
Allocator reuse of free blocks co-located with long-lived data.
Currently unaddressed, and the subject of this post.
The gap
obmalloc serves blocks from pools (one OS page) belonging to a single size
class, up to the 512-byte small-request threshold. At fork() time, any pool that
holds long-lived data and still has free blocks is a “mixed” page. After the
fork, the first allocation into one of those free blocks — in the parent or a
child — faults the whole page private, duplicating whatever long-lived (even
immortal, even frozen) data was riding along on it.
The gc.freeze() docs already gesture at this: they note that CoW-friendliness
“requires … avoiding creation of freed ‘holes’ in memory pages in the parent”
and recommend gc.collect() before the fork so holes can be released. But that is
about not fragmenting and releasing holes; it does nothing to stop post-fork
allocation from re-dirtying the partial pools that legitimately remain after a
load-everything-then-freeze sequence.
So even with gc.disable() + gc.collect() + gc.freeze() + full
immortalization, the partially-filled pools that interleave long-lived objects
with free blocks are still landmines.
Why this can’t be done from Python
The obvious user-space workaround — allocate filler objects to pack the free
blocks in each partial pool until obmalloc spills onto fresh pages — does not work
in practice, for three reasons:
- It must be done per size class (each pool serves one class), not by
allocating “the smallest thing”. - The only user-visible signal for “a new pool opened” is RSS growth, which is
noisy and arena-coarse. - Fatally: the packing loop itself, and everything that runs afterwards up to
and including the fork machinery (e.g.multiprocessing/billiardsetup),
keeps allocating into those same pools, re-introducing mixed pages in classes
you just packed. You cannot freeze the allocator’s state from code that is
itself running and allocating.
That last point is why this has to happen inside the runtime, as late as possible
before the fork — and why folding it into gc.freeze(), the call that already
marks “I’m done preparing the heap, now I’m going to fork”, is a natural fit.
Proposal: gc.freeze(seal_pages=True)
With seal_pages=True, after performing the existing freeze (moving tracked
objects to the permanent generation), gc.freeze() additionally, for the active
object allocator:
- Removes every currently partially-used pool from
usedpoolsso it is treated as
full and no further blocks are handed out from it. - Routes subsequent small-object allocations to fresh pools (and fresh arenas as
needed), which become private in children naturally and harmlessly.
The recipe becomes a one-line change to the documented sequence:
gc.disable()
# ... load all shared state, immortalize it ...
gc.collect()
gc.freeze(seal_pages=True) # was: gc.freeze()
# fork workers
Why a flag on gc.freeze() rather than a standalone primitive
I expect the main objection to be layering: gc.freeze() is a collector
operation, while sealing is an allocator (obmalloc / mimalloc) operation, and
strictly these are different subsystems. I think the flag is still the right home:
gc.freeze()is already the documented fork-preparation entry point. Its own
docs discuss page holes and prescribe the disable/collect/freeze sequence, so it
already straddles the GC/allocator line for precisely this purpose.- It’s where users already reach for CoW-friendliness; discoverability is high and
the mental model (“freeze the heap before fork”) is unchanged. - Sealing is only ever useful in combination with freezing, immediately before a
fork — the exact situationgc.freeze()exists for. A separate primitive would
almost always be called on the next line anyway.
The counter-view — that this is really an allocator concern and should live behind
a PyUnstable_-prefixed allocator API, with gc.freeze() left purely about GC
generations — is worth airing, and I’ve listed it as an open question below.
Semantics that need pinning down
- Wasted free blocks. The free blocks in sealed pools are stranded (internal
fragmentation) until/unless un-sealed. This is the primary cost and needs to be
quantified against the CoW it saves. - Frees into sealed pools. If a block in a sealed pool is later freed (e.g. a
long-lived-but-not-immortal object), does obmalloc re-link the pool into
usedpools(re-opening the page to allocation and defeating the seal), or keep
it out and strand the block? For the immortalize-everything case this is moot,
but the API must specify it. gc.unfreeze()interaction. Shouldgc.unfreeze()also un-seal, or are the
two independent? Un-sealing cleanly is harder than unfreezing, so they may need
to be decoupled even though they share an entry point.- Scope. Small objects only; allocations above the small-request threshold go
to the system allocator and are out of scope — document the boundary. - Free-threaded build. The free-threaded build uses mimalloc, not obmalloc,
with a different heap/page structure. Doesseal_pages=Trueneed a mimalloc
backend for parity, or raise / no-op (with a warning) there initially? (Honest
question — I haven’t dug into whether mimalloc exposes a clean equivalent of
“retire current pages”.) - Idempotency. Repeated
gc.freeze(seal_pages=True)calls should be safe and
cheap.
Open questions / what I’m really asking
- Has anyone measured the obmalloc residual? This is the crux. Instagram’s
published CoW work stopped atgc.freeze(); PEP 683 added immortality; I can’t
find anyone who has quantified how much CoW the partial-pool sharing actually
costs after freeze + immortalization in a real deployment. If the residual is
small, this flag isn’t worth it. If someone has numbers (or a repro I can
borrow), that would settle whether this is worth pursuing. - Flag vs standalone primitive: is
gc.freeze(seal_pages=True)the right
home, or should sealing be a separatePyUnstable_-namespaced allocator API
withgc.freeze()kept purely about GC generations? - obmalloc vs mimalloc: one behaviour over both allocators, or
obmalloc-first with mimalloc raising/no-opping until a backend exists? - Seal vs compact: is freezing the current layout the right model, or is the
better primitive an active defragmentation that relocates long-lived objects
into a minimal set of fully-packed pages before fork? (Relocation is obviously
far harder given that object addresses are stable and exposed.)
Motivation / context
This came out of trying to fit a preloaded Celery prefork worker into a
memory-limited host. gc.freeze() plus immortalizing the preloaded structures
gets most of the way, but the leftover mixed pages are the visible remaining
cost, and there is currently no supported lever for them — only fragile
user-space hacks that don’t survive the fork machinery’s own allocations.
References
gc.freeze()docs (the “holes in pages” guidance):
gc — Garbage Collector interface — Python 3.14.6 documentation- PEP 683 – Immortal Objects: PEP 683 – Immortal Objects, Using a Fixed Refcount | peps.python.org
- gh-143300 – unstable C API for immortalizing objects:
Add an unstable C API for immortalizing objects · Issue #143300 · python/cpython · GitHub - Instagram, “Copy-on-write friendly Python garbage collection” (origin of
gc.freeze):
https://instagram-engineering.com/copy-on-write-friendly-python-garbage-collection-ad6ed5233ddf - Victor Stinner’s C-API optimization ideas (the out-of-line refcount alternative,
which would also solve #3 but as a much larger ABI change):
Optimization ideas — pythoncapi 0.1 documentation