Per-interpreter runtime state and a process-wide GC stop-the-world - #8517
Merged
youknowone merged 27 commits intoAug 16, 2026
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for multiple interpreters (PEP 734 / PEP 684), plus a GC data race that
having more than one interpreter exposes. No Python-facing API is added yet:
sys.implementation.supports_isolated_interpretersstaysfalseand there is no_interpretersmodule in this PR.Layering
The types line up with CPython so the stdlib module can be added on top later:
_PyRuntimeState.interpretersvm/runtime.rsregistryPyInterpreterStatePyGlobalStatePyThreadStateVirtualMachineWhat changed
Per-interpreter state.
PyGlobalStategainsinterpreter_id/whence/is_main.Interpreter::create_subinterpreter()builds an interpreter that sharesthe process-wide
Context(immortal builtin types, safe because builtin types areIMMUTABLETYPE) but gets its ownPyGlobalState,sys.modules, builtins module,codec registry, warnings state, thread registry and stop-the-world state. Config,
module defs and frozen modules are cloned from the parent.
Interpreter registry (
vm/runtime.rs): monotonic ids, weak entries so theregistry never keeps an interpreter alive,
whencetracking, and amain_idrecorded from the first
is_maininterpreter for a future_interpreters.get_main().There is also a runtime-owned table (
store_owned_interpreter/take_owned_interpreter),which is the ownership anchor
_interpreters.create()will need, since Pythonreceives only an id. It is
threading-gated becauseInterpreter: Sendonly holdsfor
Arc-backed builds.Per-interpreter thread slots. CPython keeps a
PyThreadStateper(thread, interpreter) pair;
INTERP_THREAD_SLOTSmirrors that, sosys._current_frames()and stop-the-world are scoped to one interpreter.Signals stay owned by the main interpreter: subinterpreters no longer reinstall
SIGINT handlers, and
_thread._is_main_interpreter()now reflects the caller.Registry lifetime. An interpreter is registered before
initialize()runs anybytecode, is released when its last
PyRc<PyGlobalState>goes away rather than whenthe
Interpreterhandle drops (workers fromnew_thread()outlive the handle), andevery interpreter — not just the forking one — is repaired in a forked child, since
the collector now stops all of them.
GC. The cyclic collector's generation lists are process-global, so a collection
reads and frees objects owned by every interpreter — but
CollectStopTheWorldstopped only the collecting interpreter, leaving other interpreters' threads free to
mutate the same object graph during the reference-subtraction, reachability and
snapshot phases. It now stops every live interpreter in registry id order and
restarts in reverse. The global
collectingmutex serializes collectorsprocess-wide and fork acquires a single interpreter's exclusion, so the exclusion
orders cannot cycle.
StopTheWorldStatemethods now take&PyGlobalStateinsteadof
&VirtualMachine, since an interpreter's world must be stoppable withoutholding a VM for it — that is an API change for embedders calling these directly.
Interpreter hot paths
The interpreter-loop work stacked on top of the foundation, measured in instructions
retired per operation against
57428cd92(the last commit before it) so the numbersdo not move with machine load:
attr_getattrmethod_callbuiltin_calldict_iterdict_subscrmake_instanceskwcallFrames for a simple call now come from the thread's data stack, cleared frame blocks
are reused without re-zeroing, the per-instruction safepoint and the call preamble do
less work per instruction, an attribute lookup reaches the instance dict without
cloning it, a call builds its argument vector once, dict lookups and iteration steps
take a single read guard, and the GC tracking counters moved off the write barrier.
Frame lifetime
Data stack frames diverged from the reference implementation in ways that showed up as
extra references and, across threads, as reads of a buffer the owning thread was still
writing:
single traceback entry gave every local in that frame an extra reference. The copy
is now empty and
exit_iframefills it when the frame returns, which is what thechain-materializing path already did.
re-entered; it is dropped when the suspended caller is popped.
sys._current_frames(),_thread._current_exceptions()and the cross-threadf_backpath handed back frame objects still linked to the running frames, so theowner wrote its locals into the same buffers the reader was cloning out of. They now
take a detached copy under stop-the-world. Reading a live foreign frame's locals
through any other path raises instead, gated on a new
attached_tid.set_current_framepublished thePy<FrameObject>address in a slot whose readerspass it to
Py::from_payload_ptr, which subtracts the object header — a SIGSEGV onevery cross-thread frame read, present before this branch.
larger than the previous one could skip zeroing slots that were never cleared.
Known limitations
gc.disable(), thresholds,gc.garbageandgc.get_objects()all observe process-wide state. Making itper-interpreter additionally requires routing
untrack_object— called fromdefault_dealloc, where no VM is in scope — to the owning interpreter's lists.Documented on
gc_state().enter()on thesame OS thread is not supported yet.
enter_vmonly attaches at the outermostsection, so a nested cross-interpreter enter would run with the inner slot
DETACHED. Nothing in tree does this; the_PyThreadState_Swapequivalent belongswith the
_interpreterswork that needs it.the shared type, so it is visible through
__subclasses__in other interpreters.Tests
cargo test -p rustpython-vm --features threadingand the default (non-threading)build both pass, as do clippy and
-m testfortest_gc,test_threadingandtest_fork1.New tests cover interpreter identity and registration, module/builtins isolation,
subinterpreter creation while the parent is entered, concurrent and overlapping
execution across interpreters, and runtime-owned interpreter lifecycle. The two GC
tests were each checked to fail without their fix:
stop_the_world_parks_threads_of_another_interpreterasserts a thread entered in oneinterpreter can park another interpreter's threads.
A full
-m testsweep leaves two failures on macOS,test.test_future_stmt.test_future(a REPL syntax-error assertion) and
test_pyrepl; both fail the same way on the commitbefore the frame work. Reference counts for locals and for a tail call's function object were compared
against CPython 3.14 rather than eyeballed, and the cross-thread paths were run
repeatedly under a spinning worker — they used to crash roughly half the time.
Summary by CodeRabbit
New Features
sys.implementation.Bug Fixes