Skip to content

perf: streamline timer integration - #3

Draft
tisonkun wants to merge 7 commits into
mainfrom
feat/timer-context
Draft

perf: streamline timer integration#3
tisonkun wants to merge 7 commits into
mainfrom
feat/timer-context

Conversation

@tisonkun

@tisonkun tisonkun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • expose a reactor-owned TimerService and a cheap task-side TimerHandle, without adding an optional aggregate IoContext
  • replace the multi-call park protocol with one must-use prepare_wait operation returning WaitPlan::{Immediate, Until, Indefinite}
  • make timeout and scheduling futures own their timer handle so they can cross a 'static task boundary when their user future or task can
  • keep the production synchronization self-contained: a standard-library Mutex<VecDeque<_>> queue and a private inline waker slot, with slab remaining the only production dependency
  • add a runnable caller-owned reactor example, contract-focused concurrency and unwind tests, and steady-state Divan comparisons with consistent warmup boundaries

Design Notes

API and ownership

TimerService::new() now constructs only the reactor-owned service. TimerService::handle() returns a cloneable TimerHandle for application contexts and task state. The service alone advances the timing wheel and performs deterministic shutdown; handles can observe time, create delays, and submit registration or cancellation operations, but cannot drive the backend.

This is deliberately not a one-to-one translation of Boost.Asio. Asio's execution_context is a service-registry base, io_context owns a public run loop, and waitable timers are executor-associated objects. Scorpio owns neither an executor nor the application's platform wait primitive, so introducing an IoContext with one optional timer field would add construction and access ceremony without removing caller responsibility. Applications can instead use a non-optional field in their own context:

#[derive(Clone)]
struct AppContext {
    timer: TimerHandle,
}

let service = TimerService::new();
let context = AppContext {
    timer: service.handle(),
};

The TimerHandle name reflects that boundary more accurately than TimerContext: it is a task-facing capability, not an execution context. TimerService remains the timer-specific owner. TimerDriver would overstate ownership of the enclosing reactor, while TimerScheduler would imply responsibility for platform waiting.

The high-level timeout, timeout_at, interval, and scheduling APIs are methods on TimerHandle. Timeout and scheduling constructors clone or embed the handle before returning, so their futures do not borrow the caller's local handle. Compile-time tests assert Send + 'static for these returned futures when the supplied future or task satisfies the same boundary.

Reactor wait protocol

The previous public sequence required callers to inspect TurnResult, register a wake, handle a Boolean race result, and then query next_poll_at in the correct order. That protocol was correct only when every integration reproduced the ordering exactly.

TimerService::turn now performs bounded work and returns (). After dispatching one timer turn and any ready I/O, the reactor calls prepare_wait(&Waker) exactly once. The returned #[must_use] WaitPlan has three states:

Plan Reactor action
Immediate perform a non-blocking I/O poll, then turn the timer service again
Until(deadline) wait until the deadline unless I/O or the registered waker interrupts first
Indefinite wait until I/O or the registered waker interrupts

Queue emptiness and the replaceable reactor waker share one mutex. A producer that races prepare_wait therefore either makes prepare_wait return Immediate or takes and wakes the newly registered waker. The deadline is chosen as part of the same public operation, so the lost-wakeup ordering error is no longer expressible through the supported API.

scorpio/examples/custom_reactor.rs demonstrates the complete lifecycle with only standard-library threading and parking: a reactor thread owns and turns the service, an application task owns AppContext { timer: TimerHandle }, the registered waker unparks the reactor, and shutdown wakes and joins the thread. Run it with cargo run -p scorpio --example custom_reactor.

Queue and inline waker

Registrations and cancellations use one reusable Mutex<VecDeque<Operation>>. Producers append under the mutex; only an empty-to-nonempty transition takes the registered reactor waker. Waker clone, drop, and wake code runs outside the queue critical section. The single-writer service swaps bounded batches into private scratch storage before touching the timing wheel.

Each delay stores its task waker inline in the existing Arc<TimerState> allocation. The private DelayWakeSlot has three ownership states: READY, REGISTERING, and absorbing TERMINAL. A terminal publisher never spins or waits. When publication observes REGISTERING, the polling task retains exclusive slot ownership, cleans the replacement, and then observes the lifecycle state published before TERMINAL.

No Crossbeam, external AtomicWaker, or mea synchronization primitive is used in production. The specialized slot is kept private and contains only the transitions required by this timer protocol.

The cleanup order is also unwind-safe at the public boundaries covered here. Service drop publishes all service-owned timers as closed before running the reactor waker destructor. Registered delay drop enqueues durable reclamation before clearing the task waker. Tests use a RawWaker whose destructor intentionally unwinds to verify both contracts.

Test simplification and review fixes

Tests now use the public WaitPlan contract instead of inspecting the removed TurnResult, register_wake, and next_poll_at protocol. They cover pending operation backlogs, earliest deadlines, replaceable wake registration, the first-producer race, bounded progress, owned high-level futures, and the runnable integration path.

The old Loom test duplicated the waker state machine without calling the production type, so it could remain green after a production regression. It and the Loom dependency were removed. A small test hook now pauses the actual DelayWakeSlot::register_and_load implementation immediately after it claims REGISTERING; the test then runs the actual TimerState::publish_terminal path and verifies lifecycle observation, delegated cleanup, and final ownership state deterministically.

