GeezerVison is showing github.com — AsyncIO thread-safety issue in Python 3.14's free-threading · Issue #159449 · python/cpython
AsyncIO thread-safety issue in Python 3.14's free-threading #159449DescriptionBug report
When a short-lived thread exits while some of its async tasks are still unfinished, Python moves those leftover tasks onto a shared global list; however, because Linux reuses the OS thread IDs of exited threads for newly created threads, a later thread can be mistaken for the original creator of those tasks and remove them from the shared list without locking, which corrupts the list and causes the next thread cleaning up after an async run to segfault.
Found and fixed by a colleague.
Reproducer:
"""Reproduces a free-threaded CPython 3.14 SIGSEGV in `_asyncio_all_tasks`.
Spawner threads repeatedly start a short-lived worker thread that cancels the
asyncio Tasks left behind by its predecessor, creates a new batch of pending
Tasks, and exits. When a thread exits with pending Tasks, PyThreadState_Clear()
moves them onto the interpreter-wide `interp->asyncio_tasks_head` list. glibc
hands the exited thread's pthread_t to the next thread, so the successor has the
same `_Py_ThreadId()` as the creator of those Tasks. unregister_task() then
takes
its lock-free fast path and unlinks them from the shared list without stopping
the world, and concurrent unlinks corrupt the list.
Runner threads call asyncio.run(). Its shutdown calls asyncio.all_tasks() after
the loop stopped running, which takes the stop-the-world slow path in
_asyncio_all_tasks() and walks the corrupted list until it dereferences NULL.
If the runtime is not affected, the binary runs for --duration_secs and exits 0.
"""
import asyncio
import collections
from collections.abc import Sequence
import sys
import sysconfig
import threading
from typing import Any
from absl import app
from absl import flags
from absl import logging
_DURATION_SECS = flags.DEFINE_float(
"duration_secs", 30.0, "How long to run before declaring a pass."
)
_SPAWNER_THREADS = flags.DEFINE_integer(
"spawner_threads", 6, "Threads that churn short-lived Task-owning threads."
)
_RUNNER_THREADS = flags.DEFINE_integer(
"runner_threads", 2, "Threads that call asyncio.run() in a loop."
)
_TASKS_PER_THREAD = flags.DEFINE_integer(
"tasks_per_thread", 32, "Pending Tasks each short-lived thread leaves."
)
_TaskBatch = list[asyncio.Task[Any]]
class _InertLoop:
"""Minimal event loop stand-in for constructing `asyncio.Task` objects.
`call_soon` drops the Task's first step, so a Task stays pending without a
real event loop holding a reference to it or opening file descriptors.
"""
def get_debug(self) -> bool:
return False
def call_soon(self, *args: object, **kwargs: object) -> None:
del args, kwargs # Unused.
_INERT_LOOP = _InertLoop()
# Keeps recently cancelled Tasks alive so their memory is not reused while a
# corrupted list may still point at them. This turns a nondeterministic
# use-after-free into the deterministic NULL dereference seen in production.
_RECENTLY_CANCELLED: collections.deque[_TaskBatch] = collections.deque(
maxlen=4096
)
async def _noop() -> None:
pass
def _create_pending_tasks(count: int) -> _TaskBatch:
"""Creates `count` Tasks that stay pending until they are cancelled."""
tasks = []
for _ in range(count):
coro = _noop()
task = asyncio.Task(coro, loop=_INERT_LOOP) # type: ignore[arg-type]
# The Task never runs: close its coroutine to avoid "never awaited", and do
# not warn about Tasks that are still pending when the process exits.
coro.close()
setattr(task, "_log_destroy_pending", False)
tasks.append(task)
return tasks
def _short_lived_worker(
handoff: list[_TaskBatch], tasks_per_thread: int
) -> None:
"""Cancels the predecessor's Tasks, then exits leaving new pending Tasks."""
while handoff:
tasks = handoff.pop()
for task in tasks:
# Future.cancel() completes the Task immediately, which unregisters it
# from the asyncio task list (Task.cancel() would only request it).
asyncio.Future.cancel(task)
_RECENTLY_CANCELLED.append(tasks)
handoff.append(_create_pending_tasks(tasks_per_thread))
def _spawner_loop(stop: threading.Event, tasks_per_thread: int) -> None:
"""Starts and joins short-lived worker threads until `stop` is set."""
handoff: list[_TaskBatch] = []
while not stop.is_set():
worker = threading.Thread(
target=_short_lived_worker, args=(handoff, tasks_per_thread)
)
worker.start()
worker.join()
def _runner_loop(stop: threading.Event) -> None:
"""Runs short asyncio.run() calls until `stop` is set."""
while not stop.is_set():
asyncio.run(_noop())
def main(argv: Sequence[str]) -> None:
if len(argv) > 1:
raise app.UsageError("Too many command-line arguments.")
if not sysconfig.get_config_var("Py_GIL_DISABLED"):
raise app.UsageError("Needs a free-threaded Python build; see README.md.")
if sys._is_gil_enabled(): # pylint: disable=protected-access
raise app.UsageError("The GIL is enabled; run with PYTHON_GIL=0.")
logging.info("Running on Python %s with the GIL disabled.", sys.version)
stop = threading.Event()
threads = [
threading.Thread(
target=_spawner_loop, args=(stop, _TASKS_PER_THREAD.value)
)
for _ in range(_SPAWNER_THREADS.value)
]
threads.extend(
threading.Thread(target=_runner_loop, args=(stop,))
for _ in range(_RUNNER_THREADS.value)
)
for thread in threads:
thread.start()
stop.wait(_DURATION_SECS.value)
stop.set()
for thread in threads:
thread.join()
logging.info("PASS: no crash after %.1f seconds.", _DURATION_SECS.value)
if __name__ == "__main__":
app.run(main)
I am sending a PR separately.
CPython versions tested on:
3.14
Operating systems tested on:
Linux
Linked PRs
ActivityAnnaAr321 commented on Oct 15, 2026 This also reproduces in the Python 3.15 free-threaded build AnnaAr321 commented on Oct 15, 2026 The failure log for the reproduction code from the issue description:
assert.h assertion failed at Modules/_asynciomodule.c:4080 in int add_tasks_llist(struct llist_node *, PyListObject *): task->task_state == STATE_PENDING
*** Check failure stack trace: ***
@ 0x55d3f448b239 absl::log_internal::LogMessage::SendToLog()
@ 0x55d3f448b1c7 absl::log_internal::LogMessage::Flush()
@ 0x55d3f43c5d34 __assert_fail
@ 0x55d3f46833e3 add_tasks_llist
@ 0x55d3f4681e88 _asyncio_all_tasks
@ 0x55d3f4a866dd cfunction_vectorcall_FASTCALL_KEYWORDS
@ 0x55d3f4a05e7a _PyObject_VectorcallTstate
@ 0x55d3f4bd3696 _PyEval_EvalFrameDefault
@ 0x55d3f4bcc88c _PyEval_Vector
@ 0x55d3f4a08a34 _PyObject_VectorcallPrepend
@ 0x55d3f4c26ba1 context_run
@ 0x55d3f4bdd6c5 _PyEval_EvalFrameDefault
@ 0x55d3f4bcc88c _PyEval_Vector
@ 0x55d3f4a08a34 _PyObject_VectorcallPrepend
@ 0x55d3f4603195 thread_run
@ 0x55d3f4cb226e pythread_wrapper
@ 0x7fa8e9b440bf start_thread
@ 0x7fa8e9ab467f __clone
nascheme commented on Nov 4, 2026 added 3 commits that reference this issue on Nov 5, 2026gh-159449: Fix free-threaded asyncio race that corrupts the task lists (
MetadataMetadataStatusShow more project fields
DevelopmentNo branches or pull requestsIssue actions
|