SuperAPI AI Directory

rust-core-async-runtime

Coding & development

View repository

Updated 10-15 22:18

About this skill

rust-core-async-runtime

Conceptual overview of how async Rust works at the runtime level: the Future trait, the executor that polls it, the waker contract, Send rules across .await, and the choice of runtime crate. This skill teaches the mental model; mechanics live in cross-referenced skills.

Cross-references: [[rust-syntax-async-await]] [[rust-impl-async-tokio]] [[rust-core-memory-model]]


When to use this skill

  • User asks "what is Future / .await / Poll / a waker in Rust"
  • User asks "which async runtime should I pick" (tokio vs async-std vs smol vs embassy)
  • User asks "why is my async function not running" or "why does my future never complete"
  • User hits a Send error on tokio::spawn and needs to understand the rule, not just the fix
  • User asks "what is Pin / Unpin at a high level" (deep mechanics live in [[rust-syntax-async-await]] references/pinning.md)
  • User confuses async with parallel execution (it is not parallelism by itself)
  • User asks about structured concurrency, JoinSet, abort-on-drop semantics

For exact syntax of async fn / .await / async || and the keyword-level mechanics, refer the user to [[rust-syntax-async-await]]. For tokio-specific patterns (#[tokio::main], spawn_blocking, select!, channels), refer to [[rust-impl-async-tokio]].


Core mental model

Rust's async model has four moving parts that ALWAYS appear together:

  1. Future : a state machine generated by the compiler from each async fn / async {} block. It does nothing on its own.
  2. Executor : a runtime component that polls futures and drives them to completion. Without an executor, a future is inert.
  3. Waker : a handle handed to the future on each poll, used to tell the executor "I can make progress now, please poll me again".
  4. Reactor : the OS-event source (epoll / kqueue / IOCP / io_uring) that actually fires wakers when I/O becomes ready. Bundled with the executor in most runtimes.

ALWAYS state this four-part model when explaining async. NEVER let the user believe async fn foo() {} runs by itself. It only runs when an executor polls it.


The Future trait

The trait signature (from std::future::Future) :

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
    Ready(T),
    Pending,
}

The poll contract :

  • Poll::Ready(value) : the future has completed with value. The executor MUST NOT poll it again. "Once a future has completed (returned Ready from poll), calling its poll method again may panic, block forever, or cause other kinds of problems." (std::future::Future)
  • Poll::Pending : the future is not done. Before returning Pending, the future MUST register the Waker from cx so that it can be woken when progress is possible.
  • poll may be called many times until Ready. Each call is allowed to return either Pending or Ready. Idempotency-to-completion is the rule.

ALWAYS treat poll as a state-machine step, not as "running the future". The future advances one step per poll, until it returns Ready.


The waker contract

The Context<'_> argument carries a Waker. The contract from the std docs :

  • When returning Pending, the future stores a clone of the Waker somewhere reachable from the resource it is waiting on (e.g. a socket-readiness slot in the reactor).
  • When the resource becomes ready, someone (the reactor, another task, a timer) calls .wake() on that waker.
  • wake() schedules the task containing the future for re-polling by the executor.
  • On multiple polls, "only the Waker from the Context passed to the most recent call should be scheduled to receive a wakeup." (std::future::Future)

ALWAYS keep the most recent waker. NEVER hold a stale waker from an earlier poll; the executor may have moved the task between threads or replaced the waker, and waking the old one is a no-op or worse.

NEVER busy-loop returning Pending without registering a waker. That future will never complete, because nothing will ever wake it.


The executor model

An executor is a task-scheduler. Concretely it :

  1. Owns a queue of ready tasks. Each task wraps one top-level future.
  2. Repeatedly pops a task, polls its future once.
  3. If the future returns Ready, the task is dropped.
  4. If the future returns Pending, the task sits idle until its waker is invoked, which puts it back on the ready queue.

Two scheduler shapes exist :

  • Single-threaded (current-thread) : all tasks run on the calling thread. Tasks do NOT need Send. Good for !Send data (Rc, RefCell, MutexGuard across await) and for embedded.
  • Multi-threaded (work-stealing) : worker threads steal tasks from each others' queues. Tasks MUST be Send + 'static. Default for tokio.

ALWAYS pick current-thread when your tasks hold !Send data deliberately. NEVER assume async means "runs in parallel": one current-thread executor on one OS thread runs every task on that one thread, cooperatively.

ALWAYS pick multi-threaded for CPU-utilising workloads with many independent tasks. The work-stealing scheduler is what gives async Rust its parallelism.


Send across .await

This is the most-common confusion. The rule :

An async fn returns an anonymous Future. That future is Send iff every value held across each .await point is Send. The compiler computes this structurally.

A value is "held across .await" if it is alive at the .await line. Drop a value before .await and it is not held across.

Common !Send types that defeat Send futures :

  • Rc<T> : non-atomic refcount.
  • RefCell<T> borrow (Ref<'_, T> / RefMut<'_, T>) : !Send.
  • std::sync::MutexGuard<'_, T> : !Send by design (must release on the locking thread).
  • *mut T, *const T raw pointers : !Send unless wrapped.
  • Generators / streams holding any of the above.

Failure looks like :

error: future cannot be sent between threads safely
  --> note: future is not `Send` as this value is used across an await

ALWAYS scope !Send locks: { let g = m.lock().unwrap(); /* use g */ } BEFORE the .await. Drop g at the closing brace; then the await follows with no MutexGuard alive.

NEVER hold std::sync::MutexGuard across .await. Use tokio::sync::Mutex if you really need to hold a lock across await; its guard is Send-safe.

See references/anti-patterns.md for the two canonical examples and their fixes.


Pin / Unpin overview

