| name | rust-core-async-runtime |
| description | Use when the user needs the conceptual overview of Rust async: the Future trait, how executors work, Send across .await, Pin/Unpin overview, the choice between tokio/async-std/smol/embassy runtimes, and structured concurrency basics. Prevents picking the wrong runtime, blocking the executor with sync code, holding non-Send across .await, ignoring the waker contract, or confusing async fn with parallel execution. Covers: Future trait + Poll, waker contract, executor model, Send across .await rules, Pin/Unpin overview, runtime choice matrix (tokio multi-thread default, async-std mostly-deprecated, smol minimalist, embassy embedded), structured concurrency overview (JoinSet, parent-child task lifetime). Keywords: async runtime, Future trait, Poll, executor, tokio, async-std, smol, embassy, "what is .await", "how does async work", waker, Pin, Unpin, Send across await, structured concurrency, JoinSet, "async fn doesn't run", "future never completes", multi-thread vs current-thread, "which runtime", "block on", task, spawn.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires Rust 1.85+, edition 2024. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
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:
Future : a state machine generated by the compiler from each async fn / async {} block. It does nothing on its own.
- Executor : a runtime component that polls futures and drives them to completion. Without an executor, a future is inert.
- Waker : a handle handed to the future on each
poll, used to tell the executor "I can make progress now, please poll me again".
- 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 :
- Owns a queue of ready tasks. Each task wraps one top-level future.
- Repeatedly pops a task, polls its future once.
- If the future returns
Ready, the task is dropped.
- 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.