πŸŽ“ A1Learner
πŸ”

Rust Futures

A future is a paused job that an executor can resume when its wait is over. Calling an async function builds a future and runs no code yet.

One thread for every wait

Each wait below gets a thread of its own. The thread spends its life sleeping. Thousands of waits would need thousands of threads:

use std::thread;
use std::time::Duration;

fn wait(name: &str) -> &str {
    thread::sleep(Duration::from_millis(20));
    name
}

fn main() {
    let a = thread::spawn(|| wait("a"));
    let b = thread::spawn(|| wait("b"));
    let c = thread::spawn(|| wait("c"));
    println!("{} done", a.join().unwrap());
    println!("{} done", b.join().unwrap());
    println!("{} done", c.join().unwrap());
    println!("all waits done");
}

Calling an async fn only builds the future

Futures are lazy: creating one starts nothing. An executor β€” a runtime, taught in the lessons ahead β€” is the worker that runs futures to the end:

async fn job() -> i32 { 42 }
fn main() {
    let _future = job();
    println!("future created; no work has happened");
}

.await pauses this job, not the thread

While one job waits at .await, the same thread can run other jobs. That taking of turns is concurrency. Parallelism is different: several jobs run at the very same instant, on different cores. A multi-thread runtime can add that underneath:

let name = fetch_name().await; // pause here until the future finishes,
// then continue with its value β€” the thread stayed free meanwhile

Futures are ordinary values

You can store them, pass them, and combine them, like any other value:

let jobs = vec![fetch(1), fetch(2), fetch(3)];
// three futures in a list β€” none of them running yet

A task's handle is itself a future

A spawned task's JoinHandle is a future too:

let answer = handle.await;
// awaiting the handle waits for the task to finish
// and gives back its return value

What lives across an .await is stored inside the future

That is why futures have a size at all. It is why a fat local makes a fat future. And it is why holding a non-Send value (like an Rc) across an .await stops the task. The task then cannot run on a multi-threaded executor.

async fn report() {
    let total = count_items().await; // total is saved inside the future
    println!("{total}"); // while this line waits for its turn
}

Example

// Needs a runtime (next lesson) in a real project:
// #[tokio::main] async fn main() { run().await; }
async fn fetch_name() -> String { "Ferris".to_string() }
async fn run() -> String { let name = fetch_name().await; format!("hello, {}", name) }
fn main() { println!("async fn returns a lazy Future; run() is driven by an executor"); println!("conceptual output: hello, Ferris"); }

Key points

  • Async suits I/O waits.
  • Heavy computing belongs on threads (spawn_blocking, or the rayon crate).
  • CPU-bound work is limited by the processor, like video encoding or one very large sum.
  • I/O-bound work is limited by waiting for data, like network, file and timer waits.
  • Async shines on I/O-bound work.
  • IntoFuture: some types are not futures but turn into one when awaited β€” .await accepts them directly.
  • Library builders use it so the builder itself can be awaited.
  • Inside std, &mut F works the same way.
  • Pin<P> works the same way, where the inside is a future.
  • A future holds a job mid-wait, ready to run again when the wait ends.
  • Calling an async fn and dropping the result does nothing at all.
  • The compiler even warns you about the unused future.
  • One thread can look after thousands of futures while they wait for replies, files, or timers.

In this example, fetch_name is an async fn that would produce a name. run awaits it and builds a greeting. But look at main: it only prints two lines explaining the idea. Nothing here actually runs run(), because there is no executor yet β€” and that is the point of the example. The output shows what an executor would produce: hello, Ferris. Tokio, the runtime that presses "go" on futures like these, arrives in a few lessons.

Note

This surprises newcomers from languages where calling starts the work.

Try it Yourself

Edit the code, then press Run.

Running is simulated Β· Rust Playground

Exercise

What happens when you call an async fn but never await it or spawn it?