Visitar URL original
Per-interpreter runtime state and a process-wide GC stop-the-world by youknowone · Pull Request #8517 · RustPython/RustPython · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5c341d2
vm: add per-interpreter runtime state and interpreter registry
youknowone Aug 13, 2026
43f4b23
gc: stop every interpreter while collecting
youknowone Aug 13, 2026
ffd3d1e
vm: scope the interpreter registry per thread without threading
youknowone Aug 13, 2026
0079994
vm: keep the interpreter registry usable across bootstrap, drop and fork
youknowone Aug 13, 2026
8f108ff
vm: attach and detach thread slots when switching interpreters
youknowone Aug 13, 2026
696a8c1
vm: list only the current interpreter's subclasses
youknowone Aug 14, 2026
562b657
gc: give each interpreter its own collector state
youknowone Aug 14, 2026
30e2020
_queue, _thread: detach before taking locks held across waits
youknowone Aug 14, 2026
5ce062e
_io, _winapi: detach on the remaining stopped-holdable lock takes
youknowone Aug 14, 2026
87b6c68
gc: size the interpreter owner tag to the header padding
youknowone Aug 15, 2026
fd27902
vm: reuse cleared frame blocks and shorten interpreter hot paths
youknowone Aug 15, 2026
c3fcc70
gc, vm: address review notes on owner tags and interpreter docs
youknowone Aug 15, 2026
57428cd
vm: gate interpreter registration on stop-the-world admission
youknowone Aug 15, 2026
8d07831
gc: keep the tracking counters off the barrier path
youknowone Aug 15, 2026
4e518d3
dict: settle lookups and iteration steps under one read guard
youknowone Aug 15, 2026
eb94daf
vm: build a call's argument vector once
youknowone Aug 15, 2026
f70db35
vm: reach an instance dict without cloning it
youknowone Aug 15, 2026
4a76424
vm: shorten the per-instruction safepoint and the call preamble
youknowone Aug 15, 2026
b0f0dcc
vm: give KwArgs a zero-sized hasher
youknowone Aug 15, 2026
f9bbc6d
vm: promote borrowed stack refs before a yield
youknowone Aug 15, 2026
b4bb43c
vm: run the exact-args vectorcall on a heap frame again
youknowone Aug 15, 2026
98d2410
vm: release a tail call's function at the callee's return
youknowone Aug 16, 2026
08bd522
vm: stop copying a running frame's locals when it materializes
youknowone Aug 16, 2026
c7c1452
vm: key data stack frame reuse on the exact frame size
youknowone Aug 16, 2026
2d58fc8
vm: store the frame object payload address in the cross-thread slot
youknowone Aug 16, 2026
e24d831
vm: copy a foreign thread's frames instead of linking them
youknowone Aug 16, 2026
5d0d9be
vm: resolve the two rustdoc links this branch added
youknowone Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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
  • Loading branch information
youknowone committed Aug 15, 2026
commit 43f4b23314e703ace272352f26326f2db97414c0
4 changes: 2 additions & 2 deletions crates/capi/src/pystate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ mod tests {
current_vm_is_set(),
"This thread did not have a vm attached"
);
vm.state.stop_the_world.stop_the_world(vm);
vm.state.stop_the_world.start_the_world(vm);
vm.state.stop_the_world.stop_the_world(&vm.state);
vm.state.stop_the_world.start_the_world(&vm.state);
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions crates/stdlib/src/faulthandler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ mod decl {
use core::sync::atomic::Ordering;
let current_tid = rustpython_vm::stdlib::_thread::get_ident();
{
vm.state.stop_the_world.stop_the_world(vm);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); }
vm.state.stop_the_world.stop_the_world(&vm.state);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); }
let registry = vm.state.thread_frames.lock();
#[expect(
clippy::iter_over_hash_type,
Expand Down
4 changes: 2 additions & 2 deletions crates/vm/src/builtins/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,8 +897,8 @@ impl Py<FrameObject> {
{
// Enter STW before dereferencing `prev` — the owning thread may
// return and free the stack-allocated iframe at any time.
vm.state.stop_the_world.stop_the_world(vm);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); }
vm.state.stop_the_world.stop_the_world(&vm.state);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); }
let prev_ref = unsafe { &*prev };
// Fast path: already materialized.
if let Some(fo) = prev_ref.frame_obj() {
Expand Down
70 changes: 47 additions & 23 deletions crates/vm/src/gc_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,42 +146,58 @@ struct GcPtr(NonNull<PyObject>);
/// well-defined while all other threads are parked at a safepoint. Restarting
/// happens explicitly once the snapshot has pinned every object; `Drop` is a
/// backstop that also restarts on the early-return paths.
///
/// The generation lists are process-global, so a collection walks objects
/// owned by *every* interpreter. Stopping only the collecting interpreter
/// would leave another interpreter's threads mutating the same object graph,
/// so every live interpreter is stopped. Stopping in `runtime` id order keeps
/// exclusion acquisition ordered; the `collecting` mutex additionally
/// serializes collections process-wide, so no second collector can take these
/// exclusions in another order.
#[cfg(feature = "threading")]
struct CollectStopTheWorld {
vm: *const crate::VirtualMachine,
stopped: bool,
/// Stopped interpreter states, in stop order. Held as strong references so
/// an interpreter cannot be dropped between stop and restart.
stopped: Vec<crate::common::rc::PyRc<crate::vm::PyGlobalState>>,
}

#[cfg(feature = "threading")]
impl CollectStopTheWorld {
/// Request stop-the-world when the current thread has an attached VM.
/// Falls back to no barrier when no VM is attached (the tracked-object
/// reads then run without other threads only if the caller guarantees it).
/// Request stop-the-world on every live interpreter when the current thread
/// has an attached VM. Falls back to no barrier when no VM is attached (the
/// tracked-object reads then run without other threads only if the caller
/// guarantees it).
fn new() -> Self {
let vm = crate::vm::thread::try_with_current_vm(|vm| {
vm.state.stop_the_world.stop_the_world(vm);
vm as *const crate::VirtualMachine
});
match vm {
Some(vm) => Self { vm, stopped: true },
None => Self {
vm: core::ptr::null(),
stopped: false,
},
// No attached VM means no interpreter is running Python on this thread;
// keep the historical no-barrier fallback.
if !crate::vm::thread::current_vm_is_set() {
return Self {
stopped: Vec::new(),
};
}

let states = crate::vm::runtime::live_interpreter_states();
let mut stopped = Vec::with_capacity(states.len());
for state in states {
state.stop_the_world.stop_the_world(&state);
stopped.push(state);
}
Self { stopped }
}

/// Restart the world. Idempotent.
fn restart(&mut self) {
if self.stopped {
// SAFETY: the current thread stays attached to this VM for the
// whole collection — the VM is never popped from the thread's VM
// stack while collecting — so the pointer is valid here.
let vm = unsafe { &*self.vm };
vm.state.stop_the_world.start_the_world(vm);
self.stopped = false;
// Reverse of the stop order.
for state in self.stopped.drain(..).rev() {
state.stop_the_world.start_the_world(&state);
}
}

/// Whether this collection actually stopped the world.
#[cfg(all(unix, debug_assertions))]
fn is_stopped(&self) -> bool {
!self.stopped.is_empty()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[cfg(feature = "threading")]
Expand Down Expand Up @@ -638,7 +654,7 @@ impl GcState {
// because stack-allocated frames update only CURRENT_FRAME (via
// set_current_frame_nosave), not top_frame.
#[cfg(all(unix, feature = "threading", debug_assertions))]
if stw.stopped {
if stw.is_stopped() {
let unreachable_set: HashSet<GcPtr> = unreachable.iter().copied().collect();
let mut cur = crate::vm::thread::get_current_frame();
while !cur.is_null() {
Expand Down Expand Up @@ -1091,6 +1107,14 @@ impl GcState {
/// In threading mode this is a true global (OnceLock).
/// In non-threading mode this is thread-local, because PyRwLock/PyMutex
/// use Cell-based locks that are not Sync.
///
/// The collector is process-wide rather than per-interpreter: every
/// interpreter's tracked objects live in these generation lists, so a
/// collection stops and collects across all of them, and `gc.disable()`,
/// thresholds, `gc.garbage` and `gc.get_objects()` observe process-wide state.
/// Making the collector per-interpreter additionally requires routing
/// `untrack_object` (called from `default_dealloc`, where no VM is in scope) to
/// the owning interpreter's lists.
pub fn gc_state() -> &'static GcState {
rustpython_common::static_cell! {
static GC_STATE: GcState;
Expand Down
8 changes: 4 additions & 4 deletions crates/vm/src/stdlib/_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,8 +1193,8 @@ pub(crate) mod _thread {
{
use core::sync::atomic::Ordering;
let current_ident = get_ident();
vm.state.stop_the_world.stop_the_world(vm);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); }
vm.state.stop_the_world.stop_the_world(&vm.state);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); }
let registry = vm.state.thread_frames.lock();
registry
.iter()
Expand Down Expand Up @@ -1249,8 +1249,8 @@ pub(crate) mod _thread {
{
use core::sync::atomic::Ordering;
let current_ident = get_ident();
vm.state.stop_the_world.stop_the_world(vm);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); }
vm.state.stop_the_world.stop_the_world(&vm.state);
scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); }
let registry = vm.state.thread_frames.lock();
registry
.iter()
Expand Down
4 changes: 2 additions & 2 deletions crates/vm/src/stdlib/posix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ pub mod module {
crate::stdlib::_imp::acquire_imp_lock_for_fork(vm);

#[cfg(feature = "threading")]
vm.state.stop_the_world.stop_the_world(vm);
vm.state.stop_the_world.stop_the_world(&vm.state);
}

fn py_os_after_fork_child(vm: &VirtualMachine) {
Expand Down Expand Up @@ -729,7 +729,7 @@ pub mod module {

fn py_os_after_fork_parent(vm: &VirtualMachine) {
#[cfg(feature = "threading")]
vm.state.stop_the_world.start_the_world(vm);
vm.state.stop_the_world.start_the_world(&vm.state);

#[cfg(feature = "threading")]
crate::stdlib::_imp::release_imp_lock_after_fork_parent();
Expand Down
140 changes: 140 additions & 0 deletions crates/vm/src/vm/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,146 @@ mod tests {
assert!(!sub.is_main());
}

/// A collection must stop every interpreter, not just the collecting one:
/// the generation lists are process-global, so the reachability walk reads
/// objects owned by other interpreters while their threads would otherwise
/// still be mutating them.
#[cfg(all(feature = "threading", feature = "rustpython-compiler"))]
#[test]
fn gc_collect_is_safe_while_another_interpreter_runs() {
use crate::compiler::Mode;
use alloc::sync::Arc;
use core::{
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use std::time::Instant;

// Each interpreter churns reference cycles so both contribute tracked
// objects to the shared generation lists.
const CHURN: &str = "\
for _ in range(40):
a = {}
b = {'peer': a}
a['peer'] = b
";

let main = Interpreter::without_stdlib(Default::default());
let sub = main.create_subinterpreter();
let stop = Arc::new(AtomicBool::new(false));

let run_source = |vm: &VirtualMachine, source: &str| {
let scope = vm.new_scope_with_builtins();
let code = vm
.compile(source, Mode::Exec, "<churn>")
.map_err(|err| err.into_pyexception(vm, Some(source)))
.unwrap();
vm.run_code_obj(code, scope).unwrap();
};

// Subinterpreter thread: allocate cycles continuously.
let stop_worker = Arc::clone(&stop);
let churner = sub.enter(|vm| {
let thread_vm = vm.new_thread();
std::thread::spawn(move || {
thread_vm.run(|vm| {
while !stop_worker.load(Ordering::Acquire) {
run_source(vm, CHURN);
}
});
})
});

// Main interpreter: force collections while the sub keeps mutating.
main.enter(|vm| {
run_source(vm, CHURN);
let deadline = Instant::now() + Duration::from_secs(2);
let mut collections = 0;
while Instant::now() < deadline && collections < 20 {
crate::gc_state::gc_state().collect_force(2);
collections += 1;
}
assert!(collections > 0);
});

stop.store(true, Ordering::Release);
churner.join().expect("churn worker panicked");
}

/// A thread entered in one interpreter can park another interpreter's
/// threads. This is what makes a collection safe: the generation lists are
/// process-global, so the collector must be able to stop every interpreter,
/// not only its own.
#[cfg(all(feature = "threading", feature = "rustpython-compiler"))]
#[test]
fn stop_the_world_parks_threads_of_another_interpreter() {
use crate::compiler::Mode;
use alloc::sync::Arc;
use core::{
sync::atomic::{AtomicBool, AtomicU64, Ordering},
time::Duration,
};

let main = Interpreter::without_stdlib(Default::default());
let sub = main.create_subinterpreter();
let sub_state = sub.enter(|vm| vm.state.clone());

let progress = Arc::new(AtomicU64::new(0));
let stop = Arc::new(AtomicBool::new(false));

// Sub-interpreter worker: runs bytecode (so it reaches safepoints) and
// reports progress every iteration.
let progress_worker = Arc::clone(&progress);
let stop_worker = Arc::clone(&stop);
let worker = sub.enter(|vm| {
let thread_vm = vm.new_thread();
std::thread::spawn(move || {
thread_vm.run(|vm| {
let source = "x = 1 + 1\n";
let code = vm
.compile(source, Mode::Exec, "<spin>")
.map_err(|err| err.into_pyexception(vm, Some(source)))
.unwrap();
while !stop_worker.load(Ordering::Acquire) {
let scope = vm.new_scope_with_builtins();
vm.run_code_obj(code.clone(), scope).unwrap();
progress_worker.fetch_add(1, Ordering::Release);
}
});
})
});

// Wait until the worker is actually running.
while progress.load(Ordering::Acquire) == 0 {
std::thread::yield_now();
}

main.enter(|_vm| {
// Stop the *subinterpreter* from a thread whose current interpreter
// is main — the cross-interpreter stop a collection performs.
sub_state.stop_the_world.stop_the_world(&sub_state);

let parked_at = progress.load(Ordering::Acquire);
std::thread::sleep(Duration::from_millis(50));
assert_eq!(
progress.load(Ordering::Acquire),
parked_at,
"subinterpreter thread kept running while its world was stopped"
);

sub_state.stop_the_world.start_the_world(&sub_state);
});

// After restart the worker makes progress again.
let resumed_from = progress.load(Ordering::Acquire);
while progress.load(Ordering::Acquire) == resumed_from {
std::thread::yield_now();
}

stop.store(true, Ordering::Release);
worker.join().expect("worker panicked");
}

/// The process main id is recorded once and is stable across later creates.
#[test]
fn process_main_id_recorded_and_stable() {
Expand Down
Loading