From abd738f2ae72fd5d12ff2b0f8bcec2011b927aed Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:17:48 -0400 Subject: [PATCH 1/9] gh-159437: Skip unnecessary GC finalizer scans --- Lib/test/test_gc.py | 103 +++++++++++++ ...-10-15-23-43-15.gh-issue-159437.LHuMLA.rst | 3 + Python/gc.c | 143 ++++++++++++------ 3 files changed, 204 insertions(+), 45 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 919100f2d5ce041..7c17879c8317e2d 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -330,6 +330,7 @@ def f(): self.assertEqual(gc.collect(), 1) def test_saveall(self): + self.addCleanup(gc.collect) # Verify that cyclic garbage like lists show up in gc.garbage if the # SAVEALL option is enabled. @@ -352,6 +353,31 @@ def test_saveall(self): self.assertEqual(len(gc.garbage), 1) obj = gc.garbage.pop() self.assertEqual(id(obj), id_L) + obj.clear() + + # A no-op generator finalizer must still set the finalized marker. + gen = (item for item in ()) + next(gen, None) + self.assertTrue(gc.is_tracked(gen)) + self.assertIsNone(gen.gi_frame) + self.assertFalse(gc.is_finalized(gen)) + L = [gen] + L.append(L) + id_L = id(L) + del gen + + try: + gc.set_debug(gc.DEBUG_SAVEALL) + del L + gc.collect() + saved = next(obj for obj in gc.garbage if id(obj) == id_L) + self.assertTrue(gc.is_finalized(saved[0])) + finally: + gc.set_debug(debug) + for obj in gc.garbage: + if id(obj) == id_L: + obj.clear() + gc.garbage.clear() def test_del(self): # __del__ methods can trigger collection, make this to happen @@ -658,6 +684,83 @@ def __del__(self): lazarus = storage.pop() self.assertTrue(gc.is_finalized(lazarus)) + def test_weakref_callback_installs_finalizer(self): + """A callback can make a finalizer eligible before the object is cleared.""" + self.addCleanup(gc.collect) + events = [] + + class C: + """A self-cycle whose weakref callback installs its finalizer.""" + __slots__ = ("cycle", "__weakref__") + + def finalize(obj): + """Record whether finalization sees the intact cycle.""" + events.append(("finalizer", getattr(obj, "cycle", None) is obj)) + + def callback(reference): + """Install the finalizer after observing the cleared weakref.""" + events.append(("callback", reference() is None)) + C.__del__ = finalize + + obj = C() + obj.cycle = obj + reference = weakref.ref(obj, callback) + gc.collect() + del obj + + self.assertEqual(gc.collect(), 1) + self.assertEqual(events, [("callback", True), ("finalizer", True)]) + self.assertIsNone(reference()) + + @requires_gil_enabled("tests the generation-list collector") + def test_debug_output_resurrects_without_callbacks(self): + """Debug output can resurrect garbage without pending callbacks.""" + was_enabled = gc.isenabled() + gc.disable() + self.addCleanup(gc.enable if was_enabled else gc.disable) + self.addCleanup(gc.collect) + resurrected = [] + + class C: + """An object with a cycle and no finalizer.""" + __slots__ = ("cycle", "__weakref__") + + class Stderr: + """Retain the target when collection reports it as collectable.""" + + def write(self, text): + """Retain the target on collectable debug output. + + text: Debug text written by the collector. + Returns: Number of characters accepted. + """ + if text.startswith("gc: collectable") and not resurrected: + target = reference() + if target is not None: + resurrected.append(target) + return len(text) + + writer = Stderr() + gc.collect() + obj = C() + obj.cycle = obj + reference = weakref.ref(obj) + del obj + + debug = gc.get_debug() + try: + with unittest.mock.patch.object(sys, "stderr", writer): + gc.set_debug(gc.DEBUG_COLLECTABLE) + self.assertEqual(gc.collect(), 0) + self.assertEqual(len(resurrected), 1) + self.assertIs(reference(), resurrected[0]) + self.assertIs(resurrected[0].cycle, resurrected[0]) + finally: + gc.set_debug(debug) + for obj in resurrected: + obj.cycle = None + resurrected.clear() + def test_bug1055820b(self): # Corresponds to temp2b.py in the bug report. diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst new file mode 100644 index 000000000000000..0052c33ea970de9 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-15-23-43-15.gh-issue-159437.LHuMLA.rst @@ -0,0 +1,3 @@ +With the GIL enabled, garbage collection skips the finalization scan when no +finalizers or weakref callbacks are pending and debug output for collectable +objects is disabled. diff --git a/Python/gc.c b/Python/gc.c index 5dc79e20191ebc7..e7b6ba2f4dad4a8 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -756,7 +756,7 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) } } -/* Handle weakref callbacks. Note that it's possible for such weakrefs to be +/* Prepare weakref callbacks. Note that it's possible for such weakrefs to be * outside the unreachable set -- indeed, those are precisely the weakrefs * whose callbacks must be invoked. See gc_weakref.txt for overview & some * details. @@ -794,17 +794,20 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) * or missing `tp_traverse` methods. When that object goes away, the callback * for weakref can be executed and that could reveal unreachable objects to * Python-level code. See bpo-38006 as an example bug. + * + * Arguments: + * - `unreachable`: normalized collecting list to inspect. + * - `wrcb_to_call`: initialized list receiving weakrefs whose callbacks are safe to invoke. + * + * Return whether any finalizer is pending or a weakref callback is queued. + * This function does not invoke callbacks. */ -static int -handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, - bool *needs_resurrection_check) +Py_NO_INLINE static bool +prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) { PyGC_Head *gc; - PyGC_Head wrcb_to_call; /* weakrefs with callbacks to call */ PyGC_Head *next; - int num_freed = 0; - - gc_list_init(&wrcb_to_call); + bool has_pending_finalizer = false; /* Find all weakrefs with callbacks and move into `wrcb_to_call` if the * callback needs to be invoked. We make another pass over wrcb_to_call, @@ -814,9 +817,13 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, PyWeakReference **wrlist; PyObject *op = FROM_GC(gc); + PyTypeObject *type = Py_TYPE(op); + if (type->tp_finalize != NULL && !has_pending_finalizer) { + has_pending_finalizer = !_PyGC_FINALIZED(op); + } next = GC_NEXT(gc); - if (! _PyType_SUPPORTS_WEAKREFS(Py_TYPE(op))) { + if (! _PyType_SUPPORTS_WEAKREFS(type)) { continue; } @@ -891,21 +898,35 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, PyGC_Head *wrasgc = AS_GC((PyObject *)wr); // wrasgc is reachable, but next isn't, so they can't be the same _PyObject_ASSERT((PyObject *)wr, wrasgc != next); - gc_list_move(wrasgc, &wrcb_to_call); + gc_list_move(wrasgc, wrcb_to_call); } } + return has_pending_finalizer || !gc_list_is_empty(wrcb_to_call); +} + +/* Invoke queued weakref callbacks and move surviving weakrefs to `old`. + * + * Arguments: + * - `wrcb_to_call`: initialized queue to drain, with a temporary reference to each weakref. + * - `old`: generation receiving weakrefs that survive callback invocation. + * + * Return the number of weakrefs reclaimed while callbacks ran. + */ +static int +invoke_weakref_callbacks(PyGC_Head *wrcb_to_call, PyGC_Head *old) +{ + PyGC_Head *gc; + int num_freed = 0; + /* Invoke the callbacks we decided to honor. It's safe to invoke them * because they can't reference unreachable objects. */ - if (!*needs_resurrection_check) { - *needs_resurrection_check = !gc_list_is_empty(&wrcb_to_call); - } - while (! gc_list_is_empty(&wrcb_to_call)) { + while (! gc_list_is_empty(wrcb_to_call)) { PyObject *temp; PyObject *callback; - gc = (PyGC_Head*)wrcb_to_call._gc_next; + gc = (PyGC_Head*)wrcb_to_call->_gc_next; PyObject *op = FROM_GC(gc); _PyObject_ASSERT(op, PyWeakref_Check(op)); PyWeakReference *wr = (PyWeakReference *)op; @@ -934,7 +955,7 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, * ours). */ Py_DECREF(op); - if (wrcb_to_call._gc_next == (uintptr_t)gc) { + if (wrcb_to_call->_gc_next == (uintptr_t)gc) { /* object is still alive -- move it */ gc_list_move(gc, old); } @@ -948,7 +969,7 @@ handle_weakref_callbacks(PyGC_Head *unreachable, PyGC_Head *old, /* Clear all weakrefs to unreachable objects. When this returns, no object in * `unreachable` is weakly referenced anymore. See the comments above - * handle_weakref_callbacks() for why these weakrefs need to be cleared. + * prepare_callbacks() for why these weakrefs need to be cleared. */ static void clear_weakrefs(PyGC_Head *unreachable) @@ -964,7 +985,7 @@ clear_weakrefs(PyGC_Head *unreachable) if (PyWeakref_Check(op)) { /* A weakref inside the unreachable set is always cleared. See - * the comments above handle_weakref_callbacks() for why these + * the comments above prepare_callbacks() for why these * must be cleared. */ _PyWeakref_ClearRef((PyWeakReference *)op); @@ -1036,13 +1057,14 @@ handle_legacy_finalizers(PyThreadState *tstate, /* Run first-time finalizers (if any) on all the objects in collectable. * Note that this may remove some (or even all) of the objects from the * list, due to refcounts falling to 0. + * Return whether a finalizer may have run Python. */ -static void -finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable, - bool *needs_resurrection_check) +static bool +finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable) { destructor finalize; PyGC_Head seen; + bool needs_resurrection_check = false; /* While we're going through the loop, `finalize(op)` may cause op, or * other objects, to be reclaimed via refcounts falling to zero. So @@ -1062,7 +1084,7 @@ finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable, (finalize = Py_TYPE(op)->tp_finalize) != NULL) { if (!_PyGen_FinalizerIsNoop(op)) { - *needs_resurrection_check = true; + needs_resurrection_check = true; } _PyGC_SET_FINALIZED(op); Py_INCREF(op); @@ -1072,6 +1094,7 @@ finalize_garbage(PyThreadState *tstate, PyGC_Head *collectable, } } gc_list_merge(&seen, collectable); + return needs_resurrection_check; } /* Break reference cycles by clearing the containers involved. This is @@ -1253,6 +1276,43 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, } +/* Invoke weakref callbacks and finalizers, then handle possible resurrection. + * + * Arguments: + * - `tstate`: the thread state used when invoking finalizers. + * - `collectable`: normalized collecting list whose finalizers may run. + * - `still_unreachable`: distinct, uninitialized list head for remaining garbage. + * - `old`: generation receiving surviving weakrefs and resurrected objects. + * - `wrcb_to_call`: initialized, possibly empty list of callback-bearing weakrefs to drain. + * - `collected_count`: count incremented by weakrefs reclaimed while callbacks run. + * - `needs_resurrection_check`: whether earlier work may have run Python. + * + * Return whether resurrection analysis ran. If true, `collectable` must not be + * used again and `still_unreachable` contains the remaining garbage. Otherwise, + * `collectable` remains selected and `still_unreachable` is untouched. + */ +Py_NO_INLINE static bool +handle_callbacks(PyThreadState *tstate, PyGC_Head *collectable, + PyGC_Head *still_unreachable, PyGC_Head *old, + PyGC_Head *wrcb_to_call, Py_ssize_t *collected_count, + bool needs_resurrection_check) +{ + bool weakref_callbacks_invoked = !gc_list_is_empty(wrcb_to_call); + *collected_count += invoke_weakref_callbacks(wrcb_to_call, old); + needs_resurrection_check |= weakref_callbacks_invoked; + validate_list(old, collecting_clear_unreachable_clear); + validate_list(collectable, collecting_set_unreachable_clear); + + // Weakref callbacks can make finalizers newly eligible after the initial + // scan, so we must recheck even when we previously found no pending finalizers. + needs_resurrection_check |= finalize_garbage(tstate, collectable); + if (needs_resurrection_check) { + handle_resurrected_objects(collectable, still_unreachable, old); + } + return needs_resurrection_check; +} + + /* Invoke progress callbacks to notify clients that garbage collection * is starting or stopping */ @@ -1563,28 +1623,21 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) } } - /* Clear weakrefs and invoke callbacks as necessary. */ - stats.collected += handle_weakref_callbacks(&unreachable, old, - &needs_resurrection_check); - validate_list(old, collecting_clear_unreachable_clear); - validate_list(&unreachable, collecting_set_unreachable_clear); - - /* Call tp_finalize on objects which have one. */ - finalize_garbage(tstate, &unreachable, &needs_resurrection_check); + PyGC_Head wrcb_to_call; + gc_list_init(&wrcb_to_call); + bool has_pending_callbacks = prepare_callbacks(&unreachable, &wrcb_to_call); + has_pending_callbacks |= needs_resurrection_check; - /* Handle any objects that may have resurrected after the call - * to 'finalize_garbage' and continue the collection with the - * objects that are still unreachable */ PyGC_Head final_unreachable; - if (needs_resurrection_check) { - handle_resurrected_objects(&unreachable, &final_unreachable, old); - } - else { - /* No Python code ran during finalization, so the original - * unreachable set is still valid. */ - gc_list_init(&final_unreachable); - gc_list_merge(&unreachable, &final_unreachable); + PyGC_Head *collectable = &unreachable; + if (has_pending_callbacks) { + bool resurrection_checked = handle_callbacks(tstate, collectable, &final_unreachable, old, &wrcb_to_call, &stats.collected, needs_resurrection_check); + if (resurrection_checked) { + collectable = &final_unreachable; + } } + validate_list(old, collecting_clear_unreachable_clear); + validate_list(collectable, collecting_set_unreachable_clear); /* Clear weakrefs to objects in the unreachable set. No Python-level * code must be allowed to access those unreachable objects. During @@ -1592,14 +1645,14 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * and create new weakrefs. If those weakrefs were not cleared, they * could reveal unreachable objects. Callbacks are not executed. */ - clear_weakrefs(&final_unreachable); + clear_weakrefs(collectable); - /* Call tp_clear on objects in the final_unreachable set. This will cause + /* Call tp_clear on objects in the collectable set. This will cause * the reference cycles to be broken. It may also cause some objects * in finalizers to be freed. */ - stats.collected += gc_list_size(&final_unreachable); - delete_garbage(tstate, gcstate, &final_unreachable, old); + stats.collected += gc_list_size(collectable); + delete_garbage(tstate, gcstate, collectable, old); /* Collect statistics on uncollectable objects found and print * debugging information. */ From 29cee063425c5a198771e2f8120ff28f691f15fa Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:50:58 -0400 Subject: [PATCH 2/9] gh-159437: Count garbage while clearing weakrefs --- ...6-10-14-21-44-05.gh-issue-159437.count1.rst | 1 + Python/gc.c | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst new file mode 100644 index 000000000000000..a98b6aee7ef4ff2 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-14-21-44-05.gh-issue-159437.count1.rst @@ -0,0 +1 @@ +Count collectable objects while clearing weak references, avoiding a separate pass during garbage collection. diff --git a/Python/gc.c b/Python/gc.c index e7b6ba2f4dad4a8..dd6d052b9fc8f67 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -967,21 +967,26 @@ invoke_weakref_callbacks(PyGC_Head *wrcb_to_call, PyGC_Head *old) return num_freed; } -/* Clear all weakrefs to unreachable objects. When this returns, no object in - * `unreachable` is weakly referenced anymore. See the comments above - * prepare_callbacks() for why these weakrefs need to be cleared. +/* Clear all weakrefs to unreachable objects without invoking callbacks. + * When this returns, no object in `unreachable` is weakly referenced anymore. + * See the comments above prepare_callbacks() for why these weakrefs need to be cleared. + * + * `unreachable`: Final collectable list; membership is unchanged. + * Return: Number of objects in `unreachable`. */ -static void +static Py_ssize_t clear_weakrefs(PyGC_Head *unreachable) { PyGC_Head *gc; PyGC_Head *next; + Py_ssize_t count = 0; for (gc = GC_NEXT(unreachable); gc != unreachable; gc = next) { PyWeakReference **wrlist; PyObject *op = FROM_GC(gc); next = GC_NEXT(gc); + count++; if (PyWeakref_Check(op)) { /* A weakref inside the unreachable set is always cleared. See @@ -1015,6 +1020,8 @@ clear_weakrefs(PyGC_Head *unreachable) _PyObject_ASSERT((PyObject *)wr, wr->wr_object == Py_None); } } + + return count; } static void @@ -1645,13 +1652,12 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * and create new weakrefs. If those weakrefs were not cleared, they * could reveal unreachable objects. Callbacks are not executed. */ - clear_weakrefs(collectable); + stats.collected += clear_weakrefs(collectable); /* Call tp_clear on objects in the collectable set. This will cause * the reference cycles to be broken. It may also cause some objects * in finalizers to be freed. */ - stats.collected += gc_list_size(collectable); delete_garbage(tstate, gcstate, collectable, old); /* Collect statistics on uncollectable objects found and print From 768f2d62d9fecc84bcd4c0688a4feac30a950901 Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Sun, 18 Oct 2026 21:37:07 -0400 Subject: [PATCH 3/9] gh-159437: Align release build functions to 64-byte boundaries --- Makefile.pre.in | 8 ++- ...10-18-21-30-00.gh-issue-159437.align64.rst | 1 + configure | 71 +++++++++++++++++++ configure.ac | 16 +++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Build/2026-10-18-21-30-00.gh-issue-159437.align64.rst diff --git a/Makefile.pre.in b/Makefile.pre.in index 308fb964a0cd57e..6835c9fb4f048c2 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -102,7 +102,13 @@ CONFIGURE_LDFLAGS= @LDFLAGS@ # command line to append to these values without stomping the pre-set # values. PY_CFLAGS= $(BASECFLAGS) $(OPT) $(CONFIGURE_CFLAGS) $(CFLAGS) $(EXTRA_CFLAGS) -PY_CFLAGS_NODIST=$(CONFIGURE_CFLAGS_NODIST) $(CFLAGS_NODIST) -I$(srcdir)/Include/internal -I$(srcdir)/Include/internal/mimalloc +# Configure validates function alignment once, but make-time CFLAGS can still +# select a size-optimized build after configure has completed. +PY_ALIGN_FUNCTIONS_CFLAGS=@FUNCTION_ALIGNMENT_CFLAGS@ +ifneq ($(filter -Os -Oz,$(PY_CFLAGS) $(CONFIGURE_CFLAGS_NODIST) $(CFLAGS_NODIST)),) +PY_ALIGN_FUNCTIONS_CFLAGS= +endif +PY_CFLAGS_NODIST=$(CONFIGURE_CFLAGS_NODIST) $(CFLAGS_NODIST) $(PY_ALIGN_FUNCTIONS_CFLAGS) -I$(srcdir)/Include/internal -I$(srcdir)/Include/internal/mimalloc # Both CPPFLAGS and LDFLAGS need to contain the shell's value for setup.py to # be able to build extension modules using the directories specified in the # environment variables diff --git a/Misc/NEWS.d/next/Build/2026-10-18-21-30-00.gh-issue-159437.align64.rst b/Misc/NEWS.d/next/Build/2026-10-18-21-30-00.gh-issue-159437.align64.rst new file mode 100644 index 000000000000000..023fe865eaf7672 --- /dev/null +++ b/Misc/NEWS.d/next/Build/2026-10-18-21-30-00.gh-issue-159437.align64.rst @@ -0,0 +1 @@ +Request 64-byte function alignment for performance-oriented Linux and macOS builds. This can reduce performance variation caused by changes to the size of preceding functions. diff --git a/configure b/configure index 217d8e135600e16..c0fd0422bfd8f43 100755 --- a/configure +++ b/configure @@ -922,6 +922,7 @@ WASM_ASSETS_DIR LDFLAGS_NOLTO EXE_LDFLAGS LDFLAGS_NODIST +FUNCTION_ALIGNMENT_CFLAGS CFLAGS_NODIST BASECFLAGS CFLAGS_ALIASING @@ -9960,6 +9961,75 @@ then esac fi +# Keep performance-oriented release builds aligned on validated platforms +# without changing size-optimized, mobile, or third-party extension builds. +FUNCTION_ALIGNMENT_CFLAGS= +case $host in #( + *-*-linux-android*) : + ;; #( + *-*-linux*|*-apple-darwin*) : + + if test "x$Py_DEBUG" = xtrue +then : + +else case e in #( + e) + case " $OPT $CFLAGS " in #( + *" -Os "*|*" -Oz "*) : + ;; #( + *) : + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking whether C compiler accepts -falign-functions=64" >&5 +printf %s "checking whether C compiler accepts -falign-functions=64... " >&6; } +if test ${ax_cv_check_cflags__Werror__falign_functions_64+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) + ax_check_save_flags=$CFLAGS + CFLAGS="$CFLAGS -Werror -falign-functions=64" + cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +int +main (void) +{ + + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ax_cv_check_cflags__Werror__falign_functions_64=yes +else case e in #( + e) ax_cv_check_cflags__Werror__falign_functions_64=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + CFLAGS=$ax_check_save_flags ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ax_cv_check_cflags__Werror__falign_functions_64" >&5 +printf "%s\n" "$ax_cv_check_cflags__Werror__falign_functions_64" >&6; } +if test "x$ax_cv_check_cflags__Werror__falign_functions_64" = xyes +then : + + FUNCTION_ALIGNMENT_CFLAGS="-falign-functions=64" + +else case e in #( + e) : ;; +esac +fi + ;; +esac + ;; +esac +fi + ;; #( + *) : + ;; +esac + # WASM flags case $ac_sys_system in #( Emscripten) : @@ -10131,6 +10201,7 @@ esac + # The -arch flags for universal builds on macOS UNIVERSAL_ARCH_FLAGS= diff --git a/configure.ac b/configure.ac index ca804d6448810d2..f1714aa2afed88f 100644 --- a/configure.ac +++ b/configure.ac @@ -2430,6 +2430,21 @@ then esac fi +# Keep performance-oriented release builds aligned on validated platforms +# without changing size-optimized, mobile, or third-party extension builds. +FUNCTION_ALIGNMENT_CFLAGS= +AS_CASE([$host], + [*-*-linux-android*], [], + [*-*-linux*|*-apple-darwin*], [ + AS_VAR_IF([Py_DEBUG], [true], [], [ + AS_CASE([" $OPT $CFLAGS "], + [*" -Os "*|*" -Oz "*], [], + [AX_CHECK_COMPILE_FLAG([-falign-functions=64], [ + FUNCTION_ALIGNMENT_CFLAGS="-falign-functions=64" + ], [], [-Werror])]) + ]) + ]) + # WASM flags AS_CASE([$ac_sys_system], [Emscripten], [ @@ -2558,6 +2573,7 @@ AS_CASE([$enable_wasm_dynamic_linking], AC_SUBST([BASECFLAGS]) AC_SUBST([CFLAGS_NODIST]) +AC_SUBST([FUNCTION_ALIGNMENT_CFLAGS]) AC_SUBST([LDFLAGS_NODIST]) AC_SUBST([EXE_LDFLAGS]) AC_SUBST([LDFLAGS_NOLTO]) From d47b5811aebca236458557f76679f0d09b2f4745 Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:58:00 -0400 Subject: [PATCH 4/9] gh-159437: Skip unnecessary late weakref cleanup Count the stable unreachable set while preparing callbacks and retain a conservative cleanup flag. Reuse the count only when late weakref clearing has no work, and force fresh cleanup and counting after resurrection analysis. Add a regression for a callback-free weakref observed when clearing a young cycle releases an older finalizer. --- Lib/test/test_gc.py | 30 ++++++++++++++++++ ...-10-16-00-00-00.gh-issue-159437.weakrf.rst | 1 + Python/gc.c | 31 ++++++++++++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 7c17879c8317e2d..e43ab28eb807170 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -1196,6 +1196,36 @@ def getstats(): gc.enable() + @requires_gil_enabled("tests the generation-list collector") + def test_callback_free_weakref_clear_without_pending_callbacks(self): + """Clear callback-free weakrefs before tp_clear can expose garbage.""" + was_enabled = gc.isenabled() + gc.disable() + self.addCleanup(gc.enable if was_enabled else gc.disable) + self.addCleanup(gc.collect) + observed = [] + self.addCleanup(observed.clear) + + class Observer: + """Inspect a young target when its clearing releases this object.""" + + def __del__(self): + """Record whether the weakref still exposes the target.""" + observed.append(reference()) + + observer = Observer() + gc.collect() + # The older observer does not open the callback path for generation 0. + # Clearing the target releases it, so the weakref must already be dead. + target = C1055820(0) + target.observer = observer + reference = weakref.ref(target) + del observer, target + + self.assertEqual(gc.collect(0), 1) + self.assertEqual(observed, [None]) + self.assertIsNone(reference()) + @unittest.skipIf(ContainerNoGC is None, 'requires ContainerNoGC extension type') def test_trash_weakref_clear(self): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst new file mode 100644 index 000000000000000..0dbba4740256a4a --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-00-00-00.gh-issue-159437.weakrf.rst @@ -0,0 +1 @@ +Garbage collection in builds with the GIL now reuses the initial garbage count and skips late weakref cleanup when no weakrefs need clearing and the unreachable set remains valid. diff --git a/Python/gc.c b/Python/gc.c index dd6d052b9fc8f67..4ffa572b723ced7 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -798,16 +798,22 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) * Arguments: * - `unreachable`: normalized collecting list to inspect. * - `wrcb_to_call`: initialized list receiving weakrefs whose callbacks are safe to invoke. + * - `collectable_count_out`: receives the number of objects in `unreachable`. + * - `needs_weakref_cleanup_out`: receives whether garbage weakrefs or remaining incoming weakrefs may need cleanup. * * Return whether any finalizer is pending or a weakref callback is queued. - * This function does not invoke callbacks. + * This function does not invoke callbacks. The count and cleanup decision + * remain valid only while the unreachable set and its weakrefs cannot change. */ Py_NO_INLINE static bool -prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) +prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call, + Py_ssize_t *collectable_count_out, bool *needs_weakref_cleanup_out) { PyGC_Head *gc; PyGC_Head *next; + Py_ssize_t count = 0; bool has_pending_finalizer = false; + bool needs_weakref_cleanup = false; /* Find all weakrefs with callbacks and move into `wrcb_to_call` if the * callback needs to be invoked. We make another pass over wrcb_to_call, @@ -818,11 +824,16 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) PyObject *op = FROM_GC(gc); PyTypeObject *type = Py_TYPE(op); + count++; if (type->tp_finalize != NULL && !has_pending_finalizer) { has_pending_finalizer = !_PyGC_FINALIZED(op); } next = GC_NEXT(gc); + // Garbage weakrefs need clearing even if their referents are outside + // this collection (bpo-38006). + needs_weakref_cleanup |= PyWeakref_Check(op); + if (! _PyType_SUPPORTS_WEAKREFS(type)) { continue; } @@ -900,8 +911,12 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call) _PyObject_ASSERT((PyObject *)wr, wrasgc != next); gc_list_move(wrasgc, wrcb_to_call); } + // Callback-free weakrefs remain linked until finalization. + needs_weakref_cleanup |= *wrlist != NULL; } + *collectable_count_out = count; + *needs_weakref_cleanup_out = needs_weakref_cleanup; return has_pending_finalizer || !gc_list_is_empty(wrcb_to_call); } @@ -1632,7 +1647,9 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) PyGC_Head wrcb_to_call; gc_list_init(&wrcb_to_call); - bool has_pending_callbacks = prepare_callbacks(&unreachable, &wrcb_to_call); + Py_ssize_t collectable_count; + bool needs_weakref_cleanup; + bool has_pending_callbacks = prepare_callbacks(&unreachable, &wrcb_to_call, &collectable_count, &needs_weakref_cleanup); has_pending_callbacks |= needs_resurrection_check; PyGC_Head final_unreachable; @@ -1641,6 +1658,9 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) bool resurrection_checked = handle_callbacks(tstate, collectable, &final_unreachable, old, &wrcb_to_call, &stats.collected, needs_resurrection_check); if (resurrection_checked) { collectable = &final_unreachable; + // Python code may have changed the garbage set or created weakrefs. + // Refresh both cleanup and counting. + needs_weakref_cleanup = true; } } validate_list(old, collecting_clear_unreachable_clear); @@ -1652,7 +1672,10 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * and create new weakrefs. If those weakrefs were not cleared, they * could reveal unreachable objects. Callbacks are not executed. */ - stats.collected += clear_weakrefs(collectable); + if (needs_weakref_cleanup) { + collectable_count = clear_weakrefs(collectable); + } + stats.collected += collectable_count; /* Call tp_clear on objects in the collectable set. This will cause * the reference cycles to be broken. It may also cause some objects From 0c2699e4c22883d49629b341a8a6a0a00908b46a Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 01:21:03 -0400 Subject: [PATCH 5/9] gh-159437: Combine GC reference passes for full collections --- Include/internal/pycore_pystate.h | 12 ++ Lib/test/test_gc.py | 48 ++++++ Lib/test/test_gdb/test_backtrace.py | 10 +- ...-10-16-01-07-10.gh-issue-159437.reffus.rst | 1 + Programs/_testembed.c | 16 ++ Python/gc.c | 141 ++++++++++++++++-- Python/pystate.c | 14 ++ Tools/c-analyzer/cpython/ignored.tsv | 4 + 8 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-07-10.gh-issue-159437.reffus.rst diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index 16f876140020bb7..e838effc07c5cd3 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -62,6 +62,18 @@ _Py_IsMainInterpreter(PyInterpreterState *interp) return (interp == _PyInterpreterState_Main()); } +/* Return whether tracked objects may belong to another interpreter lifetime. + * + * Result: + * - false before any subinterpreter reservation or runtime reinitialization. + * - true after either event, including across Py_Finalize()/Py_Initialize(). + * + * Extension-owned objects may outlive the interpreter whose GC list held them, + * so callers must not assume every tracked object belongs to the current + * runtime when this returns true. + */ +PyAPI_FUNC(bool) _PyInterpreterState_MayHaveForeignGcObjects(void); + extern int _Py_IsMainInterpreterFinalizing(PyInterpreterState *interp); // Export for _interpreters module. diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index e43ab28eb807170..6424fcc5bdd3571 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -1422,6 +1422,54 @@ def test_heap_size(self): del l self.assertEqual(count, _testinternalcapi.get_tracked_heap_size()) + @requires_gil_enabled("tests fused full-collection reference accounting") + @unittest.skipIf(_testinternalcapi is None, "requires _testinternalcapi") + def test_full_collection_preserves_refs_while_untracking_immortal(self): + """Untracking an immortal preserves adjacent candidates and heap accounting.""" + import_module("ctypes") + source = textwrap.dedent(""" + import ctypes + import gc + import _testinternalcapi + + set_immortal = ctypes.pythonapi._Py_SetImmortal + set_immortal.argtypes = [ctypes.py_object] + set_immortal.restype = None + track = ctypes.pythonapi.PyObject_GC_Track + track.argtypes = [ctypes.py_object] + track.restype = None + may_have_foreign_gc_objects = ctypes.pythonapi._PyInterpreterState_MayHaveForeignGcObjects + may_have_foreign_gc_objects.argtypes = [] + may_have_foreign_gc_objects.restype = ctypes.c_bool + + gc.disable() + gc.collect(2) + candidates_before = gc.get_stats()[2]["candidates"] + parent = [] + middle = [] + set_immortal(middle) + track(middle) + child = [] + parent.append(child) + child.append(parent) + + # Traversing parent initializes child before the intervening + # immortal is removed from the forward chain. + del parent, child + assert gc.get_freeze_count() == 0 + assert not may_have_foreign_gc_objects() + # Every tracked object except middle is a candidate here. + expected_candidates = _testinternalcapi.get_tracked_heap_size() - 1 + assert gc.collect(2) == 2 + assert not gc.is_tracked(middle) + assert gc.get_stats()[2]["candidates"] - candidates_before == expected_candidates + + # get_objects() omits the result list itself. + objects = gc.get_objects() + assert _testinternalcapi.get_tracked_heap_size() == len(objects) + 1 + """) + assert_python_ok("-c", source) + class GCCallbackTests(unittest.TestCase): def setUp(self): diff --git a/Lib/test/test_gdb/test_backtrace.py b/Lib/test/test_gdb/test_backtrace.py index 714853c7b4732d9..b975ced3574dd56 100644 --- a/Lib/test/test_gdb/test_backtrace.py +++ b/Lib/test/test_gdb/test_backtrace.py @@ -97,15 +97,21 @@ def test_gc(self): 'def bar():\n' ' foo()\n' 'bar()\n') + # Full GIL collections may skip update_refs(). Stop at a visible phase + # after the collector marks collection active. + if support.Py_GIL_DISABLED: + gc_breakpoint = 'update_refs' + else: + gc_breakpoint = 'prepare_callbacks' # Verify with "py-bt": gdb_output = self.get_stack_trace(cmd, - cmds_after_breakpoint=['break update_refs', 'continue', 'py-bt'], + cmds_after_breakpoint=[f'break {gc_breakpoint}', 'continue', 'py-bt'], ) self.assertIn('Garbage-collecting', gdb_output) # Verify with "py-bt-full": gdb_output = self.get_stack_trace(cmd, - cmds_after_breakpoint=['break update_refs', 'continue', 'py-bt-full'], + cmds_after_breakpoint=[f'break {gc_breakpoint}', 'continue', 'py-bt-full'], ) self.assertIn('Garbage-collecting', gdb_output) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-07-10.gh-issue-159437.reffus.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-07-10.gh-issue-159437.reffus.rst new file mode 100644 index 000000000000000..ee5b07d1ce3dac1 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-07-10.gh-issue-159437.reffus.rst @@ -0,0 +1 @@ +With the GIL enabled, combine reference initialization and subtraction in the initial pass of a full collection when the permanent generation is empty and tracked objects cannot belong to another interpreter lifetime. diff --git a/Programs/_testembed.c b/Programs/_testembed.c index 71bc37194e4b41c..c1cf1c1fff0ea61 100644 --- a/Programs/_testembed.c +++ b/Programs/_testembed.c @@ -191,6 +191,10 @@ static int test_repeated_init_and_subinterpreters(void) for (int i=1; i <= INIT_LOOPS; i++) { printf("--- Pass %d ---\n", i); _testembed_initialize(); + if (i > 1 && !_PyInterpreterState_MayHaveForeignGcObjects()) { + error("foreign GC object risk was reset"); + return 1; + } mainstate = PyThreadState_Get(); PyEval_ReleaseThread(mainstate); @@ -204,6 +208,10 @@ static int test_repeated_init_and_subinterpreters(void) print_subinterp(); Py_EndInterpreter(substate); } + if (!_PyInterpreterState_MayHaveForeignGcObjects()) { + error("subinterpreter lifetime did not record foreign GC object risk"); + return 1; + } PyThreadState_Swap(mainstate); print_subinterp(); @@ -279,6 +287,14 @@ static int test_repeated_simple_init(void) fflush(stderr); _testembed_initialize(); + if (i == 1 && _PyInterpreterState_MayHaveForeignGcObjects()) { + error("initial main interpreter may have foreign GC objects"); + return 1; + } + if (i > 1 && !_PyInterpreterState_MayHaveForeignGcObjects()) { + error("runtime reinitialization lost foreign GC object risk"); + return 1; + } Py_Finalize(); printf("Finalized\n"); // Give test_embed some output to check } diff --git a/Python/gc.c b/Python/gc.c index 4ffa572b723ced7..77122f62a41481f 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -29,9 +29,8 @@ typedef struct _gc_runtime_state GCState; #define GC_NEXT _PyGCHead_NEXT #define GC_PREV _PyGCHead_PREV -// update_refs() set this bit for all objects in current generation. -// subtract_refs() and move_unreachable() uses this to distinguish -// visited object is in GCing or not. +// Reference initialization marks objects in the current generation. +// Reference subtraction and reachability use this bit to recognize them. // // move_unreachable() removes this flag from reachable objects. // Only unreachable objects have this flag. @@ -168,15 +167,14 @@ During a collection, _gc_prev is temporary used for gc_refs, and the gc list is singly linked until _gc_prev is restored. gc_refs - At the start of a collection, update_refs() copies the true refcount - to gc_refs, for each object in the generation being collected. - subtract_refs() then adjusts gc_refs so that it equals the number of - times an object is referenced directly from outside the generation - being collected. + The true refcount is copied to gc_refs and reduced by references from + objects in the generation being collected. The remaining count is the + number of direct references from outside that generation. Full collections + may initialize and subtract these counts in one traversal. PREV_MASK_COLLECTING - Objects in generation being collected are marked PREV_MASK_COLLECTING in - update_refs(). + Reference initialization marks objects in the generation being collected + with PREV_MASK_COLLECTING. _gc_next values @@ -456,6 +454,43 @@ visit_decref(PyObject *op, void *parent) return 0; } +/* 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. + * + * Arguments: + * - `op`: the object referenced by the traversed parent. + * - `parent`: the traversed owner used by debug reference validation. + * + * Return zero, as required by traversal callbacks. + */ +static int +gc_decref_and_mark_collecting(PyObject *op, void *parent) +{ + OBJECT_STAT_INC(object_visits); + _PyObject_ASSERT(_PyObject_CAST(parent), !_PyObject_IsFreed(op)); + + if (!_PyObject_IS_GC(op)) { + return 0; + } + + PyGC_Head *gc = AS_GC(op); + if (gc_is_collecting(gc)) { + gc_decref(gc); + return 0; + } + + if (!_PyObject_GC_IS_TRACKED(op) || _Py_IsImmortal(op)) { + return 0; + } + Py_ssize_t refs = Py_REFCNT(op); + _PyObject_ASSERT(op, refs != 0); + gc_reset_refs(gc, refs - 1); + return 0; +} + int _PyGC_VisitStackRef(_PyStackRef *ref, visitproc visit, void *arg) { @@ -463,7 +498,8 @@ _PyGC_VisitStackRef(_PyStackRef *ref, visitproc visit, void *arg) // refcounts when computing the incoming references, but otherwise treat // them like normal. assert(!PyStackRef_IsTaggedInt(*ref)); - if (!PyStackRef_RefcountOnObject(*ref) && (visit == visit_decref)) { + if (!PyStackRef_RefcountOnObject(*ref) && + (visit == visit_decref || visit == gc_decref_and_mark_collecting)) { return 0; } Py_VISIT(PyStackRef_AsPyObjectBorrow(*ref)); @@ -501,6 +537,60 @@ subtract_refs(PyGC_Head *containers) } } +/* Initialize and subtract references in one traversal of all ordinary generations. + * + * Mortal candidates retain adjusted gc_refs. Dynamic immortal candidates become + * untracked without changing other candidates' temporary counts. + * + * 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. + * - `_PyInterpreterState_MayHaveForeignGcObjects()` is false. + * + * Arguments: + * - `containers`: tracked objects belonging to the initial main interpreter. + * + * Return the number of mortal collection candidates. + */ +static Py_ssize_t +update_and_subtract_refs(PyGC_Head *containers) +{ + Py_ssize_t candidates = 0; + PyGC_Head *previous = containers; + PyGC_Head *gc = GC_NEXT(containers); + + while (gc != containers) { + PyGC_Head *next = GC_NEXT(gc); + PyObject *op = FROM_GC(gc); + + if (!gc_is_collecting(gc)) { + if (_Py_IsImmortal(op)) { + assert(!_Py_IsStaticImmortal(op)); + // The next node may already hold gc_refs instead of a predecessor, + // so ordinary untracking would corrupt its count. + _PyGCHead_SET_NEXT(previous, next); + gc->_gc_prev &= _PyGC_PREV_MASK_FINALIZED; + gc->_gc_next = 0; + _PyInterpreterState_GET()->gc.heap_size--; + gc = next; + continue; + } + gc_reset_refs(gc, Py_REFCNT(op)); + _PyObject_ASSERT(op, gc_get_refs(gc) != 0); + } + + candidates++; + (void)Py_TYPE(op)->tp_traverse(op, gc_decref_and_mark_collecting, op); + previous = gc; + gc = next; + } + + // The tail must remain valid until reachability restores predecessor links. + containers->_gc_prev = (uintptr_t)previous; + return candidates; +} + /* A traversal callback for move_unreachable. */ static int visit_reachable(PyObject *op, void *arg) @@ -1185,6 +1275,8 @@ show_stats_each_generations(GCState *gcstate) /* Deduce which objects among "base" are unreachable from outside the list and move them to 'unreachable'. The process consist in the following steps: +The first two steps may be combined for the initial pass of a full collection. + 1. Copy all reference counts to a different field (gc_prev is used to hold this copy to save memory). 2. Traverse all objects in "base" and visit all referred objects using @@ -1204,6 +1296,13 @@ show_stats_each_generations(GCState *gcstate) * The "unreachable" list must be uninitialized (this function calls gc_list_init over 'unreachable'). + * "full_collection_pass" means this is the initial pass over all ordinary generations. + Resurrection rechecks pass false even during a full collection. + + * "reachable" must point to storage for the number of survivors that remain tracked. + + * The return value is the number of mortal collection candidates. + IMPORTANT: This function leaves 'unreachable' with the NEXT_MASK_UNREACHABLE flag set but it does not clear it to skip unnecessary iteration. Before the flag is cleared (for example, by using 'clear_unreachable_mask' function or @@ -1211,15 +1310,25 @@ by a call to 'move_legacy_finalizers'), the 'unreachable' list is not a normal list and we can not use most gc_list_* functions for it. */ static inline Py_ssize_t deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable, - Py_ssize_t *reachable) { + bool full_collection_pass, Py_ssize_t *reachable) { validate_list(base, collecting_clear_unreachable_clear); /* Using ob_refcnt and gc_refs, calculate which objects in the * container set are reachable from outside the set (i.e., have a * refcount greater than 0 when all the references within the * set are taken into account). */ - Py_ssize_t candidates = update_refs(base); // gc_prev is used for gc_refs - subtract_refs(base); + // Lazy initialization can touch any tracked mortal referent. Use it only + // when all such objects belong to this initial full collection. + Py_ssize_t candidates; + if (full_collection_pass && + gc_list_is_empty(&get_gc_state()->permanent_generation.head) && + !_PyInterpreterState_MayHaveForeignGcObjects()) { + candidates = update_and_subtract_refs(base); + } + else { + candidates = update_refs(base); // gc_prev is used for gc_refs + subtract_refs(base); + } /* Leave everything reachable from outside base in base, and move * everything else (in base) to unreachable. @@ -1290,7 +1399,7 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, // removed so we can skip the expense of clearing the flag. PyGC_Head* resurrected = unreachable; Py_ssize_t reachable; - deduce_unreachable(resurrected, still_unreachable, &reachable); + deduce_unreachable(resurrected, still_unreachable, false, &reachable); clear_unreachable_mask(still_unreachable); // Move the resurrected objects to the old generation for future collection. @@ -1597,7 +1706,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) validate_list(old, collecting_clear_unreachable_clear); Py_ssize_t reachable; - stats.candidates = deduce_unreachable(young, &unreachable, &reachable); + stats.candidates = deduce_unreachable(young, &unreachable, generation == NUM_GENERATIONS - 1, &reachable); /* Move reachable objects to next generation. */ if (young != old) { diff --git a/Python/pystate.c b/Python/pystate.c index 97323ea2ba2bce1..f55521861d894c8 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -309,6 +309,17 @@ _Py_COMP_DIAG_IGNORE_DEPR_DECLS static const _PyRuntimeState initial = _PyRuntimeState_INIT(_PyRuntime, ""); _Py_COMP_DIAG_POP +/* Monotonic guard for tracked objects that may outlive their interpreter. + * Kept outside _PyRuntime so reinitialization cannot clear it. + */ +static int may_have_foreign_gc_objects; + +bool +_PyInterpreterState_MayHaveForeignGcObjects(void) +{ + return _Py_atomic_load_int_relaxed(&may_have_foreign_gc_objects) != 0; +} + #define LOCKS_INIT(runtime) \ { \ &(runtime)->interpreters.mutex, \ @@ -364,6 +375,7 @@ _PyRuntimeState_Init(_PyRuntimeState *runtime) if (runtime->_initialized) { // Py_Initialize() must be running again. + _Py_atomic_store_int_relaxed(&may_have_foreign_gc_objects, 1); // Reset to _PyRuntimeState_INIT. memcpy(runtime, &initial, sizeof(*runtime)); // Preserve the cookie from the original runtime. @@ -723,6 +735,8 @@ _PyInterpreterState_New(PyThreadState *tstate, PyInterpreterState **pinterp) assert(interpreters->main != NULL); assert(id != 0); + _Py_atomic_store_int_relaxed(&may_have_foreign_gc_objects, 1); + interp = alloc_interpreter(); if (interp == NULL) { status = _PyStatus_NO_MEMORY(); diff --git a/Tools/c-analyzer/cpython/ignored.tsv b/Tools/c-analyzer/cpython/ignored.tsv index db9f74fdbc591f0..ba0d4b94286e3f8 100644 --- a/Tools/c-analyzer/cpython/ignored.tsv +++ b/Tools/c-analyzer/cpython/ignored.tsv @@ -50,6 +50,10 @@ Modules/getbuildinfo.c - initialized - Python/getversion.c - initialized - Python/getversion.c - version - +## monotonic safety guards +# Becomes true once tracked objects may outlive their interpreter. +Python/pystate.c - may_have_foreign_gc_objects - + ## public C-API - set during first init Python/bootstrap_hash.c - _Py_HashSecret_Initialized - Python/pyhash.c - _Py_HashSecret - From 870fae23e6aee44b1902fdb4cc2198a03b563b8d Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 01:43:09 -0400 Subject: [PATCH 6/9] gh-159437: Skip unnecessary legacy finalizer scans Count tp_del candidates during the existing reference traversals so zero can skip legacy classification. Reuse callback preparation to clear initial unreachable tags, while preserving normalized links before DEBUG_COLLECTABLE output can invoke Python. Cover debug visibility of weakrefs with callbacks and legacy classification during a generation 0 collection. --- Lib/test/test_gc.py | 43 +++++++++- ...-10-16-05-37-12.gh-issue-159437.legacy.rst | 1 + Python/gc.c | 83 +++++++++++++------ 3 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-05-37-12.gh-issue-159437.legacy.rst diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 6424fcc5bdd3571..8f0932506cae01b 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -761,6 +761,46 @@ def write(self, text): obj.cycle = None resurrected.clear() + @requires_gil_enabled("tests the generation-list collector") + def test_debug_output_precedes_weakref_preparation(self): + """Debug output can observe weakrefs with callbacks before they are cleared.""" + was_enabled = gc.isenabled() + debug = gc.get_debug() + gc.disable() + gc.set_debug(0) + self.addCleanup(gc.enable if was_enabled else gc.disable) + self.addCleanup(gc.collect) + self.addCleanup(gc.set_debug, debug) + observed = [] + callbacks = [] + self.addCleanup(callbacks.clear) + + class Stderr: + """Record whether the target is visible during collectable debug output.""" + + def write(self, text): + """Record target visibility on the first collectable debug message. + + text: Debug text written by the collector. + Returns: Number of characters accepted. + """ + if text.startswith("gc: collectable") and not observed: + observed.append(reference() is not None) + return len(text) + + writer = Stderr() + gc.collect() + obj = C1055820(0) + reference = weakref.ref(obj, callbacks.append) + del obj + + with unittest.mock.patch.object(sys, "stderr", writer): + gc.set_debug(gc.DEBUG_COLLECTABLE) + gc.collect(0) + self.assertEqual(observed, [True]) + self.assertEqual(callbacks, [reference]) + self.assertIsNone(reference()) + def test_bug1055820b(self): # Corresponds to temp2b.py in the bug report. @@ -1565,7 +1605,8 @@ def test_collect_garbage(self): Uncollectable() Uncollectable() C1055820(666) - gc.collect() + # Exercise legacy-finalizer discovery in a partial collection. + gc.collect(0) for v in self.visit: if v[1] != "stop": continue diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-05-37-12.gh-issue-159437.legacy.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-05-37-12.gh-issue-159437.legacy.rst new file mode 100644 index 000000000000000..3bb142bb378d3ce --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-05-37-12.gh-issue-159437.legacy.rst @@ -0,0 +1 @@ +Garbage collection in builds with the GIL now avoids legacy finalizer scans when no candidate defines a legacy finalizer, and combines unreachable list cleanup with weakref preparation when possible. diff --git a/Python/gc.c b/Python/gc.c index 77122f62a41481f..d58134dc7d6cb56 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -44,7 +44,8 @@ typedef struct _gc_runtime_state GCState; // // Although this flag is used only in move_unreachable(), move_unreachable() // doesn't clear this flag to skip unnecessary iteration. -// move_legacy_finalizers() removes this flag instead. +// move_legacy_finalizers(), clear_unreachable_mask(), or prepare_callbacks() +// remove this flag instead. // Between them, unreachable list is not normal list and we can not use // most gc_list_* functions for it. #define NEXT_MASK_UNREACHABLE (1) @@ -200,7 +201,8 @@ NEXT_MASK_UNREACHABLE "unreachable" set. The flag is unset and the object is moved back to "reachable" set. - move_legacy_finalizers() will remove this flag from "unreachable" set. + move_legacy_finalizers(), clear_unreachable_mask(), or prepare_callbacks() + remove this flag from the "unreachable" set. */ /*** list functions ***/ @@ -522,19 +524,26 @@ _PyGC_VisitFrameStack(_PyInterpreterFrame *frame, visitproc visit, void *arg) /* Subtract internal references from gc_refs. After this, gc_refs is >= 0 * for all objects in containers. The ones with gc_refs > 0 are directly * reachable from outside containers, and so can't be collected. + * + * Return the number of objects whose type defines tp_del. A zero count + * excludes legacy finalizers from the eventual unreachable subset. */ -static void +static Py_ssize_t subtract_refs(PyGC_Head *containers) { + Py_ssize_t legacy_finalizer_count = 0; traverseproc traverse; PyGC_Head *gc = GC_NEXT(containers); for (; gc != containers; gc = GC_NEXT(gc)) { PyObject *op = FROM_GC(gc); - traverse = Py_TYPE(op)->tp_traverse; + PyTypeObject *type = Py_TYPE(op); + legacy_finalizer_count += type->tp_del != NULL; + traverse = type->tp_traverse; (void) traverse(op, visit_decref, op); } + return legacy_finalizer_count; } /* Initialize and subtract references in one traversal of all ordinary generations. @@ -550,13 +559,15 @@ subtract_refs(PyGC_Head *containers) * * Arguments: * - `containers`: tracked objects belonging to the initial main interpreter. + * - `legacy_finalizer_count_out`: receives the number of mortal candidates whose type defines tp_del. * * Return the number of mortal collection candidates. */ static Py_ssize_t -update_and_subtract_refs(PyGC_Head *containers) +update_and_subtract_refs(PyGC_Head *containers, Py_ssize_t *legacy_finalizer_count_out) { Py_ssize_t candidates = 0; + Py_ssize_t legacy_finalizer_count = 0; PyGC_Head *previous = containers; PyGC_Head *gc = GC_NEXT(containers); @@ -581,13 +592,16 @@ update_and_subtract_refs(PyGC_Head *containers) } candidates++; - (void)Py_TYPE(op)->tp_traverse(op, gc_decref_and_mark_collecting, op); + PyTypeObject *type = Py_TYPE(op); + legacy_finalizer_count += type->tp_del != NULL; + (void)type->tp_traverse(op, gc_decref_and_mark_collecting, op); previous = gc; gc = next; } // The tail must remain valid until reachability restores predecessor links. containers->_gc_prev = (uintptr_t)previous; + *legacy_finalizer_count_out = legacy_finalizer_count; return candidates; } @@ -886,19 +900,22 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers) * Python-level code. See bpo-38006 as an example bug. * * Arguments: - * - `unreachable`: normalized collecting list to inspect. + * - `unreachable`: collecting list with an untagged head; member _gc_next links must all be normalized or all carry NEXT_MASK_UNREACHABLE. * - `wrcb_to_call`: initialized list receiving weakrefs whose callbacks are safe to invoke. * - `collectable_count_out`: receives the number of objects in `unreachable`. * - `needs_weakref_cleanup_out`: receives whether garbage weakrefs or remaining incoming weakrefs may need cleanup. * * Return whether any finalizer is pending or a weakref callback is queued. - * This function does not invoke callbacks. The count and cleanup decision - * remain valid only while the unreachable set and its weakrefs cannot change. + * On return, `unreachable` is normalized. This function does not invoke callbacks. + * The count and cleanup decision remain valid only while the unreachable set and its weakrefs cannot change. */ Py_NO_INLINE static bool prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call, Py_ssize_t *collectable_count_out, bool *needs_weakref_cleanup_out) { + assert((unreachable->_gc_next & NEXT_MASK_UNREACHABLE) == 0); + validate_list(unreachable, (GC_NEXT(unreachable)->_gc_next & NEXT_MASK_UNREACHABLE) ? collecting_set_unreachable_set : collecting_set_unreachable_clear); + PyGC_Head *gc; PyGC_Head *next; Py_ssize_t count = 0; @@ -918,6 +935,7 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call, if (type->tp_finalize != NULL && !has_pending_finalizer) { has_pending_finalizer = !_PyGC_FINALIZED(op); } + gc->_gc_next &= ~NEXT_MASK_UNREACHABLE; next = GC_NEXT(gc); // Garbage weakrefs need clearing even if their referents are outside @@ -1005,6 +1023,7 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call, needs_weakref_cleanup |= *wrlist != NULL; } + validate_list(unreachable, collecting_set_unreachable_clear); *collectable_count_out = count; *needs_weakref_cleanup_out = needs_weakref_cleanup; return has_pending_finalizer || !gc_list_is_empty(wrcb_to_call); @@ -1301,16 +1320,20 @@ The first two steps may be combined for the initial pass of a full collection. * "reachable" must point to storage for the number of survivors that remain tracked. + * "legacy_finalizer_count_out" receives the number of candidate objects + whose type defines tp_del. Zero excludes legacy finalizers from the + unreachable subset. + * The return value is the number of mortal collection candidates. IMPORTANT: This function leaves 'unreachable' with the NEXT_MASK_UNREACHABLE flag set but it does not clear it to skip unnecessary iteration. Before the -flag is cleared (for example, by using 'clear_unreachable_mask' function or -by a call to 'move_legacy_finalizers'), the 'unreachable' list is not a normal -list and we can not use most gc_list_* functions for it. */ +flag is cleared (for example, by 'clear_unreachable_mask', +'move_legacy_finalizers', or 'prepare_callbacks'), the 'unreachable' list is +not a normal list and we can not use most gc_list_* functions for it. */ static inline Py_ssize_t deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable, - bool full_collection_pass, Py_ssize_t *reachable) { + bool full_collection_pass, Py_ssize_t *reachable, Py_ssize_t *legacy_finalizer_count_out) { validate_list(base, collecting_clear_unreachable_clear); /* Using ob_refcnt and gc_refs, calculate which objects in the * container set are reachable from outside the set (i.e., have a @@ -1323,11 +1346,11 @@ deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable, if (full_collection_pass && gc_list_is_empty(&get_gc_state()->permanent_generation.head) && !_PyInterpreterState_MayHaveForeignGcObjects()) { - candidates = update_and_subtract_refs(base); + candidates = update_and_subtract_refs(base, legacy_finalizer_count_out); } else { candidates = update_refs(base); // gc_prev is used for gc_refs - subtract_refs(base); + *legacy_finalizer_count_out = subtract_refs(base); } /* Leave everything reachable from outside base in base, and move @@ -1399,7 +1422,8 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, // removed so we can skip the expense of clearing the flag. PyGC_Head* resurrected = unreachable; Py_ssize_t reachable; - deduce_unreachable(resurrected, still_unreachable, false, &reachable); + Py_ssize_t unused_legacy_finalizer_count; + deduce_unreachable(resurrected, still_unreachable, false, &reachable, &unused_legacy_finalizer_count); clear_unreachable_mask(still_unreachable); // Move the resurrected objects to the old generation for future collection. @@ -1706,7 +1730,8 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) validate_list(old, collecting_clear_unreachable_clear); Py_ssize_t reachable; - stats.candidates = deduce_unreachable(young, &unreachable, generation == NUM_GENERATIONS - 1, &reachable); + Py_ssize_t legacy_finalizer_count; + stats.candidates = deduce_unreachable(young, &unreachable, generation == NUM_GENERATIONS - 1, &reachable, &legacy_finalizer_count); /* Move reachable objects to next generation. */ if (young != old) { @@ -1731,22 +1756,28 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) * legacy finalizers (e.g. tp_del) can't safely be deleted. */ gc_list_init(&finalizers); - // NEXT_MASK_UNREACHABLE is cleared here. - // After move_legacy_finalizers(), unreachable is normal list. - move_legacy_finalizers(&unreachable, &finalizers); - /* finalizers contains the unreachable objects with a legacy finalizer; - * unreachable objects reachable *from* those are also uncollectable, - * and we move those into the finalizers list too. - */ - move_legacy_finalizer_reachable(&finalizers); + if (legacy_finalizer_count != 0) { + // NEXT_MASK_UNREACHABLE is cleared here. + // After move_legacy_finalizers(), unreachable is a normal list. + move_legacy_finalizers(&unreachable, &finalizers); + /* finalizers contains the unreachable objects with a legacy finalizer; + * unreachable objects reachable *from* those are also uncollectable, + * and we move those into the finalizers list too. + */ + move_legacy_finalizer_reachable(&finalizers); + validate_list(&unreachable, collecting_set_unreachable_clear); + } validate_list(&finalizers, collecting_clear_unreachable_clear); - validate_list(&unreachable, collecting_set_unreachable_clear); bool needs_resurrection_check = false; /* Print debugging information. */ if (gcstate->debug & _PyGC_DEBUG_COLLECTABLE) { + if (legacy_finalizer_count == 0) { + // Python invoked by debug output requires normal GC list links. + clear_unreachable_mask(&unreachable); + } /* sys.stderr.write() can resurrect objects. */ needs_resurrection_check = true; for (gc = GC_NEXT(&unreachable); gc != &unreachable; gc = GC_NEXT(gc)) { From ec8a6a7bd3cfa7a3de4f1d67eb25d7505e4ed4e6 Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 01:55:51 -0400 Subject: [PATCH 7/9] gh-159437: Combine GC resurrection bookkeeping --- ...10-16-01-50-35.gh-issue-159437.resbook.rst | 1 + Python/gc.c | 60 ++++++++----------- 2 files changed, 27 insertions(+), 34 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-50-35.gh-issue-159437.resbook.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-50-35.gh-issue-159437.resbook.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-50-35.gh-issue-159437.resbook.rst new file mode 100644 index 000000000000000..a17f00a3a15fe0d --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-01-50-35.gh-issue-159437.resbook.rst @@ -0,0 +1 @@ +With the GIL enabled, avoid separate passes that clear collecting bits before resurrection analysis and unreachable tags afterward. The existing reference and late weak-reference passes now perform that bookkeeping. diff --git a/Python/gc.c b/Python/gc.c index d58134dc7d6cb56..db8cb0c88161664 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -45,7 +45,8 @@ typedef struct _gc_runtime_state GCState; // Although this flag is used only in move_unreachable(), move_unreachable() // doesn't clear this flag to skip unnecessary iteration. // move_legacy_finalizers(), clear_unreachable_mask(), or prepare_callbacks() -// remove this flag instead. +// remove this flag after initial deduction. clear_weakrefs() removes it after +// a resurrection recheck. // Between them, unreachable list is not normal list and we can not use // most gc_list_* functions for it. #define NEXT_MASK_UNREACHABLE (1) @@ -202,7 +203,8 @@ NEXT_MASK_UNREACHABLE The flag is unset and the object is moved back to "reachable" set. move_legacy_finalizers(), clear_unreachable_mask(), or prepare_callbacks() - remove this flag from the "unreachable" set. + remove this flag after initial deduction. clear_weakrefs() removes it after + a resurrection recheck. */ /*** list functions ***/ @@ -304,16 +306,6 @@ gc_list_size(PyGC_Head *list) return n; } -/* Walk the list and mark all objects as non-collecting */ -static inline void -gc_list_clear_collecting(PyGC_Head *collectable) -{ - PyGC_Head *gc; - for (gc = GC_NEXT(collectable); gc != collectable; gc = GC_NEXT(gc)) { - gc_clear_collecting(gc); - } -} - /* Append objects in a GC list to a Python list. * Return 0 if all OK, < 0 if error (out of memory for list) */ @@ -1092,11 +1084,13 @@ invoke_weakref_callbacks(PyGC_Head *wrcb_to_call, PyGC_Head *old) } /* Clear all weakrefs to unreachable objects without invoking callbacks. - * When this returns, no object in `unreachable` is weakly referenced anymore. * See the comments above prepare_callbacks() for why these weakrefs need to be cleared. * - * `unreachable`: Final collectable list; membership is unchanged. - * Return: Number of objects in `unreachable`. + * Arguments: + * - `unreachable`: final collectable list with an untagged head; member _gc_next links must all be normalized or all carry NEXT_MASK_UNREACHABLE. + * + * Return the number of objects in `unreachable`. On return, the list is + * normalized, membership is unchanged, and no member is weakly referenced. */ static Py_ssize_t clear_weakrefs(PyGC_Head *unreachable) @@ -1109,6 +1103,7 @@ clear_weakrefs(PyGC_Head *unreachable) PyWeakReference **wrlist; PyObject *op = FROM_GC(gc); + gc->_gc_next &= ~NEXT_MASK_UNREACHABLE; next = GC_NEXT(gc); count++; @@ -1310,7 +1305,9 @@ The first two steps may be combined for the initial pass of a full collection. Contracts: - * The "base" has to be a valid list with no mask set. + * The "base" has to be a valid list. Its members may carry + PREV_MASK_COLLECTING during a resurrection recheck. + NEXT_MASK_UNREACHABLE must be clear. * The "unreachable" list must be uninitialized (this function calls gc_list_init over 'unreachable'). @@ -1329,12 +1326,11 @@ The first two steps may be combined for the initial pass of a full collection. IMPORTANT: This function leaves 'unreachable' with the NEXT_MASK_UNREACHABLE flag set but it does not clear it to skip unnecessary iteration. Before the flag is cleared (for example, by 'clear_unreachable_mask', -'move_legacy_finalizers', or 'prepare_callbacks'), the 'unreachable' list is -not a normal list and we can not use most gc_list_* functions for it. */ +'move_legacy_finalizers', 'prepare_callbacks', or 'clear_weakrefs'), the +'unreachable' list is not a normal list and we can not use most gc_list_* functions for it. */ static inline Py_ssize_t deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable, bool full_collection_pass, Py_ssize_t *reachable, Py_ssize_t *legacy_finalizer_count_out) { - validate_list(base, collecting_clear_unreachable_clear); /* Using ob_refcnt and gc_refs, calculate which objects in the * container set are reachable from outside the set (i.e., have a * refcount greater than 0 when all the references within the @@ -1406,25 +1402,18 @@ deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable, * The "still_unreachable" list must be uninitialized (this function calls gc_list_init over 'still_unreachable'). -IMPORTANT: After a call to this function, the 'still_unreachable' set will have the -PREV_MARK_COLLECTING set, but the objects in this set are going to be removed so -we can skip the expense of clearing the flag to avoid extra iteration. */ +IMPORTANT: The 'still_unreachable' head is untagged. Its members carry +PREV_MASK_COLLECTING and NEXT_MASK_UNREACHABLE. 'clear_weakrefs' must normalize +the list before ordinary list operations or deletion. */ static inline void handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, PyGC_Head *old_generation) { - // Remove the PREV_MASK_COLLECTING from unreachable - // to prepare it for a new call to 'deduce_unreachable' - gc_list_clear_collecting(unreachable); - - // After the call to deduce_unreachable, the 'still_unreachable' set will - // have the PREV_MARK_COLLECTING set, but the objects are going to be - // removed so we can skip the expense of clearing the flag. PyGC_Head* resurrected = unreachable; + validate_list(resurrected, collecting_set_unreachable_clear); Py_ssize_t reachable; Py_ssize_t unused_legacy_finalizer_count; deduce_unreachable(resurrected, still_unreachable, false, &reachable, &unused_legacy_finalizer_count); - clear_unreachable_mask(still_unreachable); // Move the resurrected objects to the old generation for future collection. gc_list_merge(resurrected, old_generation); @@ -1443,8 +1432,10 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, * - `needs_resurrection_check`: whether earlier work may have run Python. * * Return whether resurrection analysis ran. If true, `collectable` must not be - * used again and `still_unreachable` contains the remaining garbage. Otherwise, - * `collectable` remains selected and `still_unreachable` is untouched. + * used again and `still_unreachable` contains the remaining garbage with + * NEXT_MASK_UNREACHABLE set on every member and an untagged head. It must pass + * through `clear_weakrefs()` before deletion. Otherwise, `collectable` remains + * selected and `still_unreachable` is untouched. */ Py_NO_INLINE static bool handle_callbacks(PyThreadState *tstate, PyGC_Head *collectable, @@ -1728,6 +1719,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) old = young; } validate_list(old, collecting_clear_unreachable_clear); + validate_list(young, collecting_clear_unreachable_clear); Py_ssize_t reachable; Py_ssize_t legacy_finalizer_count; @@ -1799,12 +1791,11 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) if (resurrection_checked) { collectable = &final_unreachable; // Python code may have changed the garbage set or created weakrefs. - // Refresh both cleanup and counting. + // Refresh cleanup and counting, and normalize the recheck's tags. needs_weakref_cleanup = true; } } validate_list(old, collecting_clear_unreachable_clear); - validate_list(collectable, collecting_set_unreachable_clear); /* Clear weakrefs to objects in the unreachable set. No Python-level * code must be allowed to access those unreachable objects. During @@ -1815,6 +1806,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) if (needs_weakref_cleanup) { collectable_count = clear_weakrefs(collectable); } + validate_list(collectable, collecting_set_unreachable_clear); stats.collected += collectable_count; /* Call tp_clear on objects in the collectable set. This will cause From 2a0502ace0aaa368bd449a1bbefc4d9797fa3c00 Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Sat, 17 Oct 2026 22:38:42 -0400 Subject: [PATCH 8/9] gh-159437: Avoid resetting discarded resurrection list --- Python/gc.c | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Python/gc.c b/Python/gc.c index db8cb0c88161664..ee25db2a25e65cd 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -274,9 +274,16 @@ gc_list_move(PyGC_Head *node, PyGC_Head *list) _PyGCHead_SET_NEXT(node, list); } -/* append list `from` onto list `to`; `from` becomes an empty list */ -static void -gc_list_merge(PyGC_Head *from, PyGC_Head *to) +/* Append all members of `from` to `to` in order. + * + * `from`: Source GC list with untagged _gc_next links. + * `to`: Distinct, disjoint GC list with untagged _gc_next links. + * + * `to` remains valid and _gc_prev flags are preserved. The source head must + * be discarded or reinitialized with gc_list_init() before any list operation. + */ +static inline void +gc_list_splice(PyGC_Head *from, PyGC_Head *to) { assert(from != to); if (!gc_list_is_empty(from)) { @@ -292,6 +299,13 @@ gc_list_merge(PyGC_Head *from, PyGC_Head *to) _PyGCHead_SET_NEXT(from_tail, to); _PyGCHead_SET_PREV(to, from_tail); } +} + +/* append list `from` onto list `to`; `from` becomes an empty list */ +static void +gc_list_merge(PyGC_Head *from, PyGC_Head *to) +{ + gc_list_splice(from, to); gc_list_init(from); } @@ -1416,7 +1430,7 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable, deduce_unreachable(resurrected, still_unreachable, false, &reachable, &unused_legacy_finalizer_count); // Move the resurrected objects to the old generation for future collection. - gc_list_merge(resurrected, old_generation); + gc_list_splice(resurrected, old_generation); } From 3afca7a7a95242d7f77bb7be38bcb8d3f2179f26 Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 02:15:08 -0400 Subject: [PATCH 9/9] gh-159437: Support frozen objects in fused GC reference passes --- Include/internal/pycore_gc.h | 12 +- Include/internal/pycore_interp_structs.h | 2 +- Include/object.h | 14 +- InternalDocs/garbage_collector.md | 14 +- Lib/test/test_gc.py | 181 ++++++++++++++++++ ...26-10-16-06-05-52.gh-issue-159437.perm.rst | 1 + Python/gc.c | 62 ++++-- 7 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-06-05-52.gh-issue-159437.perm.rst diff --git a/Include/internal/pycore_gc.h b/Include/internal/pycore_gc.h index 84cbb56a9192156..1ae91d3bbb7556d 100644 --- a/Include/internal/pycore_gc.h +++ b/Include/internal/pycore_gc.h @@ -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 */ @@ -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; diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 9cc25eda44f9de1..713f66d5cb06d17 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -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; diff --git a/Include/object.h b/Include/object.h index 549b91ebecf4f22..a0e0b5dce82dba9 100644 --- a/Include/object.h +++ b/Include/object.h @@ -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 diff --git a/InternalDocs/garbage_collector.md b/InternalDocs/garbage_collector.md index 0ef45ff8e02bc5f..b22f2c0c0327cf0 100644 --- a/InternalDocs/garbage_collector.md +++ b/InternalDocs/garbage_collector.md @@ -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 diff --git a/Lib/test/test_gc.py b/Lib/test/test_gc.py index 8f0932506cae01b..6b01bf31ab18707 100644 --- a/Lib/test/test_gc.py +++ b/Lib/test/test_gc.py @@ -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 + 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 = [] diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-06-05-52.gh-issue-159437.perm.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-06-05-52.gh-issue-159437.perm.rst new file mode 100644 index 000000000000000..f68900cd8878ad5 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-16-06-05-52.gh-issue-159437.perm.rst @@ -0,0 +1 @@ +Allow full garbage collections to combine reference passes after :func:`gc.freeze` on GIL builds with pointers larger than four bytes. diff --git a/Python/gc.c b/Python/gc.c index ee25db2a25e65cd..ccade26bead9607 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -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) { @@ -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. @@ -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. @@ -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; } @@ -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: @@ -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; +#endif gc_list_move(gc, old); } else { @@ -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); } @@ -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; } } @@ -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