From 14a870c1c2c9a2de64f2755efef690b7751bad0d Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Tue, 16 Jun 2026 19:38:49 -0700 Subject: [PATCH 1/7] gh-151518: Avoid STW starvation of attaching threads Free-threaded stop-the-world pauses can otherwise starve a thread trying to reattach after it was suspended while detached. A tight manual gc.collect() loop can release and immediately request the next stop-the-world pause, repeatedly parking the detached thread before it can attach and make progress. Add a distinct _Py_THREAD_SUSPENDED_DETACHED state for tstates parked from DETACHED. tstate_wait_attach() marks an attach waiter only after observing that detached-origin suspended state, and park_detached_threads() skips only those active waiters on later stop-the-world passes. The ordinary successful tstate_try_attach() path remains the baseline CAS-only path. Teach the related stop-the-world paths about both suspended states, including start_the_world() and tstate_delete_common(). Keep the new wait flag after the existing hot free-threaded _PyThreadStateImpl fields so their offsets do not move. Add a free-threaded GC regression test that runs a subprocess with a tight gc.collect() worker and verifies the main thread can reattach after sleeping and stop the worker. --- Include/cpython/pystate.h | 3 +- Include/internal/pycore_pystate.h | 38 ++++++-------- Include/internal/pycore_tstate.h | 10 +++- Lib/test/test_free_threading/test_gc.py | 52 +++++++++++++++++++ ...-06-16-19-20-00.gh-issue-151518.e6v0Js.rst | 2 + Python/pystate.c | 40 ++++++++++++-- 6 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst diff --git a/Include/cpython/pystate.h b/Include/cpython/pystate.h index f367146e262bfe..e551f624b6289b 100644 --- a/Include/cpython/pystate.h +++ b/Include/cpython/pystate.h @@ -118,8 +118,7 @@ struct _ts { int _whence; - /* Thread state (_Py_THREAD_ATTACHED, _Py_THREAD_DETACHED, _Py_THREAD_SUSPENDED). - See Include/internal/pycore_pystate.h for more details. */ + /* Thread state. See Include/internal/pycore_pystate.h for details. */ int state; int py_recursion_remaining; diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index 6caa7a5d30116e..24a5d363a5f46b 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -21,32 +21,28 @@ extern "C" { // interpreter at the same time. Only the "bound" thread may perform the // transitions between "attached" and "detached" on its own PyThreadState. // -// The "suspended" state is used to implement stop-the-world pauses, such as -// for cyclic garbage collection. It is only used in `--disable-gil` builds. -// The "suspended" state is similar to the "detached" state in that in both -// states the thread is not allowed to call most Python APIs. However, unlike -// the "detached" state, a thread may not transition itself out from the -// "suspended" state. Only the thread performing a stop-the-world pause may -// transition a thread from the "suspended" state back to the "detached" state. +// The "suspended" states are used to implement stop-the-world pauses, such as +// for cyclic garbage collection. They are only used in `--disable-gil` builds. +// They are similar to the "detached" state in that the thread is not allowed +// to call most Python APIs. However, unlike the "detached" state, a thread may +// not transition itself out from a "suspended" state. Only the thread +// performing a stop-the-world pause may transition a thread from a "suspended" +// state back to the "detached" state. // // The "shutting down" state is used when the interpreter is being finalized. // Threads in this state can't do anything other than block the OS thread. // (See _PyThreadState_HangThread). // -// State transition diagram: -// -// (bound thread) (stop-the-world thread) -// [attached] <-> [detached] <-> [suspended] -// | ^ -// +---------------------------->---------------------------+ -// (bound thread) -// -// The (bound thread) and (stop-the-world thread) labels indicate which thread -// is allowed to perform the transition. -#define _Py_THREAD_DETACHED 0 -#define _Py_THREAD_ATTACHED 1 -#define _Py_THREAD_SUSPENDED 2 -#define _Py_THREAD_SHUTTING_DOWN 3 +// State transitions: +// Bound thread: attached <-> detached +// attached -> suspended +// Stop-the-world thread: detached <-> suspended-detached +// suspended -> detached +#define _Py_THREAD_DETACHED 0 +#define _Py_THREAD_ATTACHED 1 +#define _Py_THREAD_SUSPENDED 2 +#define _Py_THREAD_SHUTTING_DOWN 3 +#define _Py_THREAD_SUSPENDED_DETACHED 4 /* Check if the current thread is the main thread. diff --git a/Include/internal/pycore_tstate.h b/Include/internal/pycore_tstate.h index eb2b0c84acdc7c..5804c6b203c597 100644 --- a/Include/internal/pycore_tstate.h +++ b/Include/internal/pycore_tstate.h @@ -105,8 +105,14 @@ typedef struct _PyThreadStateImpl { #ifdef Py_GIL_DISABLED // gh-144438: Add padding to ensure that the fields above don't share a - // cache line with other allocations. - char __padding[64]; + // cache line with other allocations. Reuse the first bytes of the padding + // for a cold stop-the-world flag without growing the thread state. + union { + // Set while the thread is waiting to attach after a + // stop-the-world pause suspended it while detached. + int stw_attach_waiting; + char __padding[64]; + }; #endif } _PyThreadStateImpl; diff --git a/Lib/test/test_free_threading/test_gc.py b/Lib/test/test_free_threading/test_gc.py index 30282a111345f8..bcc6b169e6aba6 100644 --- a/Lib/test/test_free_threading/test_gc.py +++ b/Lib/test/test_free_threading/test_gc.py @@ -1,5 +1,8 @@ import unittest +import subprocess +import sys +import textwrap import threading from threading import Thread import time @@ -209,6 +212,55 @@ def reader(): with threading_helper.start_threads(threads): pass + @support.requires_subprocess() + def test_tight_gc_loop_does_not_starve_attach(self): + script = textwrap.dedent(""" + import gc + import importlib + import threading + import time + + modules = ( + "abc", "argparse", "collections", "contextlib", + "decimal", "enum", "functools", "heapq", + "importlib", "inspect", "itertools", "json", + "math", "operator", "random", "re", + ) + for name in modules: + importlib.import_module(name) + + started = threading.Event() + stop = threading.Event() + + def collect(): + gc.collect() + started.set() + while not stop.is_set(): + gc.collect() + + thread = threading.Thread(target=collect, daemon=True) + thread.start() + started.wait() + # Each reattachment must make progress between consecutive pauses. + for _ in range(50): + time.sleep(0.02) + stop.set() + thread.join() + """) + proc = subprocess.run( + [sys.executable, "-I", "-X", "gil=0", "-X", "faulthandler", + "-c", script], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=support.SHORT_TIMEOUT, + ) + self.assertEqual( + proc.returncode, + 0, + f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}", + ) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst new file mode 100644 index 00000000000000..af52eac69c476a --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst @@ -0,0 +1,2 @@ +Fix a free-threaded stop-the-world race that could starve a thread reattaching +after being suspended while detached. diff --git a/Python/pystate.c b/Python/pystate.c index 75bb7520c9ec8c..74365b135ff27f 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -1939,7 +1939,9 @@ tstate_delete_common(PyThreadState *tstate, int release_gil) if (tstate->next) { tstate->next->prev = tstate->prev; } - if (tstate->state != _Py_THREAD_SUSPENDED) { + if (tstate->state != _Py_THREAD_SUSPENDED && + tstate->state != _Py_THREAD_SUSPENDED_DETACHED) + { // Any ongoing stop-the-world request should not wait for us because // our thread is getting deleted. if (interp->stoptheworld.requested) { @@ -2237,9 +2239,22 @@ tstate_set_detached(PyThreadState *tstate, int detached_state) static void tstate_wait_attach(PyThreadState *tstate) { +#ifdef Py_GIL_DISABLED + _PyThreadStateImpl *tstate_impl = (_PyThreadStateImpl *)tstate; + int stw_attach_waiting = 0; +#endif do { int state = _Py_atomic_load_int_relaxed(&tstate->state); - if (state == _Py_THREAD_SUSPENDED) { + if (state == _Py_THREAD_SUSPENDED || + state == _Py_THREAD_SUSPENDED_DETACHED) + { +#ifdef Py_GIL_DISABLED + if (state == _Py_THREAD_SUSPENDED_DETACHED) { + stw_attach_waiting = 1; + _Py_atomic_store_int_relaxed( + &tstate_impl->stw_attach_waiting, 1); + } +#endif // Wait until we're switched out of SUSPENDED to DETACHED. _PyParkingLot_Park(&tstate->state, &state, sizeof(tstate->state), /*timeout=*/-1, NULL, /*detach=*/0); @@ -2253,6 +2268,11 @@ tstate_wait_attach(PyThreadState *tstate) } // Once we're back in DETACHED we can re-attach } while (!tstate_try_attach(tstate)); +#ifdef Py_GIL_DISABLED + if (stw_attach_waiting) { + _Py_atomic_store_int_relaxed(&tstate_impl->stw_attach_waiting, 0); + } +#endif } void @@ -2443,9 +2463,16 @@ park_detached_threads(struct _stoptheworld_state *stw) _Py_FOR_EACH_TSTATE_UNLOCKED(i, t) { int state = _Py_atomic_load_int_relaxed(&t->state); if (state == _Py_THREAD_DETACHED) { + _PyThreadStateImpl *tstate_impl = (_PyThreadStateImpl *)t; + if (_Py_atomic_load_int_relaxed( + &tstate_impl->stw_attach_waiting)) + { + continue; + } // Atomically transition to "suspended" if in "detached" state. if (_Py_atomic_compare_exchange_int( - &t->state, &state, _Py_THREAD_SUSPENDED)) { + &t->state, &state, + _Py_THREAD_SUSPENDED_DETACHED)) { num_parked++; } } @@ -2530,8 +2557,11 @@ start_the_world(struct _stoptheworld_state *stw) _Py_FOR_EACH_STW_INTERP(stw, i) { _Py_FOR_EACH_TSTATE_UNLOCKED(i, t) { if (t != stw->requester) { - assert(_Py_atomic_load_int_relaxed(&t->state) == - _Py_THREAD_SUSPENDED); +#ifndef NDEBUG + int state = _Py_atomic_load_int_relaxed(&t->state); + assert(state == _Py_THREAD_SUSPENDED || + state == _Py_THREAD_SUSPENDED_DETACHED); +#endif _Py_atomic_store_int(&t->state, _Py_THREAD_DETACHED); _PyParkingLot_UnparkAll(&t->state); } From d2adaf428f62530831ca4e6a2fbcbb62b86b0264 Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Tue, 22 Sep 2026 07:51:19 -0700 Subject: [PATCH 2/7] gh-151518: Track attach waiters in the thread state Represent active attach waiters with suspended-waiting and detached-waiting states. Preserve waiter registration when the world resumes, and keep passive detached threads immediately parkable. Restore the thread-state padding and retain the single-CAS uncontended attach path. --- Include/internal/pycore_pystate.h | 17 +++++--- Include/internal/pycore_tstate.h | 10 +---- Python/pystate.c | 72 +++++++++++++++---------------- 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index 24a5d363a5f46b..c9f5fa52972179 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -24,10 +24,10 @@ extern "C" { // The "suspended" states are used to implement stop-the-world pauses, such as // for cyclic garbage collection. They are only used in `--disable-gil` builds. // They are similar to the "detached" state in that the thread is not allowed -// to call most Python APIs. However, unlike the "detached" state, a thread may -// not transition itself out from a "suspended" state. Only the thread -// performing a stop-the-world pause may transition a thread from a "suspended" -// state back to the "detached" state. +// to call most Python APIs. A suspended thread trying to attach marks itself +// as "suspended-waiting". Only the thread performing a stop-the-world pause +// may resume a suspended thread, moving it to "detached" or "detached-waiting". +// A "detached-waiting" thread must attach before it can be suspended again. // // The "shutting down" state is used when the interpreter is being finalized. // Threads in this state can't do anything other than block the OS thread. @@ -36,13 +36,16 @@ extern "C" { // State transitions: // Bound thread: attached <-> detached // attached -> suspended -// Stop-the-world thread: detached <-> suspended-detached -// suspended -> detached +// suspended -> suspended-waiting +// detached-waiting -> attached +// Stop-the-world thread: detached <-> suspended +// suspended-waiting -> detached-waiting #define _Py_THREAD_DETACHED 0 #define _Py_THREAD_ATTACHED 1 #define _Py_THREAD_SUSPENDED 2 #define _Py_THREAD_SHUTTING_DOWN 3 -#define _Py_THREAD_SUSPENDED_DETACHED 4 +#define _Py_THREAD_SUSPENDED_WAITING 4 +#define _Py_THREAD_DETACHED_WAITING 5 /* Check if the current thread is the main thread. diff --git a/Include/internal/pycore_tstate.h b/Include/internal/pycore_tstate.h index 5804c6b203c597..eb2b0c84acdc7c 100644 --- a/Include/internal/pycore_tstate.h +++ b/Include/internal/pycore_tstate.h @@ -105,14 +105,8 @@ typedef struct _PyThreadStateImpl { #ifdef Py_GIL_DISABLED // gh-144438: Add padding to ensure that the fields above don't share a - // cache line with other allocations. Reuse the first bytes of the padding - // for a cold stop-the-world flag without growing the thread state. - union { - // Set while the thread is waiting to attach after a - // stop-the-world pause suspended it while detached. - int stw_attach_waiting; - char __padding[64]; - }; + // cache line with other allocations. + char __padding[64]; #endif } _PyThreadStateImpl; diff --git a/Python/pystate.c b/Python/pystate.c index 74365b135ff27f..953f3dfc3d5b2f 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -1939,8 +1939,9 @@ tstate_delete_common(PyThreadState *tstate, int release_gil) if (tstate->next) { tstate->next->prev = tstate->prev; } - if (tstate->state != _Py_THREAD_SUSPENDED && - tstate->state != _Py_THREAD_SUSPENDED_DETACHED) + int state = _Py_atomic_load_int_relaxed(&tstate->state); + if (state != _Py_THREAD_SUSPENDED && + state != _Py_THREAD_SUSPENDED_WAITING) { // Any ongoing stop-the-world request should not wait for us because // our thread is getting deleted. @@ -2239,23 +2240,20 @@ tstate_set_detached(PyThreadState *tstate, int detached_state) static void tstate_wait_attach(PyThreadState *tstate) { -#ifdef Py_GIL_DISABLED - _PyThreadStateImpl *tstate_impl = (_PyThreadStateImpl *)tstate; - int stw_attach_waiting = 0; -#endif - do { + for (;;) { int state = _Py_atomic_load_int_relaxed(&tstate->state); - if (state == _Py_THREAD_SUSPENDED || - state == _Py_THREAD_SUSPENDED_DETACHED) - { -#ifdef Py_GIL_DISABLED - if (state == _Py_THREAD_SUSPENDED_DETACHED) { - stw_attach_waiting = 1; - _Py_atomic_store_int_relaxed( - &tstate_impl->stw_attach_waiting, 1); + if (state == _Py_THREAD_SUSPENDED) { + // Register an active attach waiter. The next stop-the-world + // request must let this thread attach before suspending it again. + if (!_Py_atomic_compare_exchange_int( + &tstate->state, &state, _Py_THREAD_SUSPENDED_WAITING)) + { + continue; } -#endif - // Wait until we're switched out of SUSPENDED to DETACHED. + state = _Py_THREAD_SUSPENDED_WAITING; + } + if (state == _Py_THREAD_SUSPENDED_WAITING) { + // Wait until the stop-the-world thread lets us attach. _PyParkingLot_Park(&tstate->state, &state, sizeof(tstate->state), /*timeout=*/-1, NULL, /*detach=*/0); } @@ -2264,15 +2262,15 @@ tstate_wait_attach(PyThreadState *tstate) _PyThreadState_HangThread(tstate); } else { - assert(state == _Py_THREAD_DETACHED); + assert(state == _Py_THREAD_DETACHED || + state == _Py_THREAD_DETACHED_WAITING); + if (_Py_atomic_compare_exchange_int( + &tstate->state, &state, _Py_THREAD_ATTACHED)) + { + return; + } } - // Once we're back in DETACHED we can re-attach - } while (!tstate_try_attach(tstate)); -#ifdef Py_GIL_DISABLED - if (stw_attach_waiting) { - _Py_atomic_store_int_relaxed(&tstate_impl->stw_attach_waiting, 0); } -#endif } void @@ -2462,17 +2460,12 @@ park_detached_threads(struct _stoptheworld_state *stw) _Py_FOR_EACH_STW_INTERP(stw, i) { _Py_FOR_EACH_TSTATE_UNLOCKED(i, t) { int state = _Py_atomic_load_int_relaxed(&t->state); + // DETACHED_WAITING threads remain counted until they attach and + // stop, so repeated pauses cannot prevent them from attaching. if (state == _Py_THREAD_DETACHED) { - _PyThreadStateImpl *tstate_impl = (_PyThreadStateImpl *)t; - if (_Py_atomic_load_int_relaxed( - &tstate_impl->stw_attach_waiting)) - { - continue; - } // Atomically transition to "suspended" if in "detached" state. if (_Py_atomic_compare_exchange_int( - &t->state, &state, - _Py_THREAD_SUSPENDED_DETACHED)) { + &t->state, &state, _Py_THREAD_SUSPENDED)) { num_parked++; } } @@ -2557,12 +2550,17 @@ start_the_world(struct _stoptheworld_state *stw) _Py_FOR_EACH_STW_INTERP(stw, i) { _Py_FOR_EACH_TSTATE_UNLOCKED(i, t) { if (t != stw->requester) { -#ifndef NDEBUG int state = _Py_atomic_load_int_relaxed(&t->state); - assert(state == _Py_THREAD_SUSPENDED || - state == _Py_THREAD_SUSPENDED_DETACHED); -#endif - _Py_atomic_store_int(&t->state, _Py_THREAD_DETACHED); + int next_state; + do { + assert(state == _Py_THREAD_SUSPENDED || + state == _Py_THREAD_SUSPENDED_WAITING); + next_state = (state == _Py_THREAD_SUSPENDED_WAITING + ? _Py_THREAD_DETACHED_WAITING + : _Py_THREAD_DETACHED); + // Retry if an attach waiter registered concurrently. + } while (!_Py_atomic_compare_exchange_int( + &t->state, &state, next_state)); _PyParkingLot_UnparkAll(&t->state); } } From 09c0d2a64fca33e515fd60a4ae87ac4b891c1196 Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Tue, 22 Sep 2026 07:51:31 -0700 Subject: [PATCH 3/7] gh-151518: Simplify the GC fairness regression Use explicit warmup imports and a joined non-daemon collector. Run the child through script_helper with a faulthandler watchdog so a stalled attachment still fails with a traceback. Describe the bug as a fairness issue in the NEWS entry. --- Lib/test/test_free_threading/test_gc.py | 53 +++++++++---------- ...-06-16-19-20-00.gh-issue-151518.e6v0Js.rst | 4 +- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/Lib/test/test_free_threading/test_gc.py b/Lib/test/test_free_threading/test_gc.py index bcc6b169e6aba6..6e2626583dcc56 100644 --- a/Lib/test/test_free_threading/test_gc.py +++ b/Lib/test/test_free_threading/test_gc.py @@ -1,7 +1,5 @@ import unittest -import subprocess -import sys import textwrap import threading from threading import Thread @@ -11,7 +9,7 @@ import weakref from test import support -from test.support import threading_helper +from test.support import script_helper, threading_helper class MyObj: @@ -214,20 +212,32 @@ def reader(): @support.requires_subprocess() def test_tight_gc_loop_does_not_starve_attach(self): - script = textwrap.dedent(""" + script = textwrap.dedent(f""" + import faulthandler + + faulthandler.dump_traceback_later({support.SHORT_TIMEOUT}, exit=True) + import gc - import importlib import threading import time - modules = ( - "abc", "argparse", "collections", "contextlib", - "decimal", "enum", "functools", "heapq", - "importlib", "inspect", "itertools", "json", - "math", "operator", "random", "re", - ) - for name in modules: - importlib.import_module(name) + # Add GC-tracked objects to lengthen the stop-the-world pauses. + import abc + import argparse + import collections + import contextlib + import decimal + import enum + import functools + import heapq + import importlib + import inspect + import itertools + import json + import math + import operator + import random + import re started = threading.Event() stop = threading.Event() @@ -238,7 +248,7 @@ def collect(): while not stop.is_set(): gc.collect() - thread = threading.Thread(target=collect, daemon=True) + thread = threading.Thread(target=collect) thread.start() started.wait() # Each reattachment must make progress between consecutive pauses. @@ -246,20 +256,9 @@ def collect(): time.sleep(0.02) stop.set() thread.join() + faulthandler.cancel_dump_traceback_later() """) - proc = subprocess.run( - [sys.executable, "-I", "-X", "gil=0", "-X", "faulthandler", - "-c", script], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=support.SHORT_TIMEOUT, - ) - self.assertEqual( - proc.returncode, - 0, - f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}", - ) + script_helper.assert_python_ok("-X", "gil=0", "-c", script) if __name__ == "__main__": diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst index af52eac69c476a..5da739e8b73223 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst @@ -1,2 +1,2 @@ -Fix a free-threaded stop-the-world race that could starve a thread reattaching -after being suspended while detached. +Fix a free-threaded stop-the-world fairness issue that could starve a thread +reattaching after being suspended while detached. From cec1cce55eab978f3f75d3e06628f492f0d467d9 Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Wed, 23 Sep 2026 10:41:49 -0700 Subject: [PATCH 4/7] gh-151518: Exercise STW fairness directly --- Lib/test/test_free_threading/test_gc.py | 54 +------------------ .../test_free_threading/test_threading.py | 39 +++++++++++++- Modules/_testinternalcapi.c | 10 ++++ Python/pystate.c | 29 +++++++--- 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/Lib/test/test_free_threading/test_gc.py b/Lib/test/test_free_threading/test_gc.py index 6e2626583dcc56..c6fb3e7f7efe86 100644 --- a/Lib/test/test_free_threading/test_gc.py +++ b/Lib/test/test_free_threading/test_gc.py @@ -1,6 +1,5 @@ import unittest -import textwrap import threading from threading import Thread import time @@ -8,8 +7,7 @@ import gc import weakref -from test import support -from test.support import script_helper, threading_helper +from test.support import threading_helper class MyObj: @@ -210,56 +208,6 @@ def reader(): with threading_helper.start_threads(threads): pass - @support.requires_subprocess() - def test_tight_gc_loop_does_not_starve_attach(self): - script = textwrap.dedent(f""" - import faulthandler - - faulthandler.dump_traceback_later({support.SHORT_TIMEOUT}, exit=True) - - import gc - import threading - import time - - # Add GC-tracked objects to lengthen the stop-the-world pauses. - import abc - import argparse - import collections - import contextlib - import decimal - import enum - import functools - import heapq - import importlib - import inspect - import itertools - import json - import math - import operator - import random - import re - - started = threading.Event() - stop = threading.Event() - - def collect(): - gc.collect() - started.set() - while not stop.is_set(): - gc.collect() - - thread = threading.Thread(target=collect) - thread.start() - started.wait() - # Each reattachment must make progress between consecutive pauses. - for _ in range(50): - time.sleep(0.02) - stop.set() - thread.join() - faulthandler.cancel_dump_traceback_later() - """) - script_helper.assert_python_ok("-X", "gil=0", "-c", script) - if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_free_threading/test_threading.py b/Lib/test/test_free_threading/test_threading.py index b5a5ca272b9405..ecc42db7b44f3a 100644 --- a/Lib/test/test_free_threading/test_threading.py +++ b/Lib/test/test_free_threading/test_threading.py @@ -1,5 +1,8 @@ import unittest -from test.support import threading_helper +import textwrap + +from test import support +from test.support import script_helper, threading_helper threading_helper.requires_working_threading(module=True) @@ -22,5 +25,39 @@ def mutate_thread(): threading_helper.run_concurrently([repr_thread, mutate_thread]) +class TestThreadState(unittest.TestCase): + @support.requires_subprocess() + def test_tight_stw_loop_does_not_starve_attach(self): + script = textwrap.dedent(f""" + import faulthandler + + faulthandler.dump_traceback_later({support.SHORT_TIMEOUT}, exit=True) + + import _testinternalcapi + import threading + import time + + started = threading.Event() + stop = threading.Event() + + def stop_the_world(): + _testinternalcapi.test_stop_the_world() + started.set() + while not stop.is_set(): + _testinternalcapi.test_stop_the_world() + + thread = threading.Thread(target=stop_the_world) + thread.start() + started.wait() + # Each reattachment must make progress between consecutive pauses. + for _ in range(50): + time.sleep(0.02) + stop.set() + thread.join() + faulthandler.cancel_dump_traceback_later() + """) + script_helper.assert_python_ok("-X", "gil=0", "-c", script) + + if __name__ == "__main__": unittest.main() diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index c01ac65dd4cc04..0f5f2285681e64 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -208,6 +208,15 @@ get_stack_margin(PyObject *self, PyObject *Py_UNUSED(args)) return PyLong_FromSize_t(_PyOS_STACK_MARGIN_BYTES); } +static PyObject * +test_stop_the_world(PyObject *self, PyObject *Py_UNUSED(args)) +{ + PyInterpreterState *interp = _PyInterpreterState_GET(); + _PyEval_StopTheWorld(interp); + _PyEval_StartTheWorld(interp); + Py_RETURN_NONE; +} + #ifdef MS_WINDOWS static const char * classify_address(uintptr_t addr, int jit_enabled, PyInterpreterState *interp) @@ -3298,6 +3307,7 @@ static PyMethodDef module_functions[] = { {"get_c_recursion_remaining", get_c_recursion_remaining, METH_NOARGS}, {"get_stack_pointer", get_stack_pointer, METH_NOARGS}, {"get_stack_margin", get_stack_margin, METH_NOARGS}, + {"test_stop_the_world", test_stop_the_world, METH_NOARGS}, {"classify_stack_addresses", classify_stack_addresses, METH_VARARGS}, {"get_jit_code_ranges", get_jit_code_ranges, METH_NOARGS}, {"get_jit_backend", get_jit_backend, METH_NOARGS}, diff --git a/Python/pystate.c b/Python/pystate.c index 953f3dfc3d5b2f..bf8b7b7782c828 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -2226,6 +2226,22 @@ tstate_try_attach(PyThreadState *tstate) #endif } +static int +tstate_try_attach_detached(PyThreadState *tstate, int *state) +{ +#ifdef Py_GIL_DISABLED + assert(*state == _Py_THREAD_DETACHED || + *state == _Py_THREAD_DETACHED_WAITING); + return _Py_atomic_compare_exchange_int(&tstate->state, + state, + _Py_THREAD_ATTACHED); +#else + assert(tstate->state == _Py_THREAD_DETACHED); + tstate->state = _Py_THREAD_ATTACHED; + return 1; +#endif +} + static void tstate_set_detached(PyThreadState *tstate, int detached_state) { @@ -2264,9 +2280,7 @@ tstate_wait_attach(PyThreadState *tstate) else { assert(state == _Py_THREAD_DETACHED || state == _Py_THREAD_DETACHED_WAITING); - if (_Py_atomic_compare_exchange_int( - &tstate->state, &state, _Py_THREAD_ATTACHED)) - { + if (tstate_try_attach_detached(tstate, &state)) { return; } } @@ -2555,9 +2569,12 @@ start_the_world(struct _stoptheworld_state *stw) do { assert(state == _Py_THREAD_SUSPENDED || state == _Py_THREAD_SUSPENDED_WAITING); - next_state = (state == _Py_THREAD_SUSPENDED_WAITING - ? _Py_THREAD_DETACHED_WAITING - : _Py_THREAD_DETACHED); + if (state == _Py_THREAD_SUSPENDED_WAITING) { + next_state = _Py_THREAD_DETACHED_WAITING; + } + else { + next_state = _Py_THREAD_DETACHED; + } // Retry if an attach waiter registered concurrently. } while (!_Py_atomic_compare_exchange_int( &t->state, &state, next_state)); From c34abd44ea5c1fbd3f3f42da6f19e79a242dd2a8 Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Wed, 23 Sep 2026 14:30:56 -0700 Subject: [PATCH 5/7] gh-151518: Preserve attach waiters when resuming BRC suspensions Share the waiter-aware resume transition between stop-the-world pauses and biased reference count merging so a concurrently registering waiter keeps its opportunity to attach. Restore the support import needed by the new upstream GC regression and align the thread-state constants. --- Include/internal/pycore_pystate.h | 20 +++++++++------- Lib/test/test_free_threading/test_gc.py | 1 + Python/pystate.c | 32 ++++++++++++------------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Include/internal/pycore_pystate.h b/Include/internal/pycore_pystate.h index c9f5fa52972179..8b8f0a98bedf76 100644 --- a/Include/internal/pycore_pystate.h +++ b/Include/internal/pycore_pystate.h @@ -21,12 +21,13 @@ extern "C" { // interpreter at the same time. Only the "bound" thread may perform the // transitions between "attached" and "detached" on its own PyThreadState. // -// The "suspended" states are used to implement stop-the-world pauses, such as -// for cyclic garbage collection. They are only used in `--disable-gil` builds. +// The "suspended" states are used to implement stop-the-world pauses and to +// merge biased reference counts on behalf of detached threads. They are only +// used in `--disable-gil` builds. // They are similar to the "detached" state in that the thread is not allowed // to call most Python APIs. A suspended thread trying to attach marks itself -// as "suspended-waiting". Only the thread performing a stop-the-world pause -// may resume a suspended thread, moving it to "detached" or "detached-waiting". +// as "suspended-waiting". Only the thread responsible for suspending it may +// resume it, moving it to "detached" or "detached-waiting". // A "detached-waiting" thread must attach before it can be suspended again. // // The "shutting down" state is used when the interpreter is being finalized. @@ -38,10 +39,10 @@ extern "C" { // attached -> suspended // suspended -> suspended-waiting // detached-waiting -> attached -// Stop-the-world thread: detached <-> suspended +// Suspending thread: detached <-> suspended // suspended-waiting -> detached-waiting -#define _Py_THREAD_DETACHED 0 -#define _Py_THREAD_ATTACHED 1 +#define _Py_THREAD_DETACHED 0 +#define _Py_THREAD_ATTACHED 1 #define _Py_THREAD_SUSPENDED 2 #define _Py_THREAD_SHUTTING_DOWN 3 #define _Py_THREAD_SUSPENDED_WAITING 4 @@ -161,8 +162,9 @@ extern void _PyThreadState_Suspend(PyThreadState *tstate); // Returns 1 on success, 0 if the thread was not in the "detached" state. extern int _PyThreadState_TrySuspendDetached(PyThreadState *tstate); -// Undo a successful _PyThreadState_TrySuspendDetached(): switch the thread -// back to "detached" and wake it if it is waiting to attach. +// Resume a thread suspended by _PyThreadState_TrySuspendDetached() or a +// stop-the-world pause: switch it back to "detached" or "detached-waiting" +// and wake it if it is waiting to attach. extern void _PyThreadState_ResumeDetached(PyThreadState *tstate); #endif diff --git a/Lib/test/test_free_threading/test_gc.py b/Lib/test/test_free_threading/test_gc.py index c6fb3e7f7efe86..30282a111345f8 100644 --- a/Lib/test/test_free_threading/test_gc.py +++ b/Lib/test/test_free_threading/test_gc.py @@ -7,6 +7,7 @@ import gc import weakref +from test import support from test.support import threading_helper diff --git a/Python/pystate.c b/Python/pystate.c index bf8b7b7782c828..34b2ec0c24d643 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -2426,8 +2426,20 @@ void _PyThreadState_ResumeDetached(PyThreadState *tstate) { assert(tstate != _PyThreadState_GET()); - assert(_Py_atomic_load_int_relaxed(&tstate->state) == _Py_THREAD_SUSPENDED); - _Py_atomic_store_int(&tstate->state, _Py_THREAD_DETACHED); + int state = _Py_atomic_load_int_relaxed(&tstate->state); + int next_state; + do { + assert(state == _Py_THREAD_SUSPENDED || + state == _Py_THREAD_SUSPENDED_WAITING); + if (state == _Py_THREAD_SUSPENDED_WAITING) { + next_state = _Py_THREAD_DETACHED_WAITING; + } + else { + next_state = _Py_THREAD_DETACHED; + } + // Retry if an attach waiter registered concurrently. + } while (!_Py_atomic_compare_exchange_int( + &tstate->state, &state, next_state)); // Wake the thread if it is parked in tstate_wait_attach(). _PyParkingLot_UnparkAll(&tstate->state); } @@ -2564,21 +2576,7 @@ start_the_world(struct _stoptheworld_state *stw) _Py_FOR_EACH_STW_INTERP(stw, i) { _Py_FOR_EACH_TSTATE_UNLOCKED(i, t) { if (t != stw->requester) { - int state = _Py_atomic_load_int_relaxed(&t->state); - int next_state; - do { - assert(state == _Py_THREAD_SUSPENDED || - state == _Py_THREAD_SUSPENDED_WAITING); - if (state == _Py_THREAD_SUSPENDED_WAITING) { - next_state = _Py_THREAD_DETACHED_WAITING; - } - else { - next_state = _Py_THREAD_DETACHED; - } - // Retry if an attach waiter registered concurrently. - } while (!_Py_atomic_compare_exchange_int( - &t->state, &state, next_state)); - _PyParkingLot_UnparkAll(&t->state); + _PyThreadState_ResumeDetached(t); } } } From 15c4b6dfdb9d2b0e8580120df609d6b4da6d9f74 Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Thu, 24 Sep 2026 09:55:25 -0700 Subject: [PATCH 6/7] gh-151518: Strengthen the STW attach fairness regression Hold each test pause for 10 ms and repeat pauses in C to reduce the opportunities for a waiting thread to run between pause requests. Clarify that parking rechecks the thread state before sleeping. --- Modules/_testinternalcapi.c | 11 +++++++++-- Python/pystate.c | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index 0f5f2285681e64..7d48d5e1839548 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -30,6 +30,7 @@ #include "pycore_instruction_sequence.h" // _PyInstructionSequence_New() #include "pycore_interpframe.h" // _PyFrame_GetFunction() #include "pycore_jit.h" // _PyJIT_AddressInJitCode() +#include "pycore_lock.h" // PyEvent_WaitTimed() #include "pycore_object.h" // _PyObject_IsFreed() #include "pycore_optimizer.h" // _Py_Executor_DependsOn #include "pycore_pathconfig.h" // _PyPathConfig_ClearGlobal() @@ -212,8 +213,14 @@ static PyObject * test_stop_the_world(PyObject *self, PyObject *Py_UNUSED(args)) { PyInterpreterState *interp = _PyInterpreterState_GET(); - _PyEval_StopTheWorld(interp); - _PyEval_StartTheWorld(interp); + // Request consecutive pauses without running Python code between them. + for (int i = 0; i < 100; i++) { + _PyEval_StopTheWorld(interp); + // Give detached threads time to try to reattach during the pause. + PyEvent event = {0}; + PyEvent_WaitTimed(&event, 10 * 1000 * 1000, /*detach=*/0); + _PyEval_StartTheWorld(interp); + } Py_RETURN_NONE; } diff --git a/Python/pystate.c b/Python/pystate.c index 34b2ec0c24d643..32a2a9ea9976fb 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -2269,7 +2269,7 @@ tstate_wait_attach(PyThreadState *tstate) state = _Py_THREAD_SUSPENDED_WAITING; } if (state == _Py_THREAD_SUSPENDED_WAITING) { - // Wait until the stop-the-world thread lets us attach. + // Park rechecks the state before sleeping, in case we were resumed. _PyParkingLot_Park(&tstate->state, &state, sizeof(tstate->state), /*timeout=*/-1, NULL, /*detach=*/0); } From 4093ea6429d65a31ef6ecf059667be9a5613eed4 Mon Sep 17 00:00:00 2001 From: Trent Nelson Date: Thu, 24 Sep 2026 10:17:43 -0700 Subject: [PATCH 7/7] gh-151518: Restrict STW test pauses to free-threaded builds STW is a no-op in GIL builds. Avoid waiting in the helper there, since WASI cannot perform the blocking futex operation. --- Modules/_testinternalcapi.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index 7d48d5e1839548..049b990d65be5f 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -212,6 +212,7 @@ get_stack_margin(PyObject *self, PyObject *Py_UNUSED(args)) static PyObject * test_stop_the_world(PyObject *self, PyObject *Py_UNUSED(args)) { +#ifdef Py_GIL_DISABLED PyInterpreterState *interp = _PyInterpreterState_GET(); // Request consecutive pauses without running Python code between them. for (int i = 0; i < 100; i++) { @@ -221,6 +222,7 @@ test_stop_the_world(PyObject *self, PyObject *Py_UNUSED(args)) PyEvent_WaitTimed(&event, 10 * 1000 * 1000, /*detach=*/0); _PyEval_StartTheWorld(interp); } +#endif Py_RETURN_NONE; }