A thread blocked on a slow operation cannot use that time to run another ordinary call on the same thread. Async code can represent waiting as a value, return control to an executor, and resume when progress is possible. This is useful for many overlapping waits; it does not make CPU-heavy work disappear.
Rust provides async, .await and the standard Future contract. It does not provide a general-purpose async runtime with networking, timers and task spawning in std. This distinction matters when choosing APIs and reading examples.
You should already understand threads, shared state and ownership. We will first inspect a complete standard-library future without pretending to build a production executor, then run a small program with a declared runtime dependency.
The three layers
| Layer | Responsibility | Examples |
|---|---|---|
| Language | Build resumable computations and await their results | async fn, async move, .await |
| Standard library | Define the interoperability contract | Future, Poll, Context, Waker, Pin |
| Runtime and ecosystem | Drive tasks, arrange wakeups, provide async operations | Executors, socket readiness, timers, async channels |
An executor polls tasks that are ready to make progress. A runtime often combines an executor with facilities for I/O and timers. A future may rely on those facilities, so supporting the Future trait does not mean it can run meaningfully under every executor.
The Book introduces futures and async syntax. Its helper libraries are dependencies too, rather than hidden parts of std.
Calling an async function creates a future
An async fn that appears to return T actually returns an anonymous type implementing Future<Output = T>. Calling it creates the future; the body runs when the future is polled. Merely storing it does not start a thread or schedule a task.
Inside async code, operation().await polls the child future. If it is ready, execution can continue immediately. If it is pending, the enclosing future can return control and later resume at that point. An .await is a possible suspension point, not a guarantee of a thread switch or even a suspension.
Two successive awaits perform operations in sequence. Even creating two futures first does not cause concurrency if you await one to completion before beginning to poll the other. A joining combinator or separately scheduled tasks must drive both for their waits to overlap.
Future, Poll and Waker
The essential signature is shown here for reference; this block is a trait sketch, not a separate program:
trait Future {
type Output;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output>;
}
Poll::Ready(value) means completion. Poll::Pending means there is no result yet. A pending future must arrange for the task's waker to be notified when another poll can make progress. A wakeup requests another poll; it does not itself run the async function or guarantee that the next poll will complete.
Real I/O futures usually register the latest waker with a readiness source. A busy loop polling every pending task wastes CPU. A no-op waker cannot wake a sleeping executor. Completed futures must not be polled again; they may panic or otherwise behave outside their normal contract. See the Future API.
A runnable standard-library polling demonstration
Create a project:
cargo new future-lab --edition 2024
cd future-lab
Put this complete program in src/main.rs and run cargo run:
use std::future::Future;
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll, Wake, Waker};
#[derive(Default)]
struct WakeCount(AtomicUsize);
impl Wake for WakeCount {
fn wake(self: Arc<Self>) {
self.0.fetch_add(1, Ordering::Relaxed);
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
struct YieldOnce {
yielded: bool,
}
impl Future for YieldOnce {
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
let this = self.get_mut();
if this.yielded {
Poll::Ready(21)
} else {
this.yielded = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
async fn doubled() -> u32 {
let value = YieldOnce { yielded: false }.await;
value * 2
}
fn main() {
let wakes = Arc::new(WakeCount::default());
let waker = Waker::from(Arc::clone(&wakes));
let mut context = Context::from_waker(&waker);
let mut future = pin!(doubled());
assert_eq!(wakes.0.load(Ordering::Relaxed), 0);
assert!(future.as_mut().poll(&mut context).is_pending());
assert_eq!(wakes.0.load(Ordering::Relaxed), 1);
assert_eq!(future.as_mut().poll(&mut context), Poll::Ready(42));
println!("one wake, then result 42");
}
Output: one wake, then result 42.
Trace the program:
- Constructing
doubled()creates its state without running its body. - The first outer poll reaches the child future, which records a yield and requests a wakeup.
- The child returns
Pending, so the outer future also returnsPending. - The demonstration explicitly polls again after observing the wake request.
- The child returns
21; the async body computes42and completes in that same outer poll.
This driver knows the exact behavior of one synthetic future. It has no event loop, sleeping mechanism, task queue, timers or I/O readiness registration. Its waker only counts notifications. Do not turn these two polls into an unconditional polling loop and call it a general executor. The safe Wake trait lets us inspect wakeups without constructing an unsafe raw waker.
Why Pin appears in poll
An async computation can retain references into its own stored state across suspension. Moving that state after it has been pinned could invalidate those references. Pin restricts how a pinned value may be moved; it does not allocate memory or make data immutable.
pin! pins a future locally. Box::pin is useful when an owned pinned allocation is needed. The compiler-generated future can be polled through a pinned mutable reference without exposing unsafe operations to this program.
YieldOnce contains only a boolean and is Unpin, meaning pinning adds no movement restriction for it. That is why its implementation can safely call self.get_mut(). Do not generalize that method to every future or reach for get_unchecked_mut to bypass an error. Ordinary application code seldom needs to implement Future manually. See std::pin.
Run overlapping waits with an explicit runtime
Use a separate project so the dependency boundary is visible:
cargo new async-lab --edition 2024
cd async-lab
Replace Cargo.toml with:
[package]
name = "async-lab"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["macros", "rt", "time"] }
This dependency requirement allows compatible Tokio releases. Cargo records the actual resolution in Cargo.lock; it is not a claim about the newest release. These features enable the macro, a current-thread runtime and timers. They do not enable every networking API.
Replace src/main.rs with:
use std::time::Duration;
use tokio::time::{sleep, timeout};
async fn label_after_wait(label: &'static str) -> &'static str {
sleep(Duration::from_millis(10)).await;
label
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (left, right) = tokio::join!(label_after_wait("left"), label_after_wait("right"),);
assert_eq!((left, right), ("left", "right"));
let result = timeout(Duration::from_millis(10), std::future::pending::<()>()).await;
assert!(result.is_err());
println!("left + right; timeout observed");
}
Run cargo run. Output: left + right; timeout observed.
The attribute builds a runtime before entering the async body. join! polls both branches on the current task, allowing their timer waits to overlap. It does not spawn two operating-system threads. We intentionally make no assertion about elapsed wall-clock time: scheduling and timer precision vary.
The timeout demonstrates a runtime timer expiring around a future that never completes. A timeout is cooperative: it cannot preempt a future whose poll blocks or performs an unbounded computation without yielding. See Tokio's main macro, join macro and timeout.
Tasks, blocking work and cancellation
A task is a future scheduled by an executor. tokio::spawn schedules an independently managed task and returns a task join handle. That is different from join!, which keeps its branches inside the current task. Tokio's spawn requires the future and its output to be Send + 'static, even with a current-thread runtime. The join! example can keep borrowed work inside its enclosing task instead.
Blocking calls remain blocking inside an async fn. std::thread::sleep, synchronous socket reads and long CPU loops can prevent every other task on the current execution thread from progressing. Use runtime-aware I/O and timers. Where appropriate, move bounded blocking work to facilities such as tokio::task::spawn_blocking; for sustained CPU parallelism, consider a deliberately sized worker pool. A blocking pool is not an unlimited resource.
Dropping an unfinished future stops polling that future and drops its owned state. It does not undo a request already sent or a write already performed. Dropping a Tokio task join handle detaches the task rather than cancelling it. Aborting a task is cooperative, and already-running blocking work generally cannot be aborted through that task handle.
Keep lock guards and borrows short around suspension points. A standard mutex guard held across .await can prevent other work from making progress. Copy out the needed data under a short lock, release it, then await. A runtime-aware mutex may allow a guard across suspension, but you still need a reason to hold the lock that long. See Tokio's task documentation.
Idioms and pitfalls
- A future is a computation value, not a running thread. Use
.awaitor an executor to drive it. .awaitrequires async context. Standardfn mainneeds a runtime entry point or an explicit driver.- Timer, socket, spawning and joining APIs may come from different crates. Read their runtime requirements instead of mixing them by name.
- Do not use a no-op waker for a pending operation that needs real wakeups.
async movemoves captures into the future; captured references still must remain valid for the future's use.- Concurrency limits and timeouts belong in the design. Thousands of tasks can still open too many connections or allocate too much memory.
Exercises
1. Count the polls
In the standard-library example, how many times is the child polled, and how many wake requests occur? If it initially returned Ready(21), would an .await have to suspend?
2. Observe laziness and destruction
Create an async block that owns a value with a destructor. Drop the future without polling it. Prove that its owned capture is dropped while its body never executes. Use a complete standard-library program.
3. Spot false concurrency
An async function calls std::thread::sleep before every .await. Explain why join! on a current-thread runtime cannot overlap those sleeps. Name an appropriate replacement for timer waits and a separate strategy for blocking file-processing work.
Solutions
1. Polling is demand driven
There are two child polls and one wake request. A ready child lets the parent continue in the same poll; .await need not suspend. A wakeup is required here because the first poll deliberately returns Pending while arranging immediate further progress.
2. Captures are owned even before the body runs
Use this as a separate future-lab/src/main.rs:
use std::cell::Cell;
struct Notice<'a>(&'a Cell<bool>);
impl Drop for Notice<'_> {
fn drop(&mut self) {
self.0.set(true);
}
}
fn main() {
let dropped = Cell::new(false);
let ran = Cell::new(false);
let notice = Notice(&dropped);
let body_ran = &ran;
let future = async move {
body_ran.set(true);
drop(notice);
};
assert!(!dropped.get());
drop(future);
assert!(dropped.get());
assert!(!ran.get());
}
The move capture becomes part of the future's owned state. Destroying that state drops notice. No polling means the body's update to ran never happens.
3. Blocking prevents cooperative progress
The OS thread sleeps instead of returning to the executor. Replace timer sleeps with tokio::time::sleep(...).await. Move suitable synchronous file-processing work into a bounded blocking worker strategy, or use the runtime's file APIs while understanding how they are implemented. Merely wrapping a synchronous call in async changes neither its blocking behavior nor its cost.
Key takeaways
asyncbuilds futures; executors poll them; runtime facilities arrange external wakeups.Pendingrequires a wakeup plan.Readyends the computation.- Pinning protects movement-sensitive state, while ordinary async code uses safe pinning helpers.
- Awaiting sequentially, joining branches and spawning tasks have different scheduling behavior.
- Timeouts and cancellation are cooperative and cannot undo external effects.
Next lesson
Continue with Testing Rust programs to turn assumptions about behavior into repeatable checks.

