FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

gh-159437: Support frozen objects in combined GC reference passes by mfalco-oai · Pull Request #159597 · python/cpython · GitHub

Repository navigation

Prev Previous commit
gh-159437: Support frozen objects in fused GC reference passes
  • Loading branch information
mfalco-oai committed Oct 21, 2026
commit eedc334c083fd0b104c81ff92de4e8f322bea91f
12 changes: 10 additions & 2 deletions Include/internal/pycore_gc.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,15 @@ static inline void _PyObject_GC_SET_SHARED(PyObject *op) {
/* Bit 1 is set when the object is in generation which is GCed currently. */
#define _PyGC_PREV_MASK_COLLECTING ((uintptr_t)2)

#define _PyGC_PREV_SHIFT 2
#if !defined(Py_GIL_DISABLED) && SIZEOF_VOID_P > 4
// Pointer alignment reserves bit 2 for permanent ownership, including queued weakrefs.
# define _PyGC_PREV_MASK_PERM ((uintptr_t)4)
# define _PyGC_PREV_SHIFT 3
#else
// 32-bit and free-threaded builds retain the existing two-bit header layout.
# define _PyGC_PREV_SHIFT 2
#endif
// The remaining high bits contain the predecessor address or gc_refs.
#define _PyGC_PREV_MASK (((uintptr_t) -1) << _PyGC_PREV_SHIFT)

/* set for debugging information */
Expand Down Expand Up @@ -151,7 +159,7 @@ static inline void _PyGCHead_SET_NEXT(PyGC_Head *gc, PyGC_Head *next) {
gc->_gc_next = (uintptr_t)next;
}

// Lowest two bits of _gc_prev is used for _PyGC_PREV_MASK_* flags.
// Low bits of _gc_prev store _PyGC_PREV_MASK_* flags.
static inline PyGC_Head* _PyGCHead_PREV(PyGC_Head *gc) {
uintptr_t prev = (gc->_gc_prev & _PyGC_PREV_MASK);
return (PyGC_Head*)prev;
Expand Down
2 changes: 1 addition & 1 deletion Include/internal/pycore_interp_structs.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ typedef struct {
_Py_ALIGNED_DEF(_PyObject_MIN_ALIGNMENT, uintptr_t) _gc_next;

// Tagged pointer to previous object in the list.
// Lowest two bits are used for flags documented later.
// Low bits store the flags documented in pycore_gc.h.
// Those bits are made available by the struct's minimum alignment.
uintptr_t _gc_prev;
} PyGC_Head;
Expand Down
14 changes: 12 additions & 2 deletions Include/object.h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,19 @@ whose size is determined when the object is allocated.

/* PyObjects are given a minimum alignment so that the least significant bits
* of an object pointer become available for other purposes.
* This must be an integer literal with the value (1 << _PyGC_PREV_SHIFT), number of bytes.
* For exposed layouts, this must be an integer literal with the value (1 << _PyGC_PREV_SHIFT), in bytes.
* Opaque headers retain 4 for source compatibility.
*/
#define _PyObject_MIN_ALIGNMENT 4
// ABI3T test headers may poison Py_GIL_DISABLED.
#ifndef _Py_OPAQUE_PYOBJECT
# if !defined(Py_GIL_DISABLED) && SIZEOF_VOID_P > 4
# define _PyObject_MIN_ALIGNMENT 8
# else
# define _PyObject_MIN_ALIGNMENT 4
# endif
#else
# define _PyObject_MIN_ALIGNMENT 4
#endif

/* Nothing is actually declared to be a PyObject, but every pointer to
* a Python object can be cast to a PyObject*. This is inheritance built
Expand Down
14 changes: 9 additions & 5 deletions InternalDocs/garbage_collector.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -660,11 +660,15 @@ of `PyGC_Head` discussed in the `Memory layout and object structure`_ section:

- The `_gc_prev` field is normally used as the "previous" pointer to maintain the
doubly linked list but its lowest two bits are used to keep the flags
`PREV_MASK_COLLECTING` and `_PyGC_PREV_MASK_FINALIZED`. Between collections,
the only flag that can be present is `_PyGC_PREV_MASK_FINALIZED` that indicates
if an object has been already finalized. During collections `_gc_prev` is
temporarily used for storing a copy of the reference count (`gc_ref`), in
addition to two flags, and the GC linked list becomes a singly linked list until
`PREV_MASK_COLLECTING` and `_PyGC_PREV_MASK_FINALIZED`. On GIL builds where `SIZEOF_VOID_P > 4`,
bit 2 is also used for `_PyGC_PREV_MASK_PERM` to record permanent-generation
ownership, including weakrefs temporarily queued for callbacks. Unfreeze clears
this flag on objects in the permanent generation. Surviving queued weakrefs
have it cleared before moving to the old generation. Between collections,
`_PyGC_PREV_MASK_FINALIZED` indicates if an object has been already finalized,
and `_PyGC_PREV_MASK_PERM` can also remain set. During collections `_gc_prev`
is temporarily used for storing a copy of the reference count (`gc_ref`), in
addition to these flags, and the GC linked list becomes a singly linked list until
`_gc_prev` is restored.

- The `_gc_next` field is used as the "next" pointer to maintain the doubly linked
Expand Down
181 changes: 181 additions & 0 deletions Lib/test/test_gc.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,187 @@ def test_freeze(self):
gc.unfreeze()
self.assertEqual(gc.get_freeze_count(), 0)

@requires_gil_enabled("tests frozen objects in the generation-list collector")
def test_frozen_references_survive_full_collection(self):
"""Full collection preserves frozen ownership and reclaims unrelated cycles."""
gc.collect()
frozen = []
gc.freeze()
self.addCleanup(gc.unfreeze)

class Node:
"""A cycle-capable object whose lifetime can be observed."""

owned = Node()
owned.link = owned
owned_ref = weakref.ref(owned)
frozen.append(owned)

garbage = Node()
garbage.link = garbage
garbage_ref = weakref.ref(garbage)
del owned, garbage

gc.collect(2)
self.assertIsNotNone(owned_ref())
self.assertIsNone(garbage_ref())

frozen.clear()
gc.collect(2)
self.assertIsNone(owned_ref())

@requires_gil_enabled("tests repeated permanent-generation transitions")
@unittest.skipUnless(sys.maxsize > 2**32, "requires 64-bit GC header flags")
def test_repeated_freeze_and_unfreeze_preserve_cycles(self):
"""Repeated freezing preserves cycles and unfreezing restores eligibility."""
import_module("ctypes")
source = textwrap.dedent("""
import ctypes
import gc
import weakref

may_have_foreign_gc_objects = ctypes.pythonapi._PyInterpreterState_MayHaveForeignGcObjects
may_have_foreign_gc_objects.argtypes = []
may_have_foreign_gc_objects.restype = ctypes.c_bool

class Node:
"A cycle-capable object whose lifetime can be observed."

gc.disable()
first = Node()
first.link = first

pablogsal Oct 17, 2026 •
edited
Loading

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

I tried leaving the frozen flag set after unfreeze to see whether this test caught it. These separate self cycles still passed. Making first and second point at each other after the frozen setup left the objects alive and caught the missing collection. We should use that cycle here.

first_ref = weakref.ref(first)
gc.freeze()

second = Node()
second.link = second
second_ref = weakref.ref(second)
gc.freeze()

# The ordinary holder makes the fused visitor encounter both frozen objects.
holder = [first, second]
del first, second
assert ctypes.sizeof(ctypes.c_void_p) > 4
assert gc.get_freeze_count() > 0
assert not may_have_foreign_gc_objects()
gc.collect(2)
assert len(holder) == 2

gc.unfreeze()
del holder
gc.collect(2)
assert first_ref() is None
assert second_ref() is None
""")
assert_python_ok("-c", source)

@requires_gil_enabled("tests permanent weak-reference callback migration")
@unittest.skipUnless(sys.maxsize > 2**32, "requires 64-bit GC header flags")
def test_permanent_weakrefs_move_to_old_after_callbacks(self):
"""Surviving queued weakrefs enter the old generation with their marker cleared."""
import_module("ctypes")
source = textwrap.dedent("""
import ctypes
import gc
import sys
import weakref

generation = int(sys.argv[1])
callbacks = []

class Target:
"A cyclic referent used to queue a permanent weakref."

untrack = ctypes.pythonapi.PyObject_GC_UnTrack
untrack.argtypes = [ctypes.py_object]
untrack.restype = None
track = ctypes.pythonapi.PyObject_GC_Track
track.argtypes = [ctypes.py_object]
track.restype = None

def callback(reference):
"Retain the callback weakref for post-collection inspection."
callbacks.append(reference)

targets = [Target(), Target()]
for target in targets:
target.link = target
references = [weakref.ref(target, callback) for target in targets]
gc.freeze()

for reference in references:
previous = ctypes.c_size_t.from_address(id(reference) - ctypes.sizeof(ctypes.c_size_t))
assert previous.value & 4, hex(previous.value)
for target in targets:
untrack(target)
track(target)
del target, targets

gc.collect(generation)

assert len(callbacks) == 2, callbacks
assert all(reference() is None for reference in references)
old_generation = min(generation + 1, 2)
for reference in references:
previous = ctypes.c_size_t.from_address(id(reference) - ctypes.sizeof(ctypes.c_size_t))
assert previous.value & 4 == 0, hex(previous.value)
assert any(obj is reference for obj in gc.get_objects(old_generation))

gc.unfreeze()
callbacks.clear()
del references
gc.collect(2)
""")
for generation in (0, 2):
with self.subTest(generation=generation):
assert_python_ok("-c", source, str(generation))

@requires_gil_enabled("tests permanent weak-reference callback ownership")
@unittest.skipUnless(sys.maxsize > 2**32, "requires 64-bit GC header flags")
def test_permanent_weakref_callback_can_release_its_last_owner(self):
"""A permanent weakref can be freed when its callback drops its final owner."""
import_module("ctypes")
source = textwrap.dedent("""
import ctypes
import gc
import weakref

callbacks = []
owners = []

class Target:
"A cyclic referent used to queue a permanent weakref."

untrack = ctypes.pythonapi.PyObject_GC_UnTrack
untrack.argtypes = [ctypes.py_object]
untrack.restype = None
track = ctypes.pythonapi.PyObject_GC_Track
track.argtypes = [ctypes.py_object]
track.restype = None

def callback(reference):
"Release the final owner without retaining the weakref."
callbacks.append(len(callbacks))
owners.remove(reference)

targets = [Target(), Target()]
for target in targets:
target.link = target
owners.append(weakref.ref(target, callback))
gc.freeze()

for target in targets:
untrack(target)
track(target)
del target, targets
gc.collect(2)

assert callbacks == [0, 1], callbacks
assert owners == [], owners
gc.unfreeze()
""")
assert_python_ok("-c", source)

def test_get_objects(self):
gc.collect()
l = []
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow full garbage collections to combine reference passes after :func:`gc.freeze` on GIL builds with pointers larger than four bytes.
62 changes: 51 additions & 11 deletions Python/gc.c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ gc_is_collecting(PyGC_Head *g)
return (g->_gc_prev & PREV_MASK_COLLECTING) != 0;
}

#ifdef _PyGC_PREV_MASK_PERM
/* Test whether a GC header retains permanent-generation ownership.
*
* Arguments:
* - `gc`: the object header to inspect.
*
* Return true for permanent objects, including queued weakrefs temporarily
* outside the permanent list.
*/
static inline bool
gc_is_permanent(PyGC_Head *gc)
{
static_assert(_Alignof(PyGC_Head) >= (1 << _PyGC_PREV_SHIFT), "GC headers must satisfy their reserved-bit alignment");
static_assert(_Alignof(PyObject) >= (1 << _PyGC_PREV_SHIFT), "GC objects must satisfy their reserved-bit alignment");

return (gc->_gc_prev & _PyGC_PREV_MASK_PERM) != 0;
}
#endif

static inline void
gc_clear_collecting(PyGC_Head *g)
{
Expand Down Expand Up @@ -161,9 +180,10 @@ _gc_prev values

Between collections, _gc_prev is used for doubly linked list.

Lowest two bits of _gc_prev are used for flags.
Lowest bits of _gc_prev are used for flags.
PREV_MASK_COLLECTING is used only while collecting and cleared before GC ends
or _PyObject_GC_UNTRACK() is called.
_PyGC_PREV_MASK_PERM identifies objects owned by the permanent generation.

During a collection, _gc_prev is temporary used for gc_refs, and the gc list
is singly linked until _gc_prev is restored.
Expand Down Expand Up @@ -464,9 +484,8 @@ visit_decref(PyObject *op, void *parent)

/* Subtract a GC reference, initializing and marking the referent on first visit.
*
* This visitor is used only when every tracked mortal object it may reach
* belongs to the collection. Non-GC, untracked, and immortal referents remain
* unchanged.
* Every tracked mortal referent must belong to this collection or be marked permanent.
* Non-GC, untracked, immortal, and permanent referents remain unchanged.
*
* Arguments:
* - `op`: the object referenced by the traversed parent.
Expand All @@ -490,6 +509,11 @@ gc_decref_and_mark_collecting(PyObject *op, void *parent)
return 0;
}

#ifdef _PyGC_PREV_MASK_PERM
if (gc_is_permanent(gc)) {
return 0;
}
#endif
if (!_PyObject_GC_IS_TRACKED(op) || _Py_IsImmortal(op)) {
return 0;
}
Expand Down Expand Up @@ -560,7 +584,7 @@ subtract_refs(PyGC_Head *containers)
* Prerequisites:
* - This is the initial pass of a full collection, not a resurrection recheck.
* - `containers` is a valid list containing all ordinary generations with no masks set.
* - The permanent generation is empty.
* - Permanent objects are identifiable, or the permanent generation is empty.
* - `_PyInterpreterState_MayHaveForeignGcObjects()` is false.
*
* Arguments:
Expand Down Expand Up @@ -1086,7 +1110,10 @@ invoke_weakref_callbacks(PyGC_Head *wrcb_to_call, PyGC_Head *old)
*/
Py_DECREF(op);
if (wrcb_to_call->_gc_next == (uintptr_t)gc) {
/* object is still alive -- move it */
#ifdef _PyGC_PREV_MASK_PERM
// The old generation must not retain permanent markers.
gc->_gc_prev &= ~_PyGC_PREV_MASK_PERM;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

The weakref has already left the permanent generation when it enters wrcb_to_call. Why keep PERM set until after the callback? Clearing it when we queue the weakref would keep the flag tied to list membership.

#endif
gc_list_move(gc, old);
}
else {
Expand Down Expand Up @@ -1350,11 +1377,13 @@ deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable,
* refcount greater than 0 when all the references within the
* set are taken into account).
*/
// Lazy initialization can touch any tracked mortal referent. Use it only
// when all such objects belong to this initial full collection.
// Marked permanent objects are excluded from lazy initialization.
// Every other tracked mortal referent must belong to this initial full collection.
Py_ssize_t candidates;
if (full_collection_pass &&
#ifndef _PyGC_PREV_MASK_PERM
gc_list_is_empty(&get_gc_state()->permanent_generation.head) &&
#endif
!_PyInterpreterState_MayHaveForeignGcObjects()) {
candidates = update_and_subtract_refs(base, legacy_finalizer_count_out);
}
Expand Down Expand Up @@ -1986,7 +2015,13 @@ _PyGC_Freeze(PyInterpreterState *interp)
{
GCState *gcstate = &interp->gc;
for (int i = 0; i < NUM_GENERATIONS; ++i) {
gc_list_merge(GEN_HEAD(gcstate, i), &gcstate->permanent_generation.head);
PyGC_Head *generation = GEN_HEAD(gcstate, i);
#ifdef _PyGC_PREV_MASK_PERM
for (PyGC_Head *gc = GC_NEXT(generation); gc != generation; gc = GC_NEXT(gc)) {
gc->_gc_prev |= _PyGC_PREV_MASK_PERM;
}
#endif
gc_list_merge(generation, &gcstate->permanent_generation.head);
gcstate->generations[i].count = 0;
}
}
Expand All @@ -1995,8 +2030,13 @@ void
_PyGC_Unfreeze(PyInterpreterState *interp)
{
GCState *gcstate = &interp->gc;
gc_list_merge(&gcstate->permanent_generation.head,
GEN_HEAD(gcstate, NUM_GENERATIONS-1));
PyGC_Head *permanent = &gcstate->permanent_generation.head;
#ifdef _PyGC_PREV_MASK_PERM
for (PyGC_Head *gc = GC_NEXT(permanent); gc != permanent; gc = GC_NEXT(gc)) {
gc->_gc_prev &= ~_PyGC_PREV_MASK_PERM;
}
#endif
gc_list_merge(permanent, GEN_HEAD(gcstate, NUM_GENERATIONS-1));
}

Py_ssize_t
Expand Down
Loading

Back | FazBrowse Home | New Git URL