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
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
  • Loading branch information
youknowone committed Aug 15, 2026
commit ffd3d1e4255ba91704188198e603eadce25708b4
15 changes: 10 additions & 5 deletions crates/vm/src/gc_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,18 @@ impl CollectStopTheWorld {
};
}

let states = crate::vm::runtime::live_interpreter_states();
let mut stopped = Vec::with_capacity(states.len());
for state in states {
// Accumulate into a live `Self` rather than a bare Vec: if a later
// `stop_the_world` unwinds, dropping this guard restarts the
// interpreters already stopped, instead of leaving their threads parked
// and their exclusion held forever.
let mut guard = Self {
stopped: Vec::new(),
};
for state in crate::vm::runtime::live_interpreter_states() {
state.stop_the_world.stop_the_world(&state);
stopped.push(state);
guard.stopped.push(state);
}
Self { stopped }
guard
}

/// Restart the world. Idempotent.
Expand Down
69 changes: 40 additions & 29 deletions crates/vm/src/vm/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use crate::vm::PyGlobalState;
use core::sync::atomic::{AtomicI64, Ordering};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::OnceLock;

/// Where an interpreter state came from (mirrors CPython `_PyInterpreterState_GetWhence`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
Expand Down Expand Up @@ -58,36 +57,41 @@ struct RegistryEntry {
state: alloc::rc::Weak<PyGlobalState>,
}

/// `main_id` value before any main interpreter has been registered.
const NO_MAIN_INTERPRETER: i64 = -1;

struct InterpreterRegistry {
next_id: AtomicI64,
/// Id of the first registered `is_main` interpreter (PEP 734 `get_main()`),
/// or [`NO_MAIN_INTERPRETER`].
main_id: AtomicI64,
/// id → entry. Main interpreter is always id 0 when created first.
entries: Mutex<HashMap<i64, RegistryEntry>>,
}

// Without the `threading` feature `RegistryEntry` holds `rc::Weak`, which is
// `!Send`/`!Sync`, so the process-global registry static would not type-check.
// SAFETY: non-threading builds are single-threaded by construction (`Rc`-based
// objects are already unsound to touch across threads), and this process-global
// registry is only ever reached from that one thread. The `parking_lot::Mutex`
// still guards the map contents against reentrancy.
#[cfg(not(feature = "threading"))]
unsafe impl Send for InterpreterRegistry {}
#[cfg(not(feature = "threading"))]
unsafe impl Sync for InterpreterRegistry {}

impl InterpreterRegistry {
fn new() -> Self {
Self {
// Monotonic ids starting at 0. Concurrent Interpreter construction
// (e.g. cargo test threads) must never share an id.
next_id: AtomicI64::new(0),
main_id: AtomicI64::new(NO_MAIN_INTERPRETER),
entries: Mutex::new(HashMap::new()),
}
}
}

/// The interpreter registry.
///
/// With `threading` this is one process-global table. Without it, `PyRc` is
/// `Rc` and each OS thread owns an independent `Context::genesis()` and
/// `GcState`, so the registry is thread-local for the same reason `gc_state()`
/// is: an `Rc` handle must never be reachable from another thread.
/// `static_cell!` provides exactly that split.
fn registry() -> &'static InterpreterRegistry {
static REGISTRY: OnceLock<InterpreterRegistry> = OnceLock::new();
rustpython_common::static_cell! {
static REGISTRY: InterpreterRegistry;
}
REGISTRY.get_or_init(InterpreterRegistry::new)
}

Expand All @@ -105,39 +109,45 @@ pub const MAIN_INTERPRETER_ID: i64 = 0;
/// in the commit that lands `_interpreters`.
pub const SUPPORTS_ISOLATED_INTERPRETERS: bool = false;

/// Process main interpreter id for PEP 734 `_interpreters.get_main()`, recorded
/// once when the first `is_main` interpreter is registered.
static MAIN_INTERPRETER: OnceLock<i64> = OnceLock::new();

/// Id of the process main interpreter (PEP 734 `get_main()`), or `None` before
/// any interpreter has been created.
/// Id of the main interpreter (PEP 734 `get_main()`), or `None` before any
/// interpreter has been created.
///
/// This is distinct from [`PyGlobalState::is_main`]: every top-level (non-sub)
/// interpreter carries `is_main` for its own signal / main-thread bookkeeping,
/// but only the first one registered becomes *the* process main.
/// but only the first one registered becomes *the* main.
#[must_use]
pub fn main_interpreter_id() -> Option<i64> {
MAIN_INTERPRETER.get().copied()
match registry().main_id.load(Ordering::Acquire) {
NO_MAIN_INTERPRETER => None,
id => Some(id),
}
}

/// Allocate a unique process-global interpreter id.
/// Allocate a unique interpreter id.
///
/// Ids are strictly monotonic and never reused for the lifetime of the process.
/// This is required for thread-safe concurrent `Interpreter` construction
/// (parallel unit tests, multi-threaded embedding).
/// Ids are strictly monotonic and never reused for the lifetime of the
/// registry, so concurrent `Interpreter` construction (parallel unit tests,
/// multi-threaded embedding) never shares an id. Without `threading` the
/// registry — like `Context::genesis()` and the GC state — is per OS thread, so
/// ids are unique within a thread rather than across the process.
pub(crate) fn alloc_interpreter_id() -> i64 {
registry().next_id.fetch_add(1, Ordering::Relaxed)
}

/// Register an interpreter state in the process-global table.
/// Register an interpreter state in the registry.
pub(crate) fn register_interpreter(state: &PyRc<PyGlobalState>) {
let id = state.interpreter_id;
let whence = state.whence;
if state.is_main {
// First `is_main` interpreter defines the process main for `get_main()`.
// First `is_main` interpreter defines the main for `get_main()`.
// Additional top-level Interpreters (embedding) keep their own `is_main`
// flag but do not displace the recorded process main.
let _ = MAIN_INTERPRETER.set(id);
// flag but do not displace the recorded main.
let _ = registry().main_id.compare_exchange(
NO_MAIN_INTERPRETER,
id,
Ordering::AcqRel,
Ordering::Relaxed,
);
}
let mut entries = registry().entries.lock();
entries.insert(
Expand Down Expand Up @@ -220,6 +230,7 @@ pub fn live_interpreter_states() -> Vec<PyRc<PyGlobalState>> {
/// only when `PyObjectRef` is `Arc`-backed).
#[cfg(feature = "threading")]
fn owned_interpreters() -> &'static Mutex<HashMap<i64, crate::Interpreter>> {
use std::sync::OnceLock;
static OWNED: OnceLock<Mutex<HashMap<i64, crate::Interpreter>>> = OnceLock::new();
OWNED.get_or_init(|| Mutex::new(HashMap::new()))
}
Expand Down