Skip to content

Commit 5dd920a

Browse files
committed
gh-159437: Combine GC reference passes for full collections
1 parent 8baf990 commit 5dd920a

8 files changed

Lines changed: 228 additions & 18 deletions

File tree

‎Include/internal/pycore_pystate.h‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ _Py_IsMainInterpreter(PyInterpreterState *interp)
6262
return (interp == _PyInterpreterState_Main());
6363
}
6464

65+
/* Return whether tracked objects may belong to another interpreter lifetime.
66+
*
67+
* Result:
68+
* - false before any subinterpreter reservation or runtime reinitialization.
69+
* - true after either event, including across Py_Finalize()/Py_Initialize().
70+
*
71+
* Extension-owned objects may outlive the interpreter whose GC list held them,
72+
* so callers must not assume every tracked object belongs to the current
73+
* runtime when this returns true.
74+
*/
75+
PyAPI_FUNC(bool) _PyInterpreterState_MayHaveForeignGcObjects(void);
76+
6577
extern int _Py_IsMainInterpreterFinalizing(PyInterpreterState *interp);
6678

6779
// Export for _interpreters module.

‎Lib/test/test_gc.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,54 @@ def test_heap_size(self):
14221422
del l
14231423
self.assertEqual(count, _testinternalcapi.get_tracked_heap_size())
14241424

1425+
@requires_gil_enabled("tests fused full-collection reference accounting")
1426+
@unittest.skipIf(_testinternalcapi is None, "requires _testinternalcapi")
1427+
def test_full_collection_preserves_refs_while_untracking_immortal(self):
1428+
"""Untracking an immortal preserves adjacent candidates and heap accounting."""
1429+
import_module("ctypes")
1430+
source = textwrap.dedent("""
1431+
import ctypes
1432+
import gc
1433+
import _testinternalcapi
1434+
1435+
set_immortal = ctypes.pythonapi._Py_SetImmortal
1436+
set_immortal.argtypes = [ctypes.py_object]
1437+
set_immortal.restype = None
1438+
track = ctypes.pythonapi.PyObject_GC_Track
1439+
track.argtypes = [ctypes.py_object]
1440+
track.restype = None
1441+
may_have_foreign_gc_objects = ctypes.pythonapi._PyInterpreterState_MayHaveForeignGcObjects
1442+
may_have_foreign_gc_objects.argtypes = []
1443+
may_have_foreign_gc_objects.restype = ctypes.c_bool
1444+
1445+
gc.disable()
1446+
gc.collect(2)
1447+
candidates_before = gc.get_stats()[2]["candidates"]
1448+
parent = []
1449+
middle = []
1450+
set_immortal(middle)
1451+
track(middle)
1452+
child = []
1453+
parent.append(child)
1454+
child.append(parent)
1455+
1456+
# Traversing parent initializes child before the intervening
1457+
# immortal is removed from the forward chain.
1458+
del parent, child
1459+
assert gc.get_freeze_count() == 0
1460+
assert not may_have_foreign_gc_objects()
1461+
# Every tracked object except middle is a candidate here.
1462+
expected_candidates = _testinternalcapi.get_tracked_heap_size() - 1
1463+
assert gc.collect(2) == 2
1464+
assert not gc.is_tracked(middle)
1465+
assert gc.get_stats()[2]["candidates"] - candidates_before == expected_candidates
1466+
1467+
# get_objects() omits the result list itself.
1468+
objects = gc.get_objects()
1469+
assert _testinternalcapi.get_tracked_heap_size() == len(objects) + 1
1470+
""")
1471+
assert_python_ok("-c", source)
1472+
14251473

14261474
class GCCallbackTests(unittest.TestCase):
14271475
def setUp(self):

‎Lib/test/test_gdb/test_backtrace.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,21 @@ def test_gc(self):
9797
'def bar():\n'
9898
' foo()\n'
9999
'bar()\n')
100+
# Full GIL collections may skip update_refs(). Stop at a visible phase
101+
# after the collector marks collection active.
102+
if support.Py_GIL_DISABLED:
103+
gc_breakpoint = 'update_refs'
104+
else:
105+
gc_breakpoint = 'prepare_callbacks'
100106
# Verify with "py-bt":
101107
gdb_output = self.get_stack_trace(cmd,
102-
cmds_after_breakpoint=['break update_refs', 'continue', 'py-bt'],
108+
cmds_after_breakpoint=[f'break {gc_breakpoint}', 'continue', 'py-bt'],
103109
)
104110
self.assertIn('Garbage-collecting', gdb_output)
105111

106112
# Verify with "py-bt-full":
107113
gdb_output = self.get_stack_trace(cmd,
108-
cmds_after_breakpoint=['break update_refs', 'continue', 'py-bt-full'],
114+
cmds_after_breakpoint=[f'break {gc_breakpoint}', 'continue', 'py-bt-full'],
109115
)
110116
self.assertIn('Garbage-collecting', gdb_output)
111117

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

‎Programs/_testembed.c‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ static int test_repeated_init_and_subinterpreters(void)
191191
for (int i=1; i <= INIT_LOOPS; i++) {
192192
printf("--- Pass %d ---\n", i);
193193
_testembed_initialize();
194+
if (i > 1 && !_PyInterpreterState_MayHaveForeignGcObjects()) {
195+
error("foreign GC object risk was reset");
196+
return 1;
197+
}
194198
mainstate = PyThreadState_Get();
195199

196200
PyEval_ReleaseThread(mainstate);
@@ -204,6 +208,10 @@ static int test_repeated_init_and_subinterpreters(void)
204208
print_subinterp();
205209
Py_EndInterpreter(substate);
206210
}
211+
if (!_PyInterpreterState_MayHaveForeignGcObjects()) {
212+
error("subinterpreter lifetime did not record foreign GC object risk");
213+
return 1;
214+
}
207215

208216
PyThreadState_Swap(mainstate);
209217
print_subinterp();
@@ -279,6 +287,14 @@ static int test_repeated_simple_init(void)
279287
fflush(stderr);
280288

281289
_testembed_initialize();
290+
if (i == 1 && _PyInterpreterState_MayHaveForeignGcObjects()) {
291+
error("initial main interpreter may have foreign GC objects");
292+
return 1;
293+
}
294+
if (i > 1 && !_PyInterpreterState_MayHaveForeignGcObjects()) {
295+
error("runtime reinitialization lost foreign GC object risk");
296+
return 1;
297+
}
282298
Py_Finalize();
283299
printf("Finalized\n"); // Give test_embed some output to check
284300
}

‎Python/gc.c‎

Lines changed: 125 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ typedef struct _gc_runtime_state GCState;
2929
#define GC_NEXT _PyGCHead_NEXT
3030
#define GC_PREV _PyGCHead_PREV
3131

32-
// update_refs() set this bit for all objects in current generation.
33-
// subtract_refs() and move_unreachable() uses this to distinguish
34-
// visited object is in GCing or not.
32+
// Reference initialization marks objects in the current generation.
33+
// Reference subtraction and reachability use this bit to recognize them.
3534
//
3635
// move_unreachable() removes this flag from reachable objects.
3736
// 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
168167
is singly linked until _gc_prev is restored.
169168
170169
gc_refs
171-
At the start of a collection, update_refs() copies the true refcount
172-
to gc_refs, for each object in the generation being collected.
173-
subtract_refs() then adjusts gc_refs so that it equals the number of
174-
times an object is referenced directly from outside the generation
175-
being collected.
170+
The true refcount is copied to gc_refs and reduced by references from
171+
objects in the generation being collected. The remaining count is the
172+
number of direct references from outside that generation. Full collections
173+
may initialize and subtract these counts in one traversal.
176174
177175
PREV_MASK_COLLECTING
178-
Objects in generation being collected are marked PREV_MASK_COLLECTING in
179-
update_refs().
176+
Reference initialization marks objects in the generation being collected
177+
with PREV_MASK_COLLECTING.
180178
181179
182180
_gc_next values
@@ -456,14 +454,52 @@ visit_decref(PyObject *op, void *parent)
456454
return 0;
457455
}
458456

457+
/* Subtract a GC reference, initializing and marking the referent on first visit.
458+
*
459+
* This visitor is used only when every tracked mortal object it may reach
460+
* belongs to the collection. Non-GC, untracked, and immortal referents remain
461+
* unchanged.
462+
*
463+
* Arguments:
464+
* - `op`: the object referenced by the traversed parent.
465+
* - `parent`: the traversed owner used by debug reference validation.
466+
*
467+
* Return zero, as required by traversal callbacks.
468+
*/
469+
static int
470+
gc_decref_and_mark_collecting(PyObject *op, void *parent)
471+
{
472+
OBJECT_STAT_INC(object_visits);
473+
_PyObject_ASSERT(_PyObject_CAST(parent), !_PyObject_IsFreed(op));
474+
475+
if (!_PyObject_IS_GC(op)) {
476+
return 0;
477+
}
478+
479+
PyGC_Head *gc = AS_GC(op);
480+
if (gc_is_collecting(gc)) {
481+
gc_decref(gc);
482+
return 0;
483+
}
484+
485+
if (!_PyObject_GC_IS_TRACKED(op) || _Py_IsImmortal(op)) {
486+
return 0;
487+
}
488+
Py_ssize_t refs = Py_REFCNT(op);
489+
_PyObject_ASSERT(op, refs != 0);
490+
gc_reset_refs(gc, refs - 1);
491+
return 0;
492+
}
493+
459494
int
460495
_PyGC_VisitStackRef(_PyStackRef *ref, visitproc visit, void *arg)
461496
{
462497
// This is a bit tricky! We want to ignore stackrefs with embedded
463498
// refcounts when computing the incoming references, but otherwise treat
464499
// them like normal.
465500
assert(!PyStackRef_IsTaggedInt(*ref));
466-
if (!PyStackRef_RefcountOnObject(*ref) && (visit == visit_decref)) {
501+
if (!PyStackRef_RefcountOnObject(*ref) &&
502+
(visit == visit_decref || visit == gc_decref_and_mark_collecting)) {
467503
return 0;
468504
}
469505
Py_VISIT(PyStackRef_AsPyObjectBorrow(*ref));
@@ -501,6 +537,60 @@ subtract_refs(PyGC_Head *containers)
501537
}
502538
}
503539

