Bug description:
On the free-threaded build, a thread that calls gc.get_count() in an allocation loop never triggers a cyclic collection, so cyclic garbage is never collected and memory grows without bound. The GIL build is unaffected.
import gc, sys
def loop(poll):
gc.collect()
before = gc.get_stats()[0]["collections"]
blocks_before = sys.getallocatedblocks()
for _ in range(200_000):
a = []
a.append(a) # one cyclic object, immediately unreachable
if poll:
gc.get_count() # drains this thread's local allocation counter
collections = gc.get_stats()[0]["collections"] - before
print(f"poll_get_count={poll!s:<5} collections_during_loop={collections:<4} "
f"young_count={gc.get_count()[0]:<7} "
f"allocated_blocks_grew_by={sys.getallocatedblocks() - blocks_before}")
loop(poll=False)
loop(poll=True)
Output on Python 3.15.0rc2 free-threading build (macOS arm64):
poll_get_count=False collections_during_loop=93 young_count=801 allocated_blocks_grew_by=1788
poll_get_count=True collections_during_loop=0 young_count=199624 allocated_blocks_grew_by=400009
With gc.get_count() in the loop, zero collections run and the ~200,000 cyclic objects are never freed; without it, the collector runs and memory stays bounded. The GIL build runs 88 collections either way.
record_allocation() is the only place that schedules a collection on the allocation path, and only when a thread's local counter reaches LOCAL_ALLOC_COUNT_THRESHOLD (512). gc.get_count() flushes that same counter to the global count and resets it to zero without doing the scheduling check, so frequent callers never reach the threshold and never schedule a collection.
cc @kumaraditya303 @nascheme
CPython versions tested on:
3.14, 3.15, CPython main branch
Operating systems tested on:
macOS
Linked PRs
Bug description:
On the free-threaded build, a thread that calls
gc.get_count()in an allocation loop never triggers a cyclic collection, so cyclic garbage is never collected and memory grows without bound. The GIL build is unaffected.Output on Python 3.15.0rc2 free-threading build (macOS arm64):
With
gc.get_count()in the loop, zero collections run and the ~200,000 cyclic objects are never freed; without it, the collector runs and memory stays bounded. The GIL build runs 88 collections either way.record_allocation()is the only place that schedules a collection on the allocation path, and only when a thread's local counter reachesLOCAL_ALLOC_COUNT_THRESHOLD(512).gc.get_count()flushes that same counter to the global count and resets it to zero without doing the scheduling check, so frequent callers never reach the threshold and never schedule a collection.cc @kumaraditya303 @nascheme
CPython versions tested on:
3.14, 3.15, CPython main branch
Operating systems tested on:
macOS
Linked PRs