# Rust Core Async Runtime

> 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", tas

- Skill: `impertio-studio/rust-core-async-runtime` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/rust-core-async-runtime`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/rust-core-async-runtime/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: Impertio-Studio (https://skillmd.com/u/impertio-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/impertio-studio/rust-core-async-runtime

---


# 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`][future]) :

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

```rust
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][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][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 :

```text
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

[future]: https://doc.rust-lang.org/std/future/trait.Future.html
[pin]: https://doc.rust-lang.org/std/pin/index.html
[async-book]: https://rust-lang.github.io/async-book/
[tokio]: https://docs.rs/tokio/latest/tokio/
[tokio-task]: https://docs.rs/tokio/latest/tokio/task/index.html
[tokio-joinset]: https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html

- [`std::future::Future`][future]
- [`std::pin`][pin]
- [Async Book][async-book]
- [Tokio crate root][tokio]
- [`tokio::task`][tokio-task]
- [`tokio::task::JoinSet`][tokio-joinset]

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.

