In Rust an async fn runs nothing until someone polls it: understand Future,
Pin and Waker with a thirty-line mini-runtime, and then use Tokio for spawn,
select!, channels, semaphores and cancellation.
An async function does nothing on its own
In JavaScript or Python, calling an asynchronous function sets it in motion at once on the language's
event loop. In Rust it does not: an async fn returns a value, a Future, and that value is
inert until an executor polls it. The documentation of std::future::Future
says it in one line: futures alone are inert and must be actively polled for
the computation to advance. Rust has no asynchronous runtime in its standard library: you choose one, for example
Tokio.
The cost of this design shows in the code: you have to understand three pieces that elsewhere stay hidden.
Future::poll is the method that advances the computation; Waker is the way a future tells
the executor "call me back"; Pin guarantees that the future is not moved in memory during
execution. The advantage is that there is no hidden runtime and no garbage collector: futures are ordinary
values, which you can create, compose and discard without scheduling costs you did not choose. For a comparison
with the JVM, see the article on virtual threads.
A statement of honesty: there is no Rust compiler on this machine (rustc
and cargo are absent), and I did not install a toolchain. No Rust listing in this article was compiled
or run: every output is marked "inferred, not run". The code follows the documented APIs of
std, Tokio and tokio-util consulted on 2026-11-12; compile it before trusting it.
Future, poll and Waker: a mini-runtime in thirty lines
The fastest way to understand asynchronous Rust is to write the smallest possible executor. The listing defines
a Future by hand (ContoAllaRovescia, "countdown", which answers "not yet" three times), a
Waker that wakes the current thread, and a block_on function that polls the future in a
loop. Everything uses only the standard library.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
// A hand-written Future: it answers "not yet" three times, then "ready".
struct ContoAllaRovescia {
restanti: u32,
}
impl Future for ContoAllaRovescia {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.restanti == 0 {
Poll::Ready("launched")
} else {
println!("poll: remaining {}", self.restanti);
self.restanti -= 1;
cx.waker().wake_by_ref(); // "call me back": without this line nobody would poll again
Poll::Pending
}
}
}
// The Waker of our mini-runtime: it wakes the thread that is in park().
struct Sveglia(Thread);
impl Wake for Sveglia {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = Box::pin(fut);
let waker = Waker::from(Arc::new(Sveglia(thread::current())));
let mut cx = Context::from_waker(&waker);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(valore) => return valore,
Poll::Pending => thread::park(), // sleeps until someone calls wake()
}
}
}
async fn saluta() -> &'static str {
println!("inside saluta()");
"hello"
}
fn main() {
let f = saluta(); // prints nothing: the Future is only created
println!("future created");
println!("{}", block_on(f)); // now it gets polled
println!("{}", block_on(ContoAllaRovescia { restanti: 3 }));
}
// Output inferred, not run:
// future created
// inside saluta()
// hello
// poll: remaining 3
// poll: remaining 2
// poll: remaining 1
// launched
poll returns Poll::Pending or Poll::Ready(value). When it answers
Pending the future has an obligation: to make sure someone calls wake when it
makes sense to try again. Here it does so at once with cx.waker().wake_by_ref(), the same shortcut used
by the Delay example in the Tokio tutorial; a real timer would call it from another thread at expiry.
Without that line block_on would go into thread::park() and never wake up, because
nobody would have told it to try again. The Waker is built from the Wake trait (stable since Rust 1.51),
whose only required method receives Arc<Self>.
The expected output, inferred from the semantics and not observed, has an instructive order. "future created" comes out
before "inside saluta()", because saluta() only built the future; the body runs
at the first poll by block_on. Then three "poll: remaining N" lines, one for each
Pending, and finally "launched". This is the difference from other languages: the work starts when you decide,
not when you write the call.
What the compiler generates from an async fn
An async fn is compiled into a state machine: an enum with one variant for each
.await point, which keeps the local variables alive across that point. The Tokio tutorial shows it
by hand-writing a MainFuture with states State0, State1 and
Terminated, and observes that "Rust futures are state machines". poll advances the state
as far as it can: if it can complete it returns Ready, otherwise Pending. Calling
poll after completion is an error, which is why the final variant panics.
This explains the term "zero-cost" you read everywhere: the future is a value on the stack (or wherever you put it),
its size is known at compile time and no allocation is needed to create it. The claim should be
read with caution: not every use is free (if you put a future in a Box or pass it to
tokio::spawn it allocates), and I did not measure sizes or times. The concrete gain is that you can
choose where to allocate.
Pin: why a future must not move
If the state machine holds a reference to one of its local variables across an .await, that
reference points inside the future itself. Moving the future in memory would leave the reference dangling.
That is why poll takes self: Pin<&mut Self>: the documentation of
std::pin describes pinning as the state in which safe code cannot move the value, unless
the type implements Unpin. The listing is the textbook example, rewritten with a fix relative to
widespread versions.
use std::marker::PhantomPinned;
use std::pin::Pin;
struct AutoRiferita {
testo: String,
vista: *const str, // points INSIDE `testo`: if the value moves, it dangles
_pin: PhantomPinned, // makes the type !Unpin: once pinned it never moves again
}
impl AutoRiferita {
fn new(testo: &str) -> Pin<Box<Self>> {
let mut boxed = Box::pin(AutoRiferita {
testo: testo.to_string(),
vista: "" as *const str,
_pin: PhantomPinned,
});
let vista: *const str = boxed.testo.as_str();
// SAFETY: we update a field without moving the value.
unsafe { boxed.as_mut().get_unchecked_mut().vista = vista; }
boxed
}
fn leggi(self: Pin<&Self>) -> &str {
// SAFETY: `vista` points to `testo`, which cannot move because it is pinned.
unsafe { &*self.vista }
}
}
fn main() {
let a = AutoRiferita::new("hello");
println!("{}", a.as_ref().leggi());
}
// Output inferred, not run:
// hello
PhantomPinned makes the struct !Unpin: without it, Pin would offer no guarantee
because the type could always be safely moved. The vista pointer is set after
Box::pin, when the address is now fixed. A detail that the examples in circulation get wrong:
&boxed.data as *const str does not compile, because you cannot convert a
&String into *const str with as; you have to go through as_str() and let the
coercion do the work, as here. In practice you never write such a structure: the compiler does it for your
async fn. You need to know Pin when you put a future in a Box
(Box::pin), when you keep it on the stack (the std::pin::pin! macro) or when you use
select! on references.
Tokio: spawn, select! and timeout
Tokio is the executor: the official tutorial explains that asynchronous functions must be passed to
tokio::spawn or be the main annotated with #[tokio::main], and that in both
cases the outermost future is handed to the executor, which calls Future::poll on it. The
listing tries the two tools you will use most: spawn, whose JoinHandle is itself a
future, and select!, which waits on several branches and completes only one.
use std::time::Duration;
use tokio::time::sleep;
async fn primario() -> &'static str {
sleep(Duration::from_millis(300)).await;
"primary"
}
async fn secondario() -> &'static str {
sleep(Duration::from_millis(100)).await;
"secondary"
}
#[tokio::main]
async fn main() {
// spawn: the future starts at once on a runtime thread; JoinHandle is itself a future
let handle = tokio::spawn(async { 6 * 7 });
println!("spawn: {}", handle.await.unwrap());
// select!: the first branch to finish wins, the others are dropped
tokio::select! {
r = primario() => println!("wins {r}"),
r = secondario() => println!("wins {r}"),
_ = sleep(Duration::from_secs(5)) => println!("timeout"),
}
}
// Output inferred, not run:
// spawn: 42
// wins secondary
The select! has three branches: primario (300 ms), secondario (100 ms) and a timeout of
five seconds. The secondary wins, and the other two are dropped (drop). This is
cancellation in Rust: the Tokio guide says you cancel by destroying the future, and that
since futures are lazy the operation simply does not proceed. There is no injected exception as in Python:
if the future has acquired resources, it is its Drop that releases them. The Cargo.toml requires
the right features, and one detail: the #[tokio::main] macro needs macros and
rt-multi-thread.
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
tokio-util = "0.7"
This block is a pattern to adapt to your project (the package name and edition are added by
cargo new). Features are opt-in: if you forget one, the compiler tells you the macro
or module does not exist; it is not a runtime error.
Bounded channels and semaphores: putting a limit on concurrency
tokio::spawn sets no limits: thousands of tasks all start together. Two tools govern them.
The mpsc::channel(n) channel is bounded: when it holds n messages, send().await
suspends the producer, and it is the simplest form of backpressure (the subject of the article on
backpressure and flow control). The
Semaphore instead limits how many tasks are in a section at the same time.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Semaphore};
use tokio::time::sleep;
#[tokio::main]
async fn main() {
// Channel bounded to 2: send().await suspends the producer if the queue is full
let (tx, mut rx) = mpsc::channel::<u32>(2);
let produttore = tokio::spawn(async move {
for i in 0..5 {
tx.send(i).await.unwrap();
}
}); // `tx` is dropped here: the channel closes and recv() will return None
while let Some(v) = rx.recv().await {
sleep(Duration::from_millis(10)).await; // slow consumer
println!("received {v}");
}
produttore.await.unwrap();
// Semaphore: at most 3 tasks inside the asynchronous critical section
let sem = Arc::new(Semaphore::new(3));
let attivi = Arc::new(AtomicUsize::new(0));
let picco = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..20 {
let (sem, attivi, picco) = (sem.clone(), attivi.clone(), picco.clone());
handles.push(tokio::spawn(async move {
let _permesso = sem.acquire().await.unwrap(); // lives until the task ends
let ora = attivi.fetch_add(1, Ordering::SeqCst) + 1;
picco.fetch_max(ora, Ordering::SeqCst);
sleep(Duration::from_millis(20)).await;
attivi.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
println!("peak <= 3: {}", picco.load(Ordering::SeqCst) <= 3);
}
// Output inferred, not run:
// received 0
// received 1
// received 2
// received 3
// received 4
// peak <= 3: true
In the first part the producer sends five values into a two-slot channel, while the consumer, slow
by 10 ms, takes them one at a time; recv() returns None when all the
Senders have been dropped, and here tx is dropped at the end of the
async move block. In the second, twenty tasks ask for a permit out of three; _permesso (named,
not just _, otherwise it would be released immediately) lives until the end of the task. The peak
counter checks it: inferred, it is never above three.
Cooperative cancellation: CancellationToken
To cancel a task from outside, you need a signal the task can await. In Tokio it is provided by
CancellationToken, which however is not in the tokio crate: it is in
tokio_util::sync, and importing it from tokio::sync is a frequent mistake that the compiler
rejects. The tokio-util documentation describes it as a
token for signalling a cancellation request to one or more tasks: each task awaits the future returned by
cancelled(), and cancel() resolves it.
use std::time::Duration;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
async fn lavoro(token: CancellationToken) -> Option<&'static str> {
tokio::select! {
_ = sleep(Duration::from_secs(10)) => Some("done"),
_ = token.cancelled() => {
println!("work: cancelled, cleanup");
None
}
}
}
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let figlio = token.child_token(); // cancelling the parent also cancels the child
let handle = tokio::spawn(lavoro(figlio));
sleep(Duration::from_millis(100)).await;
token.cancel();
println!("result: {:?}", handle.await.unwrap());
}
// Output inferred, not run:
// work: cancelled, cleanup
// result: None
The lavoro ("work") task puts its work and token.cancelled() in a select!. The child
token (child_token) is cancelled when the parent is cancelled, so a single
cancel() stops all the derived tasks. The inferred output: the cleanup message, then
result: None. Compared with Go, where context is a standard parameter, here the token is
a convention: nothing stops a task from ignoring it, and the compiler does not check it.
The cancellation safety trap
Since select! drops the losing branches, every .await in the branches can be
interrupted halfway. The documentation of select! calls this cancellation safety: it describes
what happens if a future is destroyed before completing, and lists the safe methods, including
mpsc::Receiver::recv. The typical case is a loop { select! { ... } } in which one of the branches
is an operation that partially consumes data: if it loses the race, the data read is lost. Before using a
future in select! inside a loop, check the "Cancellation safety" section of its documentation.
If you want to reuse a future across several iterations, pass it by mutable reference after pinning it
(&mut fut with Box::pin or pin!): this is the case where Pin
reappears in everyday code. The theme of making cancellation and cleanup coexist recurs in the series: in
Go it is solved with context, in
Python with CancelledError.
What you can do now
You can explain why an async fn does nothing until it is polled, what poll and Waker
ask of a future, why Pin is needed and where you meet it, and how to launch,
limit and cancel tasks with Tokio. You also know that the listings in this article were not compiled, and
that the useful next step is to do so.
The next article changes perspective: after the models, the failures. It deals with race conditions and deadlocks and how to recognise them. For the picture in which Rust's async model sits beside the others, see the comparison of concurrency models.
Sources
- std::future::Future (Rust documentation) - futures are inert and must be polled, the contract of poll and Waker, accessed on 2026-11-12
- std::pin (Rust documentation) - what pinning means, Unpin, a self-referential struct example, accessed on 2026-11-12
- std::task::Wake (Rust documentation) - the trait with wake(self: Arc<Self>) and wake_by_ref, stable since 1.51, accessed on 2026-11-12
- Async in depth (Tokio tutorial) - futures are state machines, the executor calls poll on the outermost future, the Delay that wakes itself, accessed on 2026-11-12
- Select (Tokio tutorial) - the branch that does not complete is destroyed, cancellation is obtained by destroying the future, accessed on 2026-11-12
- tokio::select! (docs.rs) - Cancellation safety section and safe methods, accessed on 2026-11-12
- tokio_util::sync::CancellationToken (docs.rs) - the token lives in tokio-util, cancelled(), cancel() and child_token, accessed on 2026-11-12
No performance figure is reported in this article: I could not measure anything in Rust on my machine.
Commenti
Caricamento commenti...
Sign in to comment