Skip to content

Commit d9a38bb

Browse files
committed
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.
1 parent 5dd920a commit d9a38bb

3 files changed

Lines changed: 100 additions & 27 deletions

File tree

‎Lib/test/test_gc.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,46 @@ def write(self, text):
761761
obj.cycle = None
762762
resurrected.clear()
763763

764+
@requires_gil_enabled("tests the generation-list collector")
765+
def test_debug_output_precedes_weakref_preparation(self):
766+
"""Debug output can observe weakrefs with callbacks before they are cleared."""
767+
was_enabled = gc.isenabled()
768+
debug = gc.get_debug()
769+
gc.disable()
770+
gc.set_debug(0)
771+
self.addCleanup(gc.enable if was_enabled else gc.disable)
772+
self.addCleanup(gc.collect)
773+
self.addCleanup(gc.set_debug, debug)
774+
observed = []
775+
callbacks = []
776+
self.addCleanup(callbacks.clear)
777+
778+
class Stderr:
779+
"""Record whether the target is visible during collectable debug output."""
780+
781+
def write(self, text):
782+
"""Record target visibility on the first collectable debug message.
783+
784+
text: Debug text written by the collector.
785+
Returns: Number of characters accepted.
786+
"""
787+
if text.startswith("gc: collectable") and not observed:
788+
observed.append(reference() is not None)
789+
return len(text)
790+
791+
writer = Stderr()
792+
gc.collect()
793+
obj = C1055820(0)
794+
reference = weakref.ref(obj, callbacks.append)
795+
del obj
796+
797+
with unittest.mock.patch.object(sys, "stderr", writer):
798+
gc.set_debug(gc.DEBUG_COLLECTABLE)
799+
gc.collect(0)
800+
self.assertEqual(observed, [True])
801+
self.assertEqual(callbacks, [reference])
802+
self.assertIsNone(reference())
803+
764804
def test_bug1055820b(self):
765805
# Corresponds to temp2b.py in the bug report.
766806

@@ -1565,7 +1605,8 @@ def test_collect_garbage(self):
15651605
Uncollectable()
15661606
Uncollectable()
15671607
C1055820(666)
1568-
gc.collect()
1608+
# Exercise legacy-finalizer discovery in a partial collection.
1609+
gc.collect(0)
15691610
for v in self.visit:
15701611
if v[1] != "stop":
15711612
continue
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

‎Python/gc.c‎

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ typedef struct _gc_runtime_state GCState;
4444
//
4545
// Although this flag is used only in move_unreachable(), move_unreachable()
4646
// doesn't clear this flag to skip unnecessary iteration.
47-
// move_legacy_finalizers() removes this flag instead.
47+
// move_legacy_finalizers(), clear_unreachable_mask(), or prepare_callbacks()
48+
// remove this flag instead.
4849
// Between them, unreachable list is not normal list and we can not use
4950
// most gc_list_* functions for it.
5051
#define NEXT_MASK_UNREACHABLE (1)
@@ -200,7 +201,8 @@ NEXT_MASK_UNREACHABLE
200201
"unreachable" set.
201202
The flag is unset and the object is moved back to "reachable" set.
202203
203-
move_legacy_finalizers() will remove this flag from "unreachable" set.
204+
move_legacy_finalizers(), clear_unreachable_mask(), or prepare_callbacks()
205+
remove this flag from the "unreachable" set.
204206
*/
205207

206208
/*** list functions ***/
@@ -522,19 +524,26 @@ _PyGC_VisitFrameStack(_PyInterpreterFrame *frame, visitproc visit, void *arg)
522524
/* Subtract internal references from gc_refs. After this, gc_refs is >= 0
523525
* for all objects in containers. The ones with gc_refs > 0 are directly
524526
* reachable from outside containers, and so can't be collected.
527+
*
528+
* Return the number of objects whose type defines tp_del. A zero count
529+
* excludes legacy finalizers from the eventual unreachable subset.
525530
*/
526-
static void
531+
static Py_ssize_t
527532
subtract_refs(PyGC_Head *containers)
528533
{
534+
Py_ssize_t legacy_finalizer_count = 0;
529535
traverseproc traverse;
530536
PyGC_Head *gc = GC_NEXT(containers);
531537
for (; gc != containers; gc = GC_NEXT(gc)) {
532538
PyObject *op = FROM_GC(gc);
533-
traverse = Py_TYPE(op)->tp_traverse;
539+
PyTypeObject *type = Py_TYPE(op);
540+
legacy_finalizer_count += type->tp_del != NULL;
541+
traverse = type->tp_traverse;
534542
(void) traverse(op,
535543
visit_decref,
536544
op);
537545
}
546+
return legacy_finalizer_count;
538547
}
539548

