Skip to content

Add a deterministic blocking-task pool (limited multi-threading)#76

Open
mystenmark wants to merge 12 commits into
mainfrom
mlogan-msim-yield
Open

Add a deterministic blocking-task pool (limited multi-threading)#76
mystenmark wants to merge 12 commits into
mainfrom
mlogan-msim-yield

Conversation

@mystenmark

@mystenmark mystenmark commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What

Adds limited multi-threading to the simulator: spawn_blocking closures now run on real OS threads, serialized by a ThreadQuantum so that at most one thread (the main executor or one pool thread) runs at any instant. The executor grants each pool thread a turn between polling rounds in seeded-random order, so execution stays deterministic without intercepting synchronization primitives.

Blocking code that must wait for progress made elsewhere in the simulation calls msim::task::yield_blocking() in its wait loop instead of blocking the OS thread — under the quantum an OS-level block would never end the thread's turn and would hang the simulator.

Node kill/restart, panic propagation, and kill_current_node() are all handled on pool threads: killed tasks unwind at their next yield point (via a deferred abort delivered as an Abort turn), and PanicWrapper restarts are routed back to the main loop through the pool.

Why

Enables simulating code that genuinely needs to yield to another thread — e.g. synchronous execution that blocks mid-transaction waiting on a condition another task must satisfy. Previously such code could only be modeled by running it inline on the single sim thread, where any real wait deadlocks the simulator.

Design sketch

The original high-level design. The implementation refines several details — a Turn enum instead of Option<u32> so kill() can request a deferred abort at a yield point, routing kill_current_node() panics/restarts back to the main loop, per-thread condvars, lazy thread startup, and joining the pool on shutdown — but the core idea (one thread runs at a time, woken in deterministic order) is unchanged.

The runtime spawns a fixed number of extra "blocking pool" threads for code that can yield execution to other threads. Normally no code needs to yield — we poll() ready futures one at a time and they always make progress immediately, because there is no other thread that could be holding a lock they require. spawn_blocking() sends tasks to a blocking_queue; the run loop enforces that only one thing happens at a time, and wakes the blocking threads in deterministic order:

// main run loop
pub fn block_on<F: Future>(&self, future: F) -> F::Output {
    let mut task = self.spawn_on_main_task(future);
    let waker = futures::task::noop_waker();
    let mut cx = Context::from_waker(&waker);
    loop {
        self.run_all_ready();
        if let Poll::Ready(val) = Pin::new(&mut task).poll(&mut cx) {
            return val;
        }
        self.quantum.wake_all();
        let going = self.time.advance_to_next_event();
        assert!(going, "no events, the task will block forever");
    }
}

// blocking pool thread loop (a fixed number are created at startup)
run_blocking_thread(thread_id: u32) {
    self.quantum.start(thread_id);
    loop {
        // Blocking threads yield before starting each task; tasks themselves can also
        // explicitly yield, generally while waiting on a notification. Rather than
        // mocking every tokio channel, a special-purpose one calls:
        //   #[cfg(msim)] ThreadQuantum::with(|q| q.yield());
        self.quantum.yield();
        if let Ok(callable) = self.blocking_queue.recv_random(&self.rand) {
            callable();
        }
    }
}

struct ThreadQuantum {
    max_thread: u32,
    active_thread: Mutex<Option<u32>>,
    cv: CondVar,
}

impl ThreadQuantum {
    // a single static ThreadQuantum lets threads call yield() from anywhere
    fn with(cb: Fn(&ThreadQuantum));

    fn start(&self, thread_id: u32) {
        *self.active_thread.lock() = Some(thread_id);
    }

    fn yield(&self) {
        let l = self.active_thread.lock();
        let cur_thread = l.expect("current thread should be set");
        *l = None;
        // wait until we are woken
        self.cv.wait_while(l, |active| active != Some(cur_thread));
    }

    fn wake_all(&self) {
        for i in 0..self.max_thread {
            let l = self.active_thread.lock();
            *l = Some(i);
            self.cv.notify_all();                   // only the assigned thread wakes
            self.cv.wait_while(l, |a| a.is_some()); // wait until it calls yield()
        }
    }
}

Notes

  • Because a simulation now spans multiple threads, per-test state that consumers kept in thread_local!s (fail-point registries, injection overrides) must become process-global, which requires at most one simulation per process. Accordingly MSIM_TEST_NUM and MSIM_TEST_CHECK_DETERMINISM are retired in the sim_test macro (both now fail loudly; the check_determinism attribute is accepted and ignored). Replacements: an external seed-search runner (one process per seed) and running a test twice in separate processes comparing log output.
  • Default pool size is 32 (MSIM_BLOCKING_THREADS); it must exceed the max number of concurrently-parked blocking tasks in the workload.

