GeezerVison is showing github.com — Data race between `gc.get_stats()` and a concurrent collection under free-threading · Issue #151646 · python/cpython
Data race between gc.get_stats() and a concurrent collection under free-threading #151646Labels interpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)topic-free-threadingtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error DescriptionBug report
Bug description:
gc.get_stats() (gc_get_stats_impl) plain-copies the per-generation stats structs
gcstate->generation_stats->{young,old}.items[index] without any lock or atomics,
|
stats[0] = gcstate->generation_stats->young.items[gcstate->generation_stats->young.index]; |
|
stats[1] = gcstate->generation_stats->old[0].items[gcstate->generation_stats->old[0].index]; |
|
stats[2] = gcstate->generation_stats->old[1].items[gcstate->generation_stats->old[1].index]; |
while a collection on another thread writes those same items[index] fields (collections++, collected += m, ...) at the end of gc_collect_main,
|
/* Update stats */ |
|
struct gc_generation_stats *stats = get_stats(gcstate, generation); |
|
stats->ts_start = start; |
|
stats->ts_stop = stop; |
|
stats->collections++; |
|
stats->collected += m; |
|
stats->uncollectable += n; |
|
stats->duration += duration; |
|
stats->candidates += state.candidates; |
so gc.get_stats() races the collector.
Reproducer:
import gc
from threading import Thread
def collector():
for _ in range(50000):
a = {}; a['self'] = a
gc.collect()
def reader():
for _ in range(50000):
gc.get_stats()
threads = [Thread(target=collector) for _ in range(1)]
threads += [Thread(target=reader) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
TSAN Report:
==================
WARNING: ThreadSanitizer: data race (pid=2301505)
Read of size 8 at 0x7fffb41a0180 by thread T2:
#0 gc_get_stats_impl /cpython/Modules/gcmodule.c:379:16
#1 gc_get_stats /cpython/Modules/clinic/gcmodule.c.h:456:12
#2 cfunction_vectorcall_NOARGS /cpython/Objects/methodobject.c:508:24
#3 _PyObject_VectorcallTstate /cpython/./Include/internal/pycore_call.h:144:11
#4 PyObject_Vectorcall /cpython/Objects/call.c:327:12
#5 _Py_VectorCallInstrumentation_StackRefSteal /cpython/Python/ceval.c:768:11
#6 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:1846:35
...
Previous write of size 8 at 0x7fffb41a0180 by thread T1:
#0 gc_collect_main /cpython/Python/gc_free_threading.c:2288:23
#1 _PyGC_Collect /cpython/Python/gc_free_threading.c:2568:12
#2 gc_collect_impl /cpython/Modules/gcmodule.c:93:12
#3 gc_collect /cpython/Modules/clinic/gcmodule.c.h:143:21
#4 _Py_BuiltinCallFastWithKeywords_StackRef /cpython/Python/ceval.c:841:11
#5 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:2508:35
...
SUMMARY: ThreadSanitizer: data race /cpython/Modules/gcmodule.c:379:16 in gc_get_stats_impl
==================
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Linked PRs
Activityzangjiucheng commented on Jun 20, 2026 Hello everyone. I can reproduce the TSAN report from the compiled Linux version.
Root cause (By my understanding)
gc_get_stats_impl copies three gc_generation_stats structs (including double duration and PyTime_t timestamp fields) without any synchronization, while gc_collect_main writes the same fields at the end of each collection cycle — after stop-the-world has resumed, so both threads can be running concurrently.
Fix proposal
Add a PyMutex stats_mutex to _gc_runtime_state (compiled only under Py_GIL_DISABLED) and hold it around both the write in gc_collect_main and the read in gc_get_stats_impl.
A per-field atomic approach won't work here because the reader needs a consistent snapshot across all fields of a single generation — mixing a new collections count with an old duration would yield logically incoherent output. A short mutex is appropriate: the collector is already serialized (only one collection runs at a time), gc.get_stats() is not a hot path, and the critical section is just 7 field writes / 3 struct copies.
The mutex in gc_collect_main is acquired after _Py_StartTheWorld(), so there is no risk of world pause.
Please let me know your suggestion :), and I will attach a PR for this proposal. added a commit that references this issue on Jul 4, 2026gh-151646: Fix data race between gc.get_stats() and GC in free-thread…
MetadataMetadatainterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)topic-free-threadingtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error DevelopmentNo branches or pull requestsIssue actions
|