Files
RustPython/crates/stdlib
Jeong, YunWon 11de045ef8 Per-interpreter runtime state and a process-wide GC stop-the-world (#8517)
* vm: add per-interpreter runtime state and interpreter registry

- vm/runtime.rs: process-global interpreter registry (monotonic ids, weak
  entries), InterpreterWhence/InterpreterInfo, process main id recording via
  main_interpreter_id(), a threading-gated owner map (store_owned_interpreter/
  take_owned_interpreter/is_owned_interpreter/owned_interpreter_count), and the
  SUPPORTS_ISOLATED_INTERPRETERS constant.
- PyGlobalState gains interpreter_id/whence/is_main and is_main_interpreter();
  PyConfig/Settings derive Clone so a subinterpreter can clone parent config.
- Interpreter: id()/whence()/is_main()/is_process_main(),
  create_subinterpreter() and create_owned_subinterpreter(); unregister on Drop.
- thread.rs: per-interpreter thread slots (INTERP_THREAD_SLOTS), slot swap when
  switching interpreters on one OS thread, cleanup keyed by interpreter id.
- Install signal handlers and init the main-thread ident only on the main
  interpreter; _thread._is_main_interpreter reflects the current interpreter.
- sys.implementation.supports_isolated_interpreters reads the constant.
- Guard the registry static for non-threading builds where rc::Weak is !Send.

Assisted-by: Claude Code:claude-opus-4-8

* gc: stop every interpreter while collecting

The generation lists are process-global, so a collection reads and frees
objects owned by every interpreter. CollectStopTheWorld stopped only the
collecting interpreter, leaving other interpreters' threads free to mutate
the same object graph during the reference-subtraction, reachability and
snapshot phases.

Stop all live interpreters instead, in runtime id order, and restart them in
reverse. The global `collecting` mutex serializes collectors process-wide, so
no second collector takes these exclusions in another order; fork acquires a
single interpreter's exclusion, so the orders cannot cycle.

- runtime: add live_interpreter_states(), ordered by interpreter id.
- StopTheWorldState methods take &PyGlobalState instead of &VirtualMachine,
  so an interpreter's world can be stopped without a VM for it; update the
  call sites in frame, _thread, posix, faulthandler and capi.
- Document in gc_state() that the collector is process-wide: gc.disable(),
  thresholds, gc.garbage and gc.get_objects() observe process-wide state, and
  a per-interpreter collector additionally needs untrack_object (called from
  default_dealloc with no VM in scope) routed to the owning interpreter.

Tests: stop_the_world_parks_threads_of_another_interpreter asserts a thread
entered in one interpreter parks another interpreter's threads (it fails
without this change), plus a collect-while-another-interpreter-churns test.

Assisted-by: Claude Code:claude-opus-4-8

* vm: scope the interpreter registry per thread without threading

The registry held `rc::Weak` in a process-global `OnceLock` and covered the
resulting `!Send`/`!Sync` with an `unsafe impl` justifying it as "non-threading
builds are single-threaded". That is not this codebase's model: `static_cell!`
is thread-local without the `threading` feature precisely so each OS thread can
own its own `Context::genesis()` and `GcState`, so two threads could reach the
same `Rc` counts through the registry.

Use `static_cell!` for the registry as well, matching `gc_state()`, and drop the
`unsafe impl`. Ids are consequently unique per registry rather than per process
in non-threading builds, which is documented on `alloc_interpreter_id`.

Move the recorded main interpreter id into the registry so it follows the same
scoping instead of living in a separate global `OnceLock`.

Also make `CollectStopTheWorld::new` accumulate into a live guard: it built a
bare `Vec` and only moved it into the restarting `Drop` type after stopping
every interpreter, so an unwind partway through the loop left the already
stopped interpreters parked forever with their exclusion held.

Assisted-by: Claude Code:claude-opus-4-8

* vm: keep the interpreter registry usable across bootstrap, drop and fork

Three gaps where the registry did not describe the interpreters that actually
exist, each of which hides an interpreter from the collector's stop-the-world.

Register before `initialize()`. Registration ran as the last step of
`initialize_vm`, so the whole bootstrap — which executes Python bytecode and
allocates GC-tracked objects — was invisible to `live_interpreter_states()`.
It still cannot run any earlier than this: the init hooks take
`PyRc::get_mut` on the state, which fails as soon as the registry holds a weak
reference to it.