Test plan

  • RUSTFLAGS="--cfg msim" cargo test -p msim --lib — 35 tests incl. 10 new pool tests: result delivery, determinism across a fixed seed, yield-based cross-task waits, nested spawn_blocking, panic propagation, kill-unwinds-parked-task (Drop impls run), clean shutdown with a parked task, kill_current_node restart, and deadlock detection.
  • Validated end-to-end against Sui via LOCAL_MSIM_PATH: sui-e2e-tests checkpoint/reconfiguration/address-balance suites (78 tests) and test_simulated_load_reconfig_with_crashes_and_delays pass, and repeated concurrent runs produce byte-identical simulation logs (determinism preserved).

mlogan added 12 commits July 22, 2026 20:33
spawn_blocking closures now run on real OS threads, serialized by a
ThreadQuantum: at most one thread (main executor or pool thread) runs at
a time, and the executor grants each pool thread a turn between polling
rounds in seeded-random order. Blocking code waits for sim progress via
task::yield_blocking() instead of blocking the OS thread.

Node kill unwinds in-flight blocking tasks at their next yield point via
deferred abort flags (kill() may itself run on a pool thread, so it
cannot perform a wake handshake).
…add tests

- block_on now tolerates quiet wake rounds (a parked task can progress
  purely by being re-woken), declaring deadlock only after 10k
  consecutive stalled rounds with no other events.
- spawn_blocking no longer requires a runtime context, matching spawn();
  the pool handle is set at runtime construction and cleared on shutdown
  to break the Arc cycle.
- shutdown() leaves the panic hook alone when already unwinding (std
  forbids modifying it from a panicking thread, which turned any failing
  test with parked blocking tasks into a SIGABRT).
Pool threads exit with TLS destructors still to run; an intercepted
clock call there (intercepts enabled, context gone) panicked inside an
extern "C" fn, which cannot unwind and aborts the process. Observed as
a flaky SIGABRT in sui e2e simtests under parallel load.

- disable intercepts before pool thread teardown
- give gettimeofday the same no-context bypass as clock_gettime and
  mach_absolute_time
Pool thread startup runs concurrently with the main sim thread, so the
info-level log (with a nondeterministic ThreadId) lands at a racy
position in otherwise-deterministic log output.
A PanicWrapper thrown on a pool thread kills the task's own node, so the
result-channel wrapper task is dead and cannot deliver the payload - the
requested restart would be lost. Pool threads now record such payloads
in the quantum; each wake round hands them to the main loop, which
schedules restarts via the same handling as run_all_ready (now factored
into Executor::handle_task_panic).
Tasks parked at yield points occupy pool threads; the pool must be
larger than the maximum number of concurrent waiters (e.g. sui's
execution driver allows 100 in-flight executions) or the task that
would unblock them starves. Idle threads are skipped by wake rounds,
so a large pool costs little.
- select candidate threads (aborts, parked tasks, idle threads up to
  queue length) under a single lock, and shuffle only those
- per-thread condvars + a main condvar: waking exactly the target thread
  replaces a notify_all thundering herd across the whole pool (2 x
  pool-size wakeups per turn, ~4ms/turn at 128 threads)
Lets shared sync code choose between quantum yields (on pool threads)
and regular blocking behavior.
Test frameworks may keep per-test state in process globals (needed now
that a simulation spans multiple threads), so a process must host at
most one simulation. Setting either env var now fails loudly.
Replacements: an external seed-search runner (one process per seed) for
MSIM_TEST_NUM; running a test twice in separate processes and comparing
log output for the determinism check. The check_determinism attribute is
accepted and ignored so existing annotations keep compiling.
- Remove the pool->runtime Arc cycle entirely rather than weakening it:
  the pool no longer stores the runtime Handle. Threads are spawned
  lazily from wake_round (on the main executor thread, where the Handle
  is already in context), so there is nothing to break on shutdown.
- Default pool size 128 -> 32 (still overridable via MSIM_BLOCKING_THREADS).
- Move the intercept-disable-on-exit guard into the intercept module as
  enable_intercepts_scoped()/InterceptsGuard.
- Drop is_blocking_pool_thread(): its only consumer was the Sui
  MutexTable integration, which is deferred; will reintroduce with a
  cleaner design if still needed.
- Clarify why every kill_current_node() panic in a wake round is handled:
  each is an independent per-node restart, and two tasks of the same node
  cannot both reach it in one round (sequential turns + run_one's
  is_killed() drop).
- Remove accidentally-committed GIT_CHECKPOINT.txt.
@mystenmark
mystenmark marked this pull request as ready for review July 24, 2026 20:35
@mystenmark
mystenmark requested review from halfprice and mwtian July 24, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants