perf: streamline timer integration - #3
Draft
tisonkun wants to merge 7 commits into
Draft
Conversation
tisonkun
force-pushed
the
feat/timer-context
branch
from
August 23, 2026 16:00
8f44dbc to
6a4285f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TimerServiceand a cheap task-sideTimerHandle, without adding an optional aggregateIoContextprepare_waitoperation returningWaitPlan::{Immediate, Until, Indefinite}'statictask boundary when their user future or task canMutex<VecDeque<_>>queue and a private inline waker slot, withslabremaining the only production dependencyDesign Notes
API and ownership
TimerService::new()now constructs only the reactor-owned service.TimerService::handle()returns a cloneableTimerHandlefor 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_contextis a service-registry base,io_contextowns 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 anIoContextwith 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:The
TimerHandlename reflects that boundary more accurately thanTimerContext: it is a task-facing capability, not an execution context.TimerServiceremains the timer-specific owner.TimerDriverwould overstate ownership of the enclosing reactor, whileTimerSchedulerwould imply responsibility for platform waiting.The high-level
timeout,timeout_at, interval, and scheduling APIs are methods onTimerHandle. 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 assertSend + 'staticfor 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 querynext_poll_atin the correct order. That protocol was correct only when every integration reproduced the ordering exactly.TimerService::turnnow performs bounded work and returns(). After dispatching one timer turn and any ready I/O, the reactor callsprepare_wait(&Waker)exactly once. The returned#[must_use]WaitPlanhas three states:ImmediateUntil(deadline)IndefiniteQueue emptiness and the replaceable reactor waker share one mutex. A producer that races
prepare_waittherefore either makesprepare_waitreturnImmediateor 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.rsdemonstrates the complete lifecycle with only standard-library threading and parking: a reactor thread owns and turns the service, an application task ownsAppContext { timer: TimerHandle }, the registered waker unparks the reactor, and shutdown wakes and joins the thread. Run it withcargo 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 privateDelayWakeSlothas three ownership states:READY,REGISTERING, and absorbingTERMINAL. A terminal publisher never spins or waits. When publication observesREGISTERING, the polling task retains exclusive slot ownership, cleans the replacement, and then observes the lifecycle state published beforeTERMINAL.No Crossbeam, external
AtomicWaker, ormeasynchronization 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
RawWakerwhose destructor intentionally unwinds to verify both contracts.Test simplification and review fixes
Tests now use the public
WaitPlancontract instead of inspecting the removedTurnResult,register_wake, andnext_poll_atprotocol. 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_loadimplementation immediately after it claimsREGISTERING; the test then runs the actualTimerState::publish_terminalpath and verifies lifecycle observation, delegated cleanup, and final ownership state deterministically.Intervalno longer stores duplicate deadline and handle fields beside theDelaythat already owns both. Missed-tick tests assert the next publictick()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:
Intervalstate, and repaired the packaged README link;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_lifecyclemeasures 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 firstVecDequeallocation 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_serviceseparately measures registration and cancellation queue drain.timer/expire_registeredconstructs 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 --quickruns 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.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
TimerStateallocation 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 passedcargo x build --locked: all workspace targets, examples, benches, and bins passed with an unchanged lockfilecargo x lint: Clippy with denied warnings, nightly formatting, Taplo, typos, license headers, and rustdoc passedcargo x bench --quick: all 16 Divan smoke cases passedcargo +1.85.0 check -p scorpio --all-features --tests --examples: MSRV passedcargo run -p scorpio --example custom_reactor: the end-to-end reactor completedcargo 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 fileREGISTERINGpublication path and all three waker-drop unwind cleanup testsThe branch was rebased onto
origin/mainat8c9df1cbefore this refinement. The PR remains draft and is not authorized for merge.