async-runtime
async-runtime is a priority-aware native Rust runtime for the smol ecosystem,
with a work-stealing general worker pool and host-driven local domains. v0.3
adds a custom async-task scheduler with worker-local queues, priority global
injectors, work stealing, and parked-worker wake-up. v0.2's budgeted local
driving and lightweight cross-thread dispatch remain available.
use ;
use NonZeroUsize;
let runtime = new.build?;
runtime.spawn?.detach;
# Ok::
Smol ecosystem
The general runtime uses async-task for task machinery and
crossbeam-deque for its queues; LocalDomain continues to use
async-executor. async-channel and futures-lite support the local-domain
and task APIs. The crate schedules futures but does not own or drive an I/O
reactor. Applications remain responsible for driving their chosen I/O runtime;
async-io futures compose naturally when the host drives async-io.
Calling smol::spawn still targets smol's own global executor. Submit work
through this crate's Runtime / Spawner when it must participate in priority
scheduling, task accounting, or runtime shutdown.
Use Runtime for movable Send work. Use one LocalDomain per fixed host
thread when tasks must remain on that thread; its LocalSpawner accepts only
Send work submitted from elsewhere.
The three priorities are High, Normal, and Background. Each general
worker uses an independent weighted selector, defaulting to 8:4:1. This is
fair scheduling opportunity, not a global execution order or throughput SLA.
General scheduler (v0.3)
The v0.3 general Runtime has one FIFO local queue per priority for every
worker, plus one global injector per priority. A runnable scheduled by that
runtime's worker goes to that worker's matching local queue; a runnable
scheduled by another thread goes to the matching global injector. This gives
nested and self-woken work a locality preference without making a worker
thread-affine.
For each weighted priority opportunity (the default remains 8:4:1), a worker
normally tries its local queue first, then takes a batch from the matching
global injector, and finally tries other workers as rotating steal victims.
After a bounded local burst it checks the global injector first, preventing a
continuously self-waking local source from starving same-priority external
submissions. Stealing
can move work between workers, so callers must not infer an execution thread
from a general task's submission thread. Use LocalDomain for genuine thread
affinity.
Idle workers park on a condition variable after checking the queues. Submitting work wakes one parked worker; shutdown wakes all workers. This avoids busy waiting when the general pool is idle, but it is an implementation mechanism, not a latency or power-use guarantee.
Priority is a scheduling preference, not a strict global order, completion ratio, throughput SLA, or starvation-proof deadline service. Work stealing and the per-worker selector improve availability of queued work; applications that need realtime behavior must still keep polls short, bound their own work, and measure their target host.
The runtime does not implement an I/O reactor or prescribe one to the host.
Optional scheduler statistics
Enable the stats feature to expose a point-in-time RuntimeStats snapshot:
[]
= { = "0.3", = ["stats"] }
# use RuntimeBuilder;
# use NonZeroUsize;
let runtime = new.build?;
let stats = runtime.stats;
println!;
# runtime.shutdown_now?;
# Ok::
RuntimeStats includes approximate runnable queue counts, sleeping workers,
executions, steals, submission origin, parks, and wake notifications. It is
intended for diagnostics and benchmark interpretation: concurrent activity can
change values while the snapshot is read, and queue counts are not task
completion or liveness counts. Without the feature, neither RuntimeStats nor
Runtime::stats() is part of the public API.
v0.3 API compatibility and limits
RuntimeBuilder, Runtime, Spawner, Task, FallibleTask, priorities, and
shutdown APIs retain their v0.2 public shapes. v0.3 changes the internal general
scheduler, so code should rely on the documented priority and task-lifecycle
semantics rather than an old queue or worker-selection detail. No migration is
needed for ordinary spawn(priority, future) calls.
General queues remain unbounded and do not provide submission backpressure.
Tasks are cooperative: a long synchronous Future::poll can delay priority
selection, stealing, shutdown progress, and wake handling. A task panic is
reported through its task handle according to the existing Task semantics;
use task handles or an application panic hook when that observation matters.
Lifecycle
Tasks are cancelled when their Task handle is dropped; call detach() for
background work. Graceful shutdown rejects new work and drains every accepted
task. shutdown_now() cancels outstanding work.
LocalDomain must be created and driven on its owner thread. Its inbox carries
only Send spawn commands: local runnables and !Send data never cross a
thread boundary.
Host-driven LocalDomain (v0.2)
LocalDomain is intended for a UI, render, game, or other host-owned thread.
Create it and call its driving methods from that one owner thread. It does not
create a thread or run itself in the background.
Use run_n when the host loop allocates work in drive steps rather than time:
# use LocalDomain;
let domain = new;
// During one host-loop iteration, make at most 64 non-blocking drive steps.
let progressed = domain.run_n;
// Update UI / render a frame / run the rest of the host loop.
# let _ = progressed;
run_n(max_steps) is non-blocking. Its limit and return value are drive
steps, not completed futures and not a precise count of Future::poll calls.
One step follows the domain's try_tick progress policy: it may materialize one
remote inbox command and gives already-local runnable work an opportunity to
run. Therefore a task may need several calls to finish, and run_n(0) performs
no work and returns 0.
Use run_for for a frame or event-loop budget:
# use LocalDomain;
# use Duration;
let domain = new;
let stats = domain.run_for;
// `stats.drive_steps` is total drive progress; `stats.inbox_commands` is the
// number of accepted remote commands materialized during this call.
debug_assert!;
run_for(budget) checks the budget before each drive step and stops when the
domain is idle or the budget expires. Duration::ZERO performs no work and
returns zero progress. This is a soft time budget: Rust cannot safely
preempt a future that is already being polled, so RunStats::elapsed can exceed
the requested budget by the duration of that poll. Keep individual polls short
and cooperative when frame latency matters.
RunStats reports drive_steps, inbox_commands, and elapsed; use it to expose
per-frame progress or to detect backlog trends. It is operational feedback, not
a realtime deadline guarantee.
Fire-and-forget cross-thread dispatch
LocalSpawner::spawn remains the right API when a caller needs a Task<T>, a
result, cancellation, or panic observation. For owner-thread callbacks and
one-way asynchronous hand-offs, use the lighter fire-and-forget APIs instead:
# use LocalDomain;
let domain = new;
let local = domain.spawner;
local.dispatch?;
local.dispatch_future?;
# Ok::
Both methods accept only Send + 'static work, return SpawnError::Closed
once shutdown starts (or the domain is gone), and enqueue work for the owner
thread to materialize. They return no task handle: there is no result channel,
cancellation handle, or completion notification. Dispatches are processed in
the inbox's FIFO order, but that is not a completion-order guarantee for async
futures.
A panic in dispatched work is isolated so that the LocalDomain remains
driveable. Rust's installed panic hook still runs, so applications should set a
hook or logging integration if they need to observe such failures.
The cross-thread inbox is deliberately unbounded in v0.2. dispatch,
dispatch_future, and remote spawn do not apply backpressure; a producer
that outruns the owner thread can grow memory without limit. Bound production
at the caller, coalesce redundant updates, or drain the domain more often.
Examples
The numbered examples are a guided path from a general worker pool to a
host-driven local domain. Run any of them with cargo run --example <name>.
- 01_quick_start — build a general
Runtime, spawnSendwork, await its result, and shut down gracefully. - 02_priority — submit
High,Normal, andBackgroundwork; priorities express scheduling preference, not a global ordering guarantee. - 03_budgeted_local — keep
!Sendstate on an owner thread and drive it withrun_nor a softrun_forframe budget. - 04_cross_thread_dispatch — submit
callbacks and
Sendfutures from another thread; the owner must drive the domain for them to execute. - 05_domain_composition — compose general and local work in both directions without blocking the owner loop.
- 06_task_lifecycle — await, cancel, detach, inspect completion, and observe task panics.
- 07_shutdown — graceful local draining, timed general shutdown, immediate cancellation, and rejection of late work.
- 08_nested_worker_locality — nested general work prefers its worker's local queue; it is not thread affinity and may be stolen.
- 09_external_multi_producer — many
threads submit through cloned
Spawners into global injectors. - 10_priority_fairness — background work makes progress while High work yields; this demonstrates weighted opportunity, not a realtime guarantee.
- 11_idle_wake — external submission wakes an idle worker; use benchmarks rather than its printed time for measurement.
- 12_custom_priority_weights — set a non-zero three-priority ratio while preserving eventual opportunities.
- 13_scheduler_stats — inspect the
optional approximate counters (
cargo run --example 13_scheduler_stats --features stats). - 90_best_practice_host_loop — a practical UI/render/game host-loop shape with per-frame budgeted driving.
Performance scenarios
Performance workloads are split by question instead of being hidden in one
large benchmark. Run the suite with cargo bench, or one scenario with
cargo bench --bench <name>:
general_spawn: batch spawn/await and nested spawn; nested work also exercises worker-local routing.priority: per-priority and mixed 8:4:1 throughput under the scheduler.local_driving: drive-onlyrun_nandrun_forcost.local_dispatch: real cross-thread producer, result bridge versus both fire-and-forget paths.frame_like: preloaded inbox work under 100 us, 500 us, and 1 ms budgets.shutdown: graceful drain and immediate cancellation.yield_storm: many tasks repeatedly yielding and being requeued; it is a useful stress scenario for local routing, global injection, and stealing.v030_external_producers: submission contention from 1–16 external producers.v030_nested_locality: nested spawn/completion under different worker and child counts.v030_steal_imbalance: a parent creates yielding children, approximating an imbalanced local queue through the public API.v030_yield_wake_storm: separate cooperative-yield and externally-woken pending-task storms.v030_priority_latencyandv030_starvation: bounded probe-progress scenarios while High work is queued; they are not SLA or infinite-stream proofs.v030_idle_wake: complete park/submit/wake/re-park cycles (run withcargo bench --bench v030_idle_wake --features stats); CPU use still needs an OS profiler.
Use the same machine, Rust toolchain, workload parameters, and release profile when comparing scheduler revisions. These benchmarks describe scenarios, not an assertion that v0.3 is faster than a prior version or another runtime. The recorded environments and measurement boundaries are available in the v0.2 baseline and the v0.3 baseline.
For the functional suite, run cargo test; include optional observability with
cargo test --all-features, and documentation examples with cargo test --doc.
Status
The crate uses Edition 2021 and requires Rust 1.71 or newer. Its native targets are Windows, Linux, macOS, Android, and iOS.
CI maintains two compatibility lines: the MSRV lane uses Rust 1.71 with the
committed, known-compatible Cargo.lock, while the Latest lane runs
cargo update and tests the newest allowed dependencies on latest stable Rust.
This lets releases keep using the last compatible smol ecosystem versions until
the crate deliberately raises its MSRV.
WASM is unsupported by design. The runtime's semantics depend on a native multi-threaded general worker pool and message passing between execution domains; reducing it to single-threaded WASM would be a different runtime model.
Mobile hosts remain responsible for app lifecycle and for driving a
LocalDomain from the appropriate UI, render, or logic thread. The crate is
cross-checked for ARM64 Android and iOS; CI also runs the core suite in an
Android emulator and links the suite for an ARM64 iOS Simulator. Running it on
iOS still requires an XCTest host app. The crate is licensed under either
MIT or Apache-2.0, at your option.