From abd738f2ae72fd5d12ff2b0f8bcec2011b927aed Mon Sep 17 00:00:00 2001 From: Mark Falco Date: Fri, 16 Oct 2026 00:17:48 -0400 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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 -