Skip to main content

06 - Rust Async and Tokio: Future, Pin, Waker and the Zero-Cost Concurrency Model

A Rust async fn does nothing until someone polls it: Future, Pin and Waker with a mini-runtime, then Tokio with select!, channels and cancellation.

What do you think about this article?

At a Glance

A Rust async fn does nothing until someone polls it: Future, Pin and Waker with a mini-runtime, then Tokio with select!, channels and cancellation.

Concurrency 13 min

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.

rust async tokio future pin waker select! async rust zero-cost

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

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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects