Skip to content

gh-151518: Avoid STW starvation of attaching threads - #152826

Open
tpn wants to merge 7 commits into
python:mainfrom
tpn:gh-151518-stw-attach-fairness
Open

tpn wants to merge 7 commits into
python:mainfrom
tpn:gh-151518-stw-attach-fairness

Conversation

@tpn

@tpn tpn commented Jul 1, 2026 •

Copy link
Copy Markdown
Member

Fixes #151518.

Repeated stop-the-world requests can starve a thread returning from a detached operation. After start_the_world() restores its state to DETACHED, the next requester can suspend it again before its OS thread completes _PyThreadState_Attach().

This change distinguishes thread states suspended while detached from those suspended while attached. An active attach waiter records that it encountered the detached-origin suspension; subsequent stop-the-world scans let that waiter attach before suspending it again. The waiter remains in the stop-the-world countdown until it actually stops. Threads detached for sleep or I/O without an active attach attempt remain immediately parkable, and the uncontended attach path remains a single CAS.

The waiting flag reuses trailing _PyThreadStateImpl padding. Existing state numbers are preserved, and both suspended states resume directly to DETACHED.

The subprocess regression explicitly uses -X gil=0, waits for the collector to start, and repeatedly returns from sleep while another thread calls gc.collect() in a tight loop. It performs 50 reattachments with one second of total intentional sleep and relies on the test framework's outer timeout for stalled progress or shutdown.

Validation on Linux x86-64 after rebasing onto 89c67a98aee:

  • Debug free-threaded build (--with-pydebug --enable-safety --enable-slower-safety --disable-gil): test_free_threading test_gc test_threading test_capi test_embed passed, 2,211 tests run. test_interpreters is unsupported in this build and skipped.
  • Release free-threaded build: test_free_threading test_gc test_threading test_embed passed, 647 tests run.
  • Debug free-threaded binary with -X gil=1: GC, threading, embedding, and free-threaded GC tests passed, 403 tests run. The final regression also passed separately with this parent setting.
  • Normal GIL build, using a separate bytecode cache: test_gc test_threading test_capi test_embed test_interpreters passed, 2,132 tests run. test_free_threading skipped as expected.
  • Final regression passed -R 3:3 reference-leak checking. Seven additional stress trials passed with mixed global/local pauses, repeated detach/reattach, and workers blocked waiting for collector completion.
  • Compiled layout comparison matched all 64 common measurements: _PyThreadStateImpl remains 18,200 bytes, and the new flag overlays padding at offset 18,136. make patchcheck and git diff --check passed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a free-threaded stop-the-world (STW) fairness issue where repeated STW requests could repeatedly re-park a thread that is trying to reattach, leading to starvation (notably after returning from detached operations like time.sleep()).

Changes:

  • Add a distinct thread-state for threads suspended while detached (_Py_THREAD_SUSPENDED_DETACHED) and update STW parking/unparking logic accordingly.
  • Track “attach-waiters” across STW passes via a new cold _PyThreadStateImpl flag (stw_attach_waiting) reused from existing padding, so subsequent STW requests avoid re-parking those threads until they attach.
  • Add a regression test exercising a tight gc.collect() loop vs. a sleeping thread, plus a NEWS entry and updated state documentation.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Python/pystate.c Implements the new suspended-detached state and attach-waiter logic to prevent STW starvation.
Include/internal/pycore_pystate.h Documents the new suspended-detached state and updates state constants/diagram.
Include/internal/pycore_tstate.h Adds stw_attach_waiting in reused tail padding for free-threaded builds.
Lib/test/test_free_threading/test_gc.py Adds a subprocess regression test for STW starvation during tight GC loops.
Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst Announces the fix in NEWS.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Lib/test/test_free_threading/test_gc.py Outdated
@ngoldbaum

ngoldbaum commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

@kumaraditya303 can you add this to your PR review queue?

Comment thread Include/internal/pycore_tstate.h Outdated
Comment thread Lib/test/test_free_threading/test_gc.py Outdated
Comment thread Misc/NEWS.d/next/Core_and_Builtins/2026-06-16-19-20-00.gh-issue-151518.e6v0Js.rst Outdated
Comment thread Lib/test/test_free_threading/test_gc.py Outdated
Comment thread Lib/test/test_free_threading/test_gc.py Outdated

@dpdani dpdani left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks this looks much better now.

I would also like @colesbury or @mpage to have a look at this.

Comment thread Python/pystate.c Outdated
Comment thread Lib/test/test_free_threading/test_gc.py Outdated
Comment thread Python/pystate.c
tpn added 5 commits September 23, 2026 14:25
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.
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.
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.
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.
@tpn
tpn force-pushed the gh-151518-stw-attach-fairness branch from bc20719 to c34abd4 Compare September 23, 2026 21:31
@dpdani

dpdani commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Thanks for your changes. Unfortunately the test doesn't fail for me on main. The added C testing function should probably have some waiting time between starting the STW pause and ending it.

@ngoldbaum

Copy link
Copy Markdown
Contributor

should probably have some waiting time between starting the STW pause and ending it

Or even better: deterministic synchronization.

Comment thread Python/pystate.c
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.
@tpn

tpn commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

Added 100 consecutive 10 ms pauses in C in 15c4b6d. With the same test changes, the unfixed PR base now times out with 4 or 64 CPUs; the fixed build passes with 1, 4, and 64 CPUs, and under TSan.

On deterministic synchronization: an event before reattachment does not prove the thread has entered the attach wait. That would need a hook inside the attach path. This remains a stress test, and the base still passes on one CPU.

@tpn

tpn commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

Thanks for your changes. Unfortunately the test doesn't fail for me on main. The added C testing function should probably have some waiting time between starting the STW pause and ending it.

@dpdani thanks, adding the wait helped. I originally hit this while stress-testing on B200 machines, and the earlier regression wasn't reliably catching it on my local Threadripper either.

The helper now runs 100 consecutive STW pauses in C, holding each for 10 ms. With just the test changes applied to the unfixed base, it now times out locally with either 4 or 64 CPUs available; the fixed build passes. Could you try the latest version and see whether it reproduces for you?

STW is a no-op in GIL builds. Avoid waiting in the helper there,
since WASI cannot perform the blocking futex operation.
@tpn

tpn commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

@colesbury or @mpage (or both): would you like time to review this as well? Looks like everything is green and all feedback has been addressed, with at least one core approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Free-threaded CPython can starve a thread reattaching during repeated stop-the-world GC

5 participants