540+
/* Initialize and subtract references in one traversal of all ordinary generations.
541+
*
542+
* Mortal candidates retain adjusted gc_refs. Dynamic immortal candidates become
543+
* untracked without changing other candidates' temporary counts.
544+
*
545+
* Prerequisites:
546+
* - This is the initial pass of a full collection, not a resurrection recheck.
547+
* - `containers` is a valid list containing all ordinary generations with no masks set.
548+
* - The permanent generation is empty.
549+
* - `_PyInterpreterState_MayHaveForeignGcObjects()` is false.
550+
*
551+
* Arguments:
552+
* - `containers`: tracked objects belonging to the initial main interpreter.
553+
*
554+
* Return the number of mortal collection candidates.
555+
*/
556+
static Py_ssize_t
557+
update_and_subtract_refs(PyGC_Head *containers)
558+
{
559+
Py_ssize_t candidates = 0;
560+
PyGC_Head *previous = containers;
561+
PyGC_Head *gc = GC_NEXT(containers);
562+
563+
while (gc != containers) {
564+
PyGC_Head *next = GC_NEXT(gc);
565+
PyObject *op = FROM_GC(gc);
566+
567+
if (!gc_is_collecting(gc)) {
568+
if (_Py_IsImmortal(op)) {
569+
assert(!_Py_IsStaticImmortal(op));
570+
// The next node may already hold gc_refs instead of a predecessor,
571+
// so ordinary untracking would corrupt its count.
572+
_PyGCHead_SET_NEXT(previous, next);
573+
gc->_gc_prev &= _PyGC_PREV_MASK_FINALIZED;
574+
gc->_gc_next = 0;
575+
_PyInterpreterState_GET()->gc.heap_size--;
576+
gc = next;
577+
continue;
578+
}
579+
gc_reset_refs(gc, Py_REFCNT(op));
580+
_PyObject_ASSERT(op, gc_get_refs(gc) != 0);
581+
}
582+
583+
candidates++;
584+
(void)Py_TYPE(op)->tp_traverse(op, gc_decref_and_mark_collecting, op);
585+
previous = gc;
586+
gc = next;
587+
}
588+
589+
// The tail must remain valid until reachability restores predecessor links.
590+
containers->_gc_prev = (uintptr_t)previous;
591+
return candidates;
592+
}
593+
504594
/* A traversal callback for move_unreachable. */
505595
static int
506596
visit_reachable(PyObject *op, void *arg)
@@ -1185,6 +1275,8 @@ show_stats_each_generations(GCState *gcstate)
11851275
/* Deduce which objects among "base" are unreachable from outside the list
11861276
and move them to 'unreachable'. The process consist in the following steps:
11871277
1278+
The first two steps may be combined for the initial pass of a full collection.
1279+
11881280
1. Copy all reference counts to a different field (gc_prev is used to hold
11891281
this copy to save memory).
11901282
2. Traverse all objects in "base" and visit all referred objects using
@@ -1204,22 +1296,39 @@ show_stats_each_generations(GCState *gcstate)
12041296
* The "unreachable" list must be uninitialized (this function calls
12051297
gc_list_init over 'unreachable').
12061298
1299+
* "full_collection_pass" means this is the initial pass over all ordinary generations.
1300+
Resurrection rechecks pass false even during a full collection.
1301+
1302+
* "reachable" must point to storage for the number of survivors that remain tracked.
1303+
1304+
* The return value is the number of mortal collection candidates.
1305+
12071306
IMPORTANT: This function leaves 'unreachable' with the NEXT_MASK_UNREACHABLE
12081307
flag set but it does not clear it to skip unnecessary iteration. Before the
12091308
flag is cleared (for example, by using 'clear_unreachable_mask' function or
12101309
by a call to 'move_legacy_finalizers'), the 'unreachable' list is not a normal
12111310
list and we can not use most gc_list_* functions for it. */
12121311
static inline Py_ssize_t
12131312
deduce_unreachable(PyGC_Head *base, PyGC_Head *unreachable,
1214-
Py_ssize_t *reachable) {
1313+
bool full_collection_pass, Py_ssize_t *reachable) {
12151314
validate_list(base, collecting_clear_unreachable_clear);
12161315
/* Using ob_refcnt and gc_refs, calculate which objects in the
12171316
* container set are reachable from outside the set (i.e., have a
12181317
* refcount greater than 0 when all the references within the
12191318
* set are taken into account).
12201319
*/
1221-
Py_ssize_t candidates = update_refs(base); // gc_prev is used for gc_refs
1222-
subtract_refs(base);
1320+
// Lazy initialization can touch any tracked mortal referent. Use it only
1321+
// when all such objects belong to this initial full collection.
1322+
Py_ssize_t candidates;
1323+
if (full_collection_pass &&
1324+
gc_list_is_empty(&get_gc_state()->permanent_generation.head) &&
1325+
!_PyInterpreterState_MayHaveForeignGcObjects()) {
1326+
candidates = update_and_subtract_refs(base);
1327+
}
1328+
else {
1329+
candidates = update_refs(base); // gc_prev is used for gc_refs
1330+
subtract_refs(base);
1331+
}
12231332

12241333
/* Leave everything reachable from outside base in base, and move
12251334
* everything else (in base) to unreachable.
@@ -1290,7 +1399,7 @@ handle_resurrected_objects(PyGC_Head *unreachable, PyGC_Head* still_unreachable,
12901399
// removed so we can skip the expense of clearing the flag.
12911400
PyGC_Head* resurrected = unreachable;
12921401
Py_ssize_t reachable;
1293-
deduce_unreachable(resurrected, still_unreachable, &reachable);
1402+
deduce_unreachable(resurrected, still_unreachable, false, &reachable);
12941403
clear_unreachable_mask(still_unreachable);
12951404

12961405
// 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)
15971706
validate_list(old, collecting_clear_unreachable_clear);
15981707

15991708
Py_ssize_t reachable;
1600-
stats.candidates = deduce_unreachable(young, &unreachable, &reachable);
1709+
stats.candidates = deduce_unreachable(young, &unreachable, generation == NUM_GENERATIONS - 1, &reachable);
16011710

16021711
/* Move reachable objects to next generation. */
16031712
if (young != old) {

‎Python/pystate.c‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,17 @@ _Py_COMP_DIAG_IGNORE_DEPR_DECLS
309309
static const _PyRuntimeState initial = _PyRuntimeState_INIT(_PyRuntime, "");
310310
_Py_COMP_DIAG_POP
311311

312+
/* Monotonic guard for tracked objects that may outlive their interpreter.
313+
* Kept outside _PyRuntime so reinitialization cannot clear it.
314+
*/
315+
static int may_have_foreign_gc_objects;
316+
317+
bool
318+
_PyInterpreterState_MayHaveForeignGcObjects(void)
319+
{
320+
return _Py_atomic_load_int_relaxed(&may_have_foreign_gc_objects) != 0;
321+
}
322+
312323
#define LOCKS_INIT(runtime) \
313324
{ \
314325
&(runtime)->interpreters.mutex, \
@@ -364,6 +375,7 @@ _PyRuntimeState_Init(_PyRuntimeState *runtime)
364375

365376
if (runtime->_initialized) {
366377
// Py_Initialize() must be running again.
378+
_Py_atomic_store_int_relaxed(&may_have_foreign_gc_objects, 1);
367379
// Reset to _PyRuntimeState_INIT.
368380
memcpy(runtime, &initial, sizeof(*runtime));
369381
// Preserve the cookie from the original runtime.
@@ -723,6 +735,8 @@ _PyInterpreterState_New(PyThreadState *tstate, PyInterpreterState **pinterp)
723735
assert(interpreters->main != NULL);
724736
assert(id != 0);
725737

738+
_Py_atomic_store_int_relaxed(&may_have_foreign_gc_objects, 1);
739+
726740
interp = alloc_interpreter();
727741
if (interp == NULL) {
728742
status = _PyStatus_NO_MEMORY();

0 commit comments

Comments
 (0)