← 返回首页
asyncio: TaskObj_dealloc leaves a refcount-0 Task GC-tracked, hanging or crashing the free-threaded build · Issue #153809 · python/cpython · GitHub
Skip to content

Navigation Menu

Toggle navigation
Sign in
Appearance settings
Search or jump to...

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Include my email address so I can be contacted

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Resetting focus

asyncio: TaskObj_dealloc leaves a refcount-0 Task GC-tracked, hanging or crashing the free-threaded build #153809

New issue
New issue

Description

Crash report

What happened?

On the free-threaded build, TaskObj_dealloc leaves an _asyncio.Task at refcount 0 while it is still GC-tracked, across a _PyEval_StopTheWorld(). A gc.collect() on another thread walks the tracked set during that window and finds the refcount-0 object: the interpreter aborts on a debug build, and hangs or segfaults on a release build.

Root cause

Modules/_asynciomodule.c, TaskObj_dealloc:

static void TaskObj_dealloc(PyObject *self) { if (PyObject_CallFinalizerFromDealloc(self) < 0) { return; // resurrected } // unregister the task after finalization so that // if the task gets resurrected, it remains registered unregister_task((TaskObj *)self); // <-- may _PyEval_StopTheWorld() PyTypeObject *tp = Py_TYPE(self); PyObject_GC_UnTrack(self); // <-- untrack happens only here ... }

The refcount is already 0 on entry, but the Task stays on the GC tracked list across both the finalizer and unregister_task(). For a Task deallocated on a thread other than its creator, unregister_task() takes a stop-the-world branch:

static void unregister_task(TaskObj *task) { #ifdef Py_GIL_DISABLED if (task->task_tid == _Py_ThreadId()) { unregister_task_safe(task); } else { PyThreadState *tstate = _PyThreadState_GET(); _PyEval_StopTheWorld(tstate->interp); // <-- the window unregister_task_safe(task); _PyEval_StartTheWorld(tstate->interp); } #else unregister_task_safe(task); #endif }

FutureObj_dealloc has the same finalizer-before-untrack shape but untracks immediately afterwards, and does not reproduce — the stop-the-world inside unregister_task() is what widens the window enough to be hit reliably.

Reproducer

import sys, gc, threading, _asyncio assert not sys._is_gil_enabled() N, ITERS = 4, 6000 barrier = threading.Barrier(N) def worker(): barrier.wait() for i in range(ITERS): try: _asyncio.Task([1, 2, 3]) # fails the coro check before task_tid is set except Exception: pass if i % 16 == 0: gc.collect() ts = [threading.Thread(target=worker) for _ in range(N)] for t in ts: t.start() for t in ts: t.join()

Task([1, 2, 3]) fails __init__'s coroutine check before task_tid is assigned, so task_tid keeps its zero value and every transient Task takes the stop-the-world branch on teardown. A well-formed Task deallocated on a thread other than its creator takes the identical branch.

Observed

Free-threaded CPython 3.16.0a0 (main, bcf98ddbc40):

build result
--disable-gil debug validate_refcounts abort, exit 134 (10/10)
--disable-gil release interpreter wedges (5/5); segfault also observed

Debug:

Python/gc_free_threading.c:1083: validate_refcounts: Assertion "_Py_REFCNT(((PyObject*)((op)))) > 0" failed: tracked objects must have a reference count > 0 object type name: _asyncio.Task object refcount : 0 Fatal Python error: _PyObject_AssertFailed

Release (no Py_DEBUG, no sanitizer) — gc.collect() spins forever while every other thread parks behind it:

t1 (spinning, 100% CPU): _mi_page_free_collect Objects/mimalloc/page.c:245 <- mi_heap_visit_blocks(visitor=update_refs) <- gc_visit_heaps_lock_held Python/gc_free_threading.c:395 <- deduce_unreachable_heap Python/gc_free_threading.c:1447 <- gc_collect_main Python/gc_free_threading.c:2257 t0 / t2 / t3 (parked): _PyMutex_Lock(&_PyRuntime+169592) <- _PyParkingLot_Park <- _PySemaphore_Wait

Still wedged after 150 s for work that an identical-shape control (a plain class whose __init__ raises — same threads, same gc.collect() cadence, no stop-the-world in the dealloc) completes in 0.11 s. It reproduces with as few as 10 transient Tasks, and with only 2 threads.

Suggested fix

Move PyObject_GC_UnTrack(self) to the top of TaskObj_dealloc, before the finalizer and unregister_task() — the standard dealloc discipline (subtype_dealloc untracks first). Resurrection re-tracks the object, so this remains compatible with gh-142556, whose fix introduced the current finalize → unregister → untrack ordering. An object at refcount 0 should not stay on the GC tracked list across a _PyEval_StopTheWorld().

Related

Full report, reproducer and complete release-build backtraces: https://gist.github.com/devdanzin/df90699fe37599d5502ce39f718f6657

AI Disclaimer: this report was drafted by Claude Code, which also created and ran the reproducer; the maintainer reviewed and edited it.

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

Output from running 'python -VV' on the command line:

Python 3.16.0a0 free-threading build (heads/main:bcf98ddbc40, Jul 4 2026, 15:37:00) [Clang 21.1.8 (6ubuntu1)]

Metadata

Metadata

Labels

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

  • Open in GitHub Copilot app

Footer

© 2026 GitHub, Inc.