Async futures generated by the compiler are self-referential : the state machine may hold a pointer into its own buffer. Moving such a value mid-execution would invalidate the pointer.

Pin<P> (where P is a pointer type) is a wrapper that guarantees the pointed-to value will not move until it is dropped. The waker contract requires poll to take Pin<&mut Self> precisely because the runtime must not move the future between polls.

  • Unpin is an auto-trait : a type is Unpin when it is safe to move even when pinned. Almost everything except compiler-generated futures and self-referential types implements Unpin automatically.
  • Compiler-generated async futures are !Unpin by default.
  • Box::pin(future) / pin!(future) are the standard ways to construct a pinned future before handing it to an executor or combinator that requires Pin<&mut F>.

ALWAYS use Box::pin when you have an owned future and need a pinned pointer. For stack-local pinning, use the std::pin::pin! macro.

NEVER try to move a value out of a Pin<&mut T> unless T: Unpin. The deep mechanics (PhantomPinned, structural vs projection pinning, pin-project) live in [[rust-syntax-async-await]] references/pinning.md and are out of scope for this skill.


Runtime choice matrix

Runtime Status (2026-05) Pick when
tokio Production de-facto standard, 1.x stable Multi-threaded workloads, networked services, anything that wants a mature ecosystem (axum, tonic, reqwest, sqlx). Default choice.
async-std Mostly-deprecated, last release 2022 NEVER for new code. Existing async-std code should migrate to tokio or smol.
smol Minimalist, library-friendly Library authors who want runtime-agnosticism. Embedded into other runtimes via async-executor. Small, easy to embed.
embassy no_std embedded async Microcontrollers and bare-metal. Provides executors, time, network drivers for embedded targets.

ALWAYS recommend tokio as the default for application code, unless one of these conditions applies :

  • Library that does not want to lock callers to a runtime : smol or runtime-agnostic with explicit Future API.
  • no_std embedded target : embassy.
  • Existing codebase already standardised on another runtime : keep consistency.

NEVER mix two runtimes in the same process (e.g. spawning tokio tasks inside a smol executor) without an explicit bridge. The two executors do not share wakers; futures from one runtime polled by another will either panic or block.

NEVER call block_on from inside a running runtime. Tokio's Runtime::block_on and tokio::runtime::Handle::block_on panic if called from a thread that already runs a tokio runtime ("Cannot start a runtime from within a runtime"). Use tokio::spawn + .await instead, or tokio::task::block_in_place for the blocking-shim escape.


Decision tree A: which runtime

What is the deployment target?
|
+-- Microcontroller / bare metal (no_std)
|   -> embassy
|
+-- Library crate (no opinion on runtime)
|   -> Return Futures and let caller choose, or use smol's `async-executor`
|
+-- Application / service / CLI
    -> tokio (default; multi-threaded by default,
                current-thread for `!Send` data)

Decision tree B: which scheduler flavor

Are any of your tasks `!Send`
(hold Rc / RefCell / std MutexGuard across await)?
|
+-- YES -> current-thread runtime
|          tokio::runtime::Builder::new_current_thread()
|          or  #[tokio::main(flavor = "current_thread")]
|
+-- NO  -> Do you need CPU parallelism across tasks?
           |
           +-- YES -> multi-threaded runtime (default tokio)
           |          #[tokio::main]
           |
           +-- NO  -> current-thread is fine and slightly cheaper
                     (no work-stealing overhead)

Structured concurrency overview

Async tasks have a parent-child story only if you give them one. tokio::spawn produces a detached task : it runs independently of its caller. Dropping the JoinHandle does NOT cancel the task.

Structured concurrency = parent task owns the lifetime of its children. In tokio this is provided by JoinSet<T> :

  • JoinSet::new() creates a set.
  • .spawn(future) adds a task; the AbortHandle lives inside the set.
  • .join_next().await yields completed task results one at a time.
  • Dropping the JoinSet aborts every still-running task in it. This is the structured-concurrency guarantee.

ALWAYS prefer JoinSet over a Vec<JoinHandle<T>> when you want children to die with the parent. NEVER rely on dropping a bare JoinHandle to cancel a task; you must call .abort().

Cancellation propagation : abort() schedules cancellation but does not run it synchronously. The task observes cancellation at its next .await point. Tasks doing CPU work between awaits will not be cancelled until they hit an await. ALWAYS sprinkle .await points (e.g. tokio::task::yield_now().await) in long CPU loops if you need responsive cancellation; better, move the CPU work to spawn_blocking.

See [[rust-impl-async-tokio]] for full JoinSet patterns and select! cancellation safety.


Section: Avoid these mistakes

(Full list with WHYs in references/anti-patterns.md.)

  • Calling a blocking function (std::fs::read, std::thread::sleep, a CPU-loop) directly inside async fn. Blocks the executor thread; other tasks starve. Use tokio::task::spawn_blocking or the async equivalent.
  • Holding std::sync::MutexGuard across .await. Future becomes !Send; on multi-thread runtimes the compiler rejects it.
  • Calling Runtime::block_on from inside an already-running runtime. Panics with "Cannot start a runtime from within a runtime".
  • Assuming async fn foo() {} runs by itself. It does nothing; only an executor polling it makes it run. let _ = foo(); produces an unused-future warning.
  • Confusing tokio::spawn with std::thread::spawn or rayon parallelism. tokio::spawn schedules cooperative async tasks on the runtime workers; it does not create OS threads. For CPU-bound parallel work, use rayon or spawn_blocking.

Reference links

For deeper drill-downs see:

  • references/methods.md : Future / Poll / Waker / Context signatures, executor entry-point signatures.
  • references/examples.md : minimal hand-written futures, runtime setup snippets, JoinSet skeleton.
  • references/anti-patterns.md : common mistakes with root-cause explanations.