540549
/* Initialize and subtract references in one traversal of all ordinary generations.
@@ -550,13 +559,15 @@ subtract_refs(PyGC_Head *containers)
550559
*
551560
* Arguments:
552561
* - `containers`: tracked objects belonging to the initial main interpreter.
562+
* - `legacy_finalizer_count_out`: receives the number of mortal candidates whose type defines tp_del.
553563
*
554564
* Return the number of mortal collection candidates.
555565
*/
556566
static Py_ssize_t
557-
update_and_subtract_refs(PyGC_Head *containers)
567+
update_and_subtract_refs(PyGC_Head *containers, Py_ssize_t *legacy_finalizer_count_out)
558568
{
559569
Py_ssize_t candidates = 0;
570+
Py_ssize_t legacy_finalizer_count = 0;
560571
PyGC_Head *previous = containers;
561572
PyGC_Head *gc = GC_NEXT(containers);
562573

@@ -581,13 +592,16 @@ update_and_subtract_refs(PyGC_Head *containers)
581592
}
582593

583594
candidates++;
584-
(void)Py_TYPE(op)->tp_traverse(op, gc_decref_and_mark_collecting, op);
595+
PyTypeObject *type = Py_TYPE(op);
596+
legacy_finalizer_count += type->tp_del != NULL;
597+
(void)type->tp_traverse(op, gc_decref_and_mark_collecting, op);
585598
previous = gc;
586599
gc = next;
587600
}
588601

589602
// The tail must remain valid until reachability restores predecessor links.
590603
containers->_gc_prev = (uintptr_t)previous;
604+
*legacy_finalizer_count_out = legacy_finalizer_count;
591605
return candidates;
592606
}
593607