Interval no longer stores duplicate deadline and handle fields beside the Delay that already owns both. Missed-tick tests assert the next public tick() result rather than inspecting those private copies.

Four fresh, context-isolated ScopeDB review passes were run sequentially against the complete resulting tree. Confirmed findings were fixed before the next pass:

  1. reordered service and delay cleanup so panicking waker destructors cannot skip durable terminal or cancellation state;
  2. removed the mirrored Loom model and dependency, deduplicated Interval state, and repaired the packaged README link;
  3. restored deterministic coverage through the production waker transition and corrected frontend benchmark warmup/sample boundaries;
  4. changed the Scorpio frontend benchmark back to TimerService::new() so it measures the production system-clock path rather than the deterministic test clock.

Apart from that benchmark-path finding, the fourth pass reported no correctness, API-flow, line-reduction, or project-consistency findings.

Divan measurement boundaries

timer/frontend_lifecycle measures relative-delay creation, first poll, and drop for Scorpio, Tokio, async-io, and futures-timer at 64 and 1,024 timers. It excludes backend draining for every implementation. Each implementation performs untimed warmup before sampling so the comparison represents steady-state queue or driver storage instead of charging Scorpio's first VecDeque allocation while reusing the other drivers. Scorpio still creates a fresh boxed service per Divan input; the warmup returns queue capacity to its producer side before the timed closure. Deferred result destruction drains and validates the exact operation count outside the timed interval.

The former one-timer case was removed because one iteration was only around two to three times Divan's 41 ns measurement precision and did not provide a stable regression signal. The 64 and 1,024 item cases retain the useful small-batch and bulk boundaries.

timer/scorpio_service separately measures registration and cancellation queue drain. timer/expire_registered constructs and registers all inputs outside timing, then measures service expiry and terminal polling for same-deadline buckets and a distribution spanning selected wheel levels. cargo x bench --quick runs all 16 cases as isolated-process smoke checks.

Current performance result

Current steady-state measurements were collected after the final benchmark corrections on an Apple M4 Max running macOS 26.3.1 with rustc 1.98.0. Each implementation was run three times in interleaved order with --sample-count 5000 --sample-size 1 --color never; the table reports the median of the three run medians.

Items Scorpio Tokio Scorpio gap
64 4.082 us 3.582 us +14.0%
1,024 64.45 us 56.04 us +15.0%

This is a caller-front-end boundary, not a universal runtime ranking. Tokio owns a runtime, driver lock, and intrusive timer entry; Scorpio preserves explicit caller-owned service advancement and queues cross-thread operations.

The inline-waker optimization was separately evaluated on matched pre/post revisions before the steady-state warmup correction. Because both sides used the same older boundary, the relative result remains useful for attributing that change, while the absolute numbers are not presented as the current benchmark output. Across 64 and 1,024 item frontend and registered-expiry cases, removing the per-delay Box<Waker> reduced medians by roughly 16.6% to 19.8%. The allocation probe removed exactly one allocation per first-polled delay.

The remaining gap is consistent with work Scorpio still performs by design: one Arc<TimerState> allocation per first poll and explicit multi-producer queue submission/cancellation. Copying Tokio's self-referential pinned entry would add raw-pointer lifetime, pinning, driver-lock, and reuse-generation invariants. That complexity is not justified by the remaining 14-15% synthetic frontend gap without a representative application workload showing the same bottleneck.

Proportionate follow-ups remain evidence-gated: isolate the remaining TimerState allocation before changing ownership; profile cancellation drain and cache behavior before adding batching; and revisit producer sharding only for a demonstrated multi-producer workload. Do not add another public context layer, external synchronization primitive, pool, or thread-local fast path solely to improve this benchmark.

Validation

  • cargo x test: 63 unit tests and 1 doctest passed
  • cargo x build --locked: all workspace targets, examples, benches, and bins passed with an unchanged lockfile
  • cargo x lint: Clippy with denied warnings, nightly formatting, Taplo, typos, license headers, and rustdoc passed
  • cargo x bench --quick: all 16 Divan smoke cases passed
  • cargo +1.85.0 check -p scorpio --all-features --tests --examples: MSRV passed
  • cargo run -p scorpio --example custom_reactor: the end-to-end reactor completed
  • cargo package -p scorpio --allow-dirty --no-verify: packaged 12 files successfully; the README design link resolves to the repository rather than an omitted package-relative file
  • targeted Miri passed the forced REGISTERING publication path and all three waker-drop unwind cleanup tests
  • four sequential fresh ScopeDB review passes completed with every confirmed finding fixed before the next pass

The branch was rebased onto origin/main at 8c9df1c before this refinement. The PR remains draft and is not authorized for merge.

@tisonkun tisonkun changed the title feat: add composable I/O context perf: streamline timer context Aug 10, 2026
@tisonkun tisonkun changed the title perf: streamline timer context perf: streamline timer service Aug 10, 2026
@tisonkun
tisonkun force-pushed the feat/timer-context branch from 8f44dbc to 6a4285f Compare August 23, 2026 16:00
@tisonkun tisonkun changed the title perf: streamline timer service perf: streamline timer integration Aug 23, 2026
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.

1 participant