Skip to content

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

Description

@devdanzin

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