@@ -886,19 +900,22 @@ move_legacy_finalizer_reachable(PyGC_Head *finalizers)
886900
* Python-level code. See bpo-38006 as an example bug.
887901
*
888902
* Arguments:
889-
* - `unreachable`: normalized collecting list to inspect.
903+
* - `unreachable`: collecting list with an untagged head; member _gc_next links must all be normalized or all carry NEXT_MASK_UNREACHABLE.
890904
* - `wrcb_to_call`: initialized list receiving weakrefs whose callbacks are safe to invoke.
891905
* - `collectable_count_out`: receives the number of objects in `unreachable`.
892906
* - `needs_weakref_cleanup_out`: receives whether garbage weakrefs or remaining incoming weakrefs may need cleanup.
893907
*
894908
* Return whether any finalizer is pending or a weakref callback is queued.
895-
* This function does not invoke callbacks. The count and cleanup decision
896-
* remain valid only while the unreachable set and its weakrefs cannot change.
909+
* On return, `unreachable` is normalized. This function does not invoke callbacks.
910+
* The count and cleanup decision remain valid only while the unreachable set and its weakrefs cannot change.
897911
*/
898912
Py_NO_INLINE static bool
899913
prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call,
900914
Py_ssize_t *collectable_count_out, bool *needs_weakref_cleanup_out)
901915
{
916+
assert((unreachable->_gc_next & NEXT_MASK_UNREACHABLE) == 0);
917+
validate_list(unreachable, (GC_NEXT(unreachable)->_gc_next & NEXT_MASK_UNREACHABLE) ? collecting_set_unreachable_set : collecting_set_unreachable_clear);
918+
902919
PyGC_Head *gc;
903920
PyGC_Head *next;
904921
Py_ssize_t count = 0;
@@ -918,6 +935,7 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call,
918935
if (type->tp_finalize != NULL && !has_pending_finalizer) {
919936
has_pending_finalizer = !_PyGC_FINALIZED(op);
920937
}
938+
gc->_gc_next &= ~NEXT_MASK_UNREACHABLE;
921939
next = GC_NEXT(gc);
922940

923941
// Garbage weakrefs need clearing even if their referents are outside
@@ -1005,6 +1023,7 @@ prepare_callbacks(PyGC_Head *unreachable, PyGC_Head *wrcb_to_call,
10051023
needs_weakref_cleanup |= *wrlist != NULL;
10061024
}
10071025

1026+
validate_list(unreachable, collecting_set_unreachable_clear);
10081027
*collectable_count_out = count;
10091028
*needs_weakref_cleanup_out = needs_weakref_cleanup;
10101029
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.
13011320
13021321
* "reachable" must point to storage for the number of survivors that remain tracked.
13031322
1323+
* "legacy_finalizer_count_out" receives the number of candidate objects
1324+
whose type defines tp_del. Zero excludes legacy finalizers from the
1325+
unreachable subset.
1326+
13041327
* The return value is the number of mortal collection candidates.
13051328
13061329
IMPORTANT: This function leaves 'unreachable' with the NEXT_MASK_UNREACHABLE
13071330
flag set but it does not clear it to skip unnecessary iteration. Before the
1308-
flag is cleared (for example, by using 'clear_unreachable_mask' function or
1309-
by a call to 'move_legacy_finalizers'), the 'unreachable' list is not a normal
1310-
list and we can not use most gc_list_* functions for it. */
1331+
flag is cleared (for example, by 'clear_unreachable_mask',
1332+
'move_legacy_finalizers', or 'prepare_callbacks'), the 'unreachable' list is
1333+
not a normal list and we can not use most gc_list_* functions for it. */
13111334
static inline Py_ssize_t
13121335
deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable,
1313-
bool full_collection_pass, Py_ssize_t *reachable) {
1336+
bool full_collection_pass, Py_ssize_t *reachable, Py_ssize_t *legacy_finalizer_count_out) {
13141337
validate_list(base, collecting_clear_unreachable_clear);
13151338
/* Using ob_refcnt and gc_refs, calculate which objects in the
13161339
* container set are reachable from outside the set (i.e., have a
@@ -1323,11 +1346,11 @@ deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable,
13231346
if (full_collection_pass &&
13241347
gc_list_is_empty(&get_gc_state()->permanent_generation.head) &&
13251348
!_PyInterpreterState_MayHaveForeignGcObjects()) {
1326-
candidates = update_and_subtract_refs(base);
1349+
candidates = update_and_subtract_refs(base, legacy_finalizer_count_out);
13271350
}
13281351
else {
13291352
candidates = update_refs(base); // gc_prev is used for gc_refs
1330-
subtract_refs(base);
1353+
*legacy_finalizer_count_out = subtract_refs(base);
13311354
}
13321355

13331356
/* Leave everything reachable from outside base in base, and move
@@ -1399,7 +1422,8 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable,
13991422
// removed so we can skip the expense of clearing the flag.
14001423
PyGC_Head* resurrected = unreachable;
14011424
Py_ssize_t reachable;
1402-
deduce_unreachable(resurrected, still_unreachable, false, &reachable);
1425+
Py_ssize_t unused_legacy_finalizer_count;
1426+
deduce_unreachable(resurrected, still_unreachable, false, &reachable, &unused_legacy_finalizer_count);
14031427
clear_unreachable_mask(still_unreachable);
14041428

14051429
// 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)
17061730
validate_list(old, collecting_clear_unreachable_clear);
17071731

17081732
Py_ssize_t reachable;
1709-
stats.candidates = deduce_unreachable(young, &unreachable, generation == NUM_GENERATIONS - 1, &reachable);
1733+
Py_ssize_t legacy_finalizer_count;
1734+
stats.candidates = deduce_unreachable(young, &unreachable, generation == NUM_GENERATIONS - 1, &reachable, &legacy_finalizer_count);
17101735

17111736
/* Move reachable objects to next generation. */
17121737
if (young != old) {
@@ -1731,22 +1756,28 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason)
17311756
* legacy finalizers (e.g. tp_del) can't safely be deleted.
17321757
*/
17331758
gc_list_init(&finalizers);
1734-
// NEXT_MASK_UNREACHABLE is cleared here.
1735-
// After move_legacy_finalizers(), unreachable is normal list.
1736-
move_legacy_finalizers(&unreachable, &finalizers);
1737-
/* finalizers contains the unreachable objects with a legacy finalizer;
1738-
* unreachable objects reachable *from* those are also uncollectable,
1739-
* and we move those into the finalizers list too.
1740-
*/
1741-
move_legacy_finalizer_reachable(&finalizers);
1759+
if (legacy_finalizer_count != 0) {
1760+
// NEXT_MASK_UNREACHABLE is cleared here.
1761+
// After move_legacy_finalizers(), unreachable is a normal list.
1762+
move_legacy_finalizers(&unreachable, &finalizers);
1763+
/* finalizers contains the unreachable objects with a legacy finalizer;
1764+
* unreachable objects reachable *from* those are also uncollectable,
1765+
* and we move those into the finalizers list too.
1766+
*/
1767+
move_legacy_finalizer_reachable(&finalizers);
1768+
validate_list(&unreachable, collecting_set_unreachable_clear);
1769+
}
17421770

17431771
validate_list(&finalizers, collecting_clear_unreachable_clear);
1744-
validate_list(&unreachable, collecting_set_unreachable_clear);
17451772

17461773
bool needs_resurrection_check = false;
17471774

17481775
/* Print debugging information. */
17491776
if (gcstate->debug & _PyGC_DEBUG_COLLECTABLE) {
1777+
if (legacy_finalizer_count == 0) {
1778+
// Python invoked by debug output requires normal GC list links.
1779+
clear_unreachable_mask(&unreachable);
1780+
}
17501781
/* sys.stderr.write() can resurrect objects. */
17511782
needs_resurrection_check = true;
17521783
for (gc = GC_NEXT(&unreachable); gc != &unreachable; gc = GC_NEXT(gc)) {

0 commit comments

Comments
 (0)