Stop unregistering in `Interpreter::drop`. The handle does not decide the
interpreter's lifetime — every `ThreadedVirtualMachine` from `new_thread()`
holds its own `PyRc<PyGlobalState>` — so an interpreter with running workers
disappeared from the registry while its threads kept mutating the object graph.
The entries are weak, so lifetime is already correct without the removal; dead
entries are now reaped when registering instead. Interpreters are consequently
released rather than unregistered at a fixed point, so the two tests asserting
disappearance now wait for it: a collection in progress legitimately holds a
reference to every live interpreter.

Repair other interpreters after fork. `py_os_after_fork_child` only fixed the
forking interpreter, leaving every other one with slots for threads that did
not survive (still ATTACHED if they were running bytecode) plus locks and
stop-the-world flags held by them. Since a collection stops all interpreters,
the child's first collection would wait for threads that no longer exist. Reset
their locks, stop-the-world state and thread tables, drop this thread's cached
slots for them, and reinit the registry's own locks first, since enumerating
interpreters now takes them.

Tests: test_gc, test_threading and test_fork1 pass, as do the vm tests in both
the threading and default configurations.

Assisted-by: Claude Code:claude-opus-4-8

* vm: attach and detach thread slots when switching interpreters

`enter_vm` decided whether to attach from `was_outermost` (an empty VM_STACK),
which held while a thread could only ever be in one interpreter. With a slot per
(thread, interpreter) pair, entering interpreter B from a thread already inside
interpreter A's section switched CURRENT_THREAD_SLOT to B's slot but attached
nothing: the thread then ran B's bytecode with B's slot DETACHED while A's slot
stayed ATTACHED. A collector stopping B force-parks the DETACHED slot and
concludes B is stopped, and then walks the object graph this thread is still
mutating.

Pair the attach/detach with the slot switch instead (≈ `_PyThreadState_Swap`):
`begin_interpreter_section` detaches the enclosing interpreter's slot, makes the
target slot current and attaches it, and `end_interpreter_section` undoes that
and re-attaches the enclosing interpreter. Both live in `set_current_vm`, which
every path making a VM current already goes through, so `enter_vm` and
`VmBootstrapGuard` no longer track outermost-ness themselves.

`nested_enter_of_subinterpreter_is_stoppable` covers this: it runs a
subinterpreter nested inside the parent's section and asserts the sub's threads
park when the sub's world is stopped. It fails with the previous
attach-at-outermost-only behavior.

Assisted-by: Claude Code:claude-opus-4-8

* vm: list only the current interpreter's subclasses

Interpreters share the context, so `class Foo(int)` in one of them pushes
onto the same `int.subclasses` every other one reads, and
`int.__subclasses__()` returned types no other interpreter can reach.

Record the creating interpreter on `HeapTypeExt` and filter
`__subclasses__` by it, the way `lookup_tp_subclasses` reads
`tp_subclasses` out of per-interpreter state for static builtin types.
Types built before any interpreter exists — the ones the shared context
creates, including the exception hierarchy — carry no id and stay visible
to every interpreter, which is what `_PyStaticType_InitBuiltin` produces
by registering the builtin subclass links once per interpreter.

The other walks over `subclasses` (version-tag invalidation, abc flag
propagation, mro updates, slot propagation) are left as they are: each
starts from a type being mutated, so from a heap type, whose subclasses
all live in the interpreter that created it.

`subinterpreter_subclasses_are_scoped_to_their_interpreter` covers this
and fails without the filter.

Assisted-by: Claude Code:claude-opus-5

* gc: give each interpreter its own collector state

The generation lists stay process-wide, because an object is untracked from
`default_dealloc`, where no interpreter is in scope to route to. What changes
is that a collection no longer acts on every interpreter's objects, and the gc
module no longer reports one interpreter's state to another.

`track_object` stamps the running interpreter into a new `gc_owner` word on the
object header, and a collection takes as candidates only the objects carrying
its own tag plus the ones carrying none. `gc_owner` fits in the padding the
header's alignment already forces, so objects do not grow; an assertion on the
header size keeps it that way.

Objects allocated with no interpreter running — everything the shared context
builds — carry no owner and stay candidates for every interpreter, which is
where they were before. Objects that outlive the interpreter that tracked them
are adopted the same way by the next full collection, rather than being left to
a collector that will never come.

`enabled`, the thresholds, the debug flags, the statistics, `gc.garbage` and
`gc.callbacks` move onto `PyGlobalState` — the last two off the shared
`Context` — so `gc.disable()`, `gc.set_threshold()`, `gc.get_stats()`,
`gc.get_objects()` and `gc.garbage` describe the interpreter that asks. The
occupancy counts behind `gc.get_count()` and `gc.get_freeze_count()` stay
process-wide: they measure how full the shared lists are. Their decrements are
saturating now, since a collection zeroes the generations it emptied while
another interpreter's objects are still sitting in them.

Stop-the-world still stops every interpreter. Unowned objects are candidates
and any interpreter can incref one, so the refcounts a collection reads are
only stable while all of them are parked.

This also fixes a deadlock it exposed: `CollectStopTheWorld` dropped its
references to the stopped interpreters while the collection still held the
generation read locks, so releasing the last reference to one — which frees its
objects, and so untracks them — waited for a write lock behind that read lock.
The references are now held until the guard itself drops.

`collections_only_reach_the_collecting_interpreter` and
`get_objects_only_reports_the_calling_interpreter` cover this and both fail
without the owner check.

Assisted-by: Claude Code:claude-opus-5

* _queue, _thread: detach before taking locks held across waits

`_queue.Semaphore` holds its mutex across the `allow_threads` condvar wait,
and `join_internal` holds a thread handle's completion mutex the same way.
Stop-the-world can stop a thread while it holds either one. The remaining
acquisitions ran attached, so a thread blocking on such a mutex had no
safepoint left to reach: the stop never completed, and the holder was never
resumed to release it.

Route those acquisitions through helpers that detach first. The fork-child
reinit paths keep their direct locks.

Assisted-by: Claude

* _io, _winapi: detach on the remaining stopped-holdable lock takes

`TextIOWrapper.__repr__` took `data` directly while every other method takes it
through `lock_opt`, which detaches. `Overlapped` holds `inner` across the
`allow_threads` in `GetOverlappedResult`, and all four of its takes were direct.

A thread stopped by stop-the-world can be holding either mutex, so taking one
while attached left the blocked thread with no safepoint to reach.

Assisted-by: Claude

* gc: size the interpreter owner tag to the header padding

`gc_owner` was a u32. With 4-byte pointers its alignment pushed it out of the
padding that follows the gc bits and generation, growing every object by a
word and tripping the `SIZEOF_PYOBJECT_HEAD` assertion on 32-bit targets.

Introduce `GcOwner = u16`, which the `repr(C)` layout places at offset 10 on
32-bit and 18 on 64-bit, leaving the header at 6 words on both. Tags now run
out after 65535 interpreters; `alloc_owner` already falls back to
`GC_NO_OWNER`, so an interpreter past that collects as it did before tagging.

Also widen three test deadlines that measure liveness, not speed.

Assisted-by: Claude

* vm: reuse cleared frame blocks and shorten interpreter hot paths

- `datastack` remembers the most recently popped frame block. `push_frame`
  reports an exact LIFO reuse, and `setup_datastack_frame` then skips
  zero-filling localsplus.
- Small-int loads push the context's cached int as a borrowed stack reference
  instead of taking a new one.
- Calls to jitted functions go straight to `execute_call_vectorcall`.
- Binary-op specialization reads ints through the new `PyInt::try_to_i64_fast`
  instead of the generic primitive conversion, and `try_to_bool` moves its
  non-bool path into a `#[cold]` helper.
- Dict caches read an entry through a keys-version stamp
  (`get_index_if_keys_version`) rather than an entry-index hint.
- Frame publishing caches a pointer to `ThreadSlot::top_iframe` in a
  thread-local `Cell` instead of borrowing `CURRENT_THREAD_SLOT`.

* gc, vm: address review notes on owner tags and interpreter docs

Look up retired owner tags with a sorted binary search instead of a linear
scan, which every scanned object paid for once per dropped interpreter.

Drop the claim that clearing a tag frees it for reuse; `alloc_owner` only
ever hands out new tags. Also correct the tag space it mentions, which is
16-bit since the tag was sized to the header padding.

Document `is_main` as "top-level interpreter" rather than "the process
main": every top-level interpreter sets it, and only the first registered
one becomes the main `main_interpreter_id` reports.

Assert membership rather than an exact owned-interpreter count delta; the
owned table is process-global and other tests store into it in parallel.

Assisted-by: Claude

* vm: gate interpreter registration on stop-the-world admission

A collection snapshots the registry, stops the interpreters it found, and
then reads tracked objects with their threads parked. An interpreter that
registered after the snapshot was taken was absent from it, so nothing
stopped it and its bootstrap ran Python — allocating and mutating the
shared generation lists — underneath that scan.

Registration and the stop now share a process-global gate: the collection
holds it from before the snapshot until the restart, and registration takes
it around the registry insert. The insert runs detached, since a thread
that waited for the gate, or re-attached while holding it, would leave the
stop it waits for no safepoint to complete at.

Assisted-by: Claude

* gc: keep the tracking counters off the barrier path

Every tracked allocation and every free went through sequentially consistent
counter updates, and each free through a `fetch_update` CAS loop. The counters
drive only the gen0 threshold and `gc.get_count()`, and the generation locks —
not the counters — order the list changes they describe, so they are relaxed
now and the decrement is a load plus a conditional `fetch_sub`.

`is_enabled` and `threshold`, both read once per allocation, are relaxed for
the same reason: an allocation racing `gc.disable()` may use either value.

Drop `alloc_count`, which nothing has ever read.

Assisted-by: Claude

* dict: settle lookups and iteration steps under one read guard

A lookup probed under a read guard, dropped it with the matched entry in
hand, and then took the lock again to re-find the entry. `lookup_extract`
reads the entry while the probe still holds the guard when key identity
settles the match, which is the case that cannot run Python.

Dict and set iterators took one lock to compare the size and another to read
the entry, then cloned both the key and the value even though a keys or
values view keeps only one of them. `next_entry_checked` does the size check
and the read under one guard, and clones through a per-view projection.

A store that missed its inline-cache hint re-probed the dict afterwards only
to recover the entry index the store had just computed; `unchecked_push`
reports that index instead.

Assisted-by: Claude

* vm: build a call's argument vector once

The specialized CALL handlers collected the positional arguments into one
vector and then copied them into a second one to put `self` in front, so
every specialized builtin, method-descriptor, class and non-Python call paid
two allocations, a copy and two frees. The arguments are already laid out on
the value stack in vectorcall order, so `take_call_args` fills one vector by
index — the shape `execute_call_vectorcall` already used.

`vectorcall_native_function` and the keyword path of `vectorcall_function`
then cloned that vector again to build `FuncArgs`; both now move it in
through `from_vectorcall_owned`, as the other vectorcall slots do.

Assisted-by: Claude

* vm: reach an instance dict without cloning it

The specialized attribute instructions cloned the instance dict — a rwlock
round-trip plus a refcount round-trip — for two things that only look at it:
`LoadAttrMethodLazyDict` asking whether the dict exists, and the keys-version
stamp check that is the whole of `shadowing_instance_attr`'s fast path. Both
now read it borrowed, through `has_instance_dict` / `with_instance_dict`.

`generic_getattr_opt` probed the dict with the name's `&Wtf8`, which hashes
the name on every lookup and can never match a key by pointer. Passing the
`Py<PyStr>` uses the string's cached hash and the interned-key identity
check, and drops an allocation when a stored key is not an exact `str`.

Assisted-by: Claude

* vm: shorten the per-instruction safepoint and the call preamble

The dispatch loop asks once per instruction whether stop-the-world wants this
thread, and that read went through `CURRENT_THREAD_SLOT` — a `RefCell` borrow,
so two stores to thread-local memory around an `Option` test. Cache the
`stop_requested` pointer in a plain `Cell` at the same three places the frame
pointers are cached, and the safepoint becomes one relaxed load.

`lasti` is advanced from the index the loop just read, rather than reloaded
to increment it, and `Resume` reads `quickened` before swapping it, so a call
to an already-quickened code object costs a load instead of an atomic
read-modify-write.

An exact-args vectorcall to a Python function built a heap `FrameObject`
where the equivalent `invoke` path uses a data stack frame; it now does the
same when tracing is off.

Assisted-by: Claude

* vm: give KwArgs a zero-sized hasher

`KwArgs::default()` is built for every call, keyword-less ones included, and
it seeded a `RandomState` each time — a thread-local read and 16 bytes in
every `FuncArgs`. Keyword names come from the program text, so the map now
uses `BuildHasherDefault`, whose `Default` is a zero-init.

Assisted-by: Claude

* vm: promote borrowed stack refs before a yield

`yield <small int>` tripped the "borrowed refs on stack at yield point"
assertion: `LoadSmallInt` pushes a borrowed ref, and a yield saves the stack
with the frame, which is exactly what that assertion forbids. Promote the
stack first, so a suspended frame owns everything it holds.

Assisted-by: Claude

* vm: run the exact-args vectorcall on a heap frame again

`vectorcall_function` took the data stack path for an exact-args call whenever
tracing was off. A data stack frame that materializes — one traceback entry is
enough — copies its localsplus into the `FrameObject`, so every value it holds
gains a reference for as long as the frame runs, and a tail call holds the
callee's function object until the caller's next call or return. Both are
reachable without this hunk, through the `CallPyExactArgs` specialization, but
routing the vectorcall entry point through the data stack made them apply from
a call site's first execution: test_enumerate, test_memoryio, test_sys,
test_tempfile and test_traceback all read a refcount or a finalization that
moved.

Assisted-by: Claude

* vm: release a tail call's function at the callee's return

The trampoline moved the callee's function object into the caller's
`SuspendedFrame` and dropped it only once the caller had run again, so a
function entered through a tail call kept one reference past its return:
`sys.getrefcount` on it read one high for the caller's next stretch of
bytecode, and anything the function reached stayed alive that much longer.

The callee's frame is released before the `ReturnValue` or `Unwind` action is
formed, and a materialized frame object holds its own references, so the owner
is dropped where the suspended caller is popped instead.

Assisted-by: Claude

* vm: stop copying a running frame's locals when it materializes

`materialize_slow` cloned the source frame's fast locals into the new
`FrameObject`, so every value in a data stack frame gained a reference lasting
as long as that frame object: one traceback entry through a frame kept all of
its locals alive, and `sys.getrefcount` read one high for each of them.

Nothing read that copy while the frame ran. `find_live_source_iframe` already
routes reads to the live frame, and `exit_iframe` fills the slots from it at
return, so the copy now starts empty as `materialize_slow_chain`'s does, and
`snapshot_to_heap` goes with its last caller.

Two readers had to be brought to the live frame the same way.
`has_active_hidden_locals` read the frame object's own slots, which let
`locals()` in a class body write an inlined comprehension's hidden variable
into the class namespace; it now shares `live_fastlocals` with the other two.
Another thread's frame has no live frame to reach from here, so the six
cross-thread sites fill the copy under stop-the-world through
`materialize_with_locals`.

Assisted-by: Claude

* vm: key data stack frame reuse on the exact frame size

push_frame and pop_frame compared 16-byte-aligned sizes, so two frames
whose sizes differ by less than the alignment matched as a reuse. The
caller reads that as "every slot was cleared by the previous frame", but
the larger frame's tail slots were never touched by it.

Assisted-by: Claude

* vm: store the frame object payload address in the cross-thread slot

set_current_frame and reinit_frame_slot_after_fork wrote the address of
the Py<FrameObject>, while the cross-thread readers hand that pointer to
Py::from_payload_ptr, which subtracts the object header from it. Both
writers now store the payload address.

Assisted-by: Claude

* vm: copy a foreign thread's frames instead of linking them

sys._current_frames(), _thread._current_exceptions() and the cross-thread
f_back path handed back frame objects still linked to the running frames.
The owning thread wrote its locals into those same buffers at exit_iframe
while the reader was cloning out of them. materialize_detached and
materialize_detached_chain copy the values under stop-the-world and leave
nothing for the owner to write into; materialize_with_locals is gone.

FrameColdData gains attached_tid, the thread still running the frame a
frame object was materialized from, which exit_iframe clears once it has
written the values in. check_locals_access now rejects a read from any
other thread while it is set; the FrameOwner test could not, because
materialize_slow forces FrameObject ownership.

Also removes with_iframe, which had no callers.

Assisted-by: Claude

* vm: resolve the two rustdoc links this branch added

MAIN_INTERPRETER_ID is not in scope in interpreter.rs, and new_thread only
exists in a threading build, so neither link resolved.

Assisted-by: Claude
2026-08-16 21:21:31 +09:00
..
2026-07-07 09:19:25 +09:00
2026-04-21 00:13:25 +09:00