Skill maprust-async / async-await-basics

async fn, async blocks and .await

18 min read

By the end you can

  • Write an async fn and an async block and await them from #[tokio::main]
  • Predict what runs when an async fn is called but never awaited
  • Explain why std alone cannot run an async main and what #[tokio::main] expands to
Predict

This looks like TypeScript with different punctuation. In what order do the three lines print?

rust
async fn greet(name: &str) -> String {
    println!("greet({name}) is running");
    format!("hello, {name}")
}

#[tokio::main]
async fn main() {
    let fut = greet("ada");
    println!("future created");
    let msg = fut.await;
    println!("{msg}");
}
Answer

In order: future created, then greet(ada) is running, then hello, ada.

If you guessed greet(ada) is running first, that is the TypeScript habit talking. In TS, calling an async function runs its body straight away, up to the first await. In Rust, greet("ada") ran none of the body. It only built a value, fut. The body ran when fut.await asked it to.

Here is what each piece did.

  • async fn greet(...) -> String does not return a String. The compiler turns the body into an anonymous type that implements the Future trait, and the function hands that value back 6. Its type is impl Future<Output = String>. Think of it as a recipe for producing a String, not a promise that one is already on the way.
  • let fut = greet("ada"); builds the recipe. Nothing runs, because futures in Rust are lazy: they do nothing until you ask with .await 1.
  • fut.await runs the future until it finishes and gives you the Output, here the String. If the future has to wait (on a timer, a socket), .await is where your function pauses and the runtime can do other work.

Two syntax notes for a TypeScript hand. .await is postfix: you write greet("ada").await, not await greet("ada"), which chains nicely as fetch().await.text().await. And .await takes ownership of the future, so you cannot await the same future twice the way you can re-await a resolved Promise.

Predict

One call is awaited, one is not. What does this print, and what does the compiler say?

rust
async fn log_visit(user: u32) {
    println!("visit from {user}");
}

#[tokio::main]
async fn main() {
    log_visit(1);
    log_visit(2).await;
    println!("done");
}
Answer

It prints visit from 2, then done.

visit from 1 never appears. log_visit(1) built a future and then dropped it at the semicolon, so its body never ran 2. The compiler tells you so, pointing at that line:

  • warning: unused implementer of Future that must be used
  • note: futures do nothing unless you .await or poll them

It is a warning, not an error, so the program still builds. Treat this warning as a bug report every time you see it.

An async block is the same idea without a named function. async { ... } is an expression whose value is a future 6. The code inside it does not run where you write it; it runs when the block's future is awaited. Inside the block you can use .await, and the value of the last expression becomes the future's Output.

Async blocks are handy when you want a small piece of async work right where you are, for example to group a few awaits into one future you can pass around. You will meet async move { ... } soon too: it works the same but moves the variables it uses into the block, like a move closure.

Listing
#[tokio::main]
async fn main() {
    let name = String::from("ada");
    let count = async {
        println!("block is running");
        name.len()
    };
    println!("block created");
    let n = count.await;
    println!("{name} has {n} letters");
}
Prints block created, then block is running, then ada has 3 letters.

So who runs a future? Try deleting #[tokio::main] from any example above and compiling. The compiler rejects it with error E0752: main function is not allowed to be async.

The reason main cannot be async is that async code needs a runtime, a crate that manages the details of executing asynchronous code; main can start one but is not one itself 5. Someone has to keep polling the future main would return, and there is nothing above main to do it.

This is a real difference from Node. Most languages that support async bundle a runtime, but Rust does not 8. The standard library defines the Future trait so that libraries agree on the shape of a future, but it has no executor to run one. Rust lets you pick a runtime to suit your needs instead of shipping one 7. For a web service that choice is almost always Tokio, which you add to Cargo.toml like any other crate.

Step through

What #[tokio::main] does to your main

  1. Step 1 of 4 · You write

    An async fn main, which on its own would be rejected with E0752.

    rust
    #[tokio::main]
    async fn main() {
        println!("hello");
    }
  2. Step 2 of 4 · Macro runs

    #[tokio::main] is a macro. At compile time it turns your async fn main into a normal, synchronous fn main that sets up a runtime and runs your async body on it 4.

  3. Step 3 of 4 · Expanded code

    Roughly what the compiler sees. Your old body becomes an async block, and block_on drives that future to completion on the current thread, returning when it finishes. The real expansion uses tokio::runtime::Builder to configure the runtime, but the shape is the same.

    rust
    fn main() {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            println!("hello");
        })
    }
  4. Step 4 of 4 · Inside the body

    Every .await inside the body now has a runtime underneath it that can pause and resume your code. That is why all the earlier examples worked once #[tokio::main] was on main.

Worked example

A tiny request handler with two async fns and an async block

Toward your HTTP service: write handle(id) that loads a user from a pretend database (100 ms) and returns a response string. Handle requests 1 and 2 from main, time them, then build request 3 as an async block and await it later. Needs tokio with the full feature.

  1. Write the slow dependency. tokio::time::sleep returns a future; awaiting it pauses this function for 100 ms without blocking the thread. Note the .await: without it, the sleep would be built and dropped, and the function would return instantly.

    rust
    use std::time::{Duration, Instant};
    use tokio::time::sleep;
    
    async fn load_user(id: u32) -> String {
        sleep(Duration::from_millis(100)).await; // pretend database call
        format!("user-{id}")
    }
  2. Write the handler. Calling load_user(id) builds a future; .await runs it and hands back the String. An async fn can await other async fns, just like in TypeScript.

    rust
    async fn handle(id: u32) -> String {
        let user = load_user(id).await;
        format!("200 OK: {user}")
    }
  3. Call it from an async main under #[tokio::main]. Each .await finishes before the next line starts, so two requests take about 200 ms. Awaiting in sequence is not concurrency; running them at the same time comes in a later lesson.

    rust
    #[tokio::main]
    async fn main() {
        let start = Instant::now();
        let first = handle(1).await;
        let second = handle(2).await;
        println!("{first} | {second} | took {:?}", start.elapsed());
  4. Build request 3 as an async block. The println! after it prints first, proving the block has not started. Awaiting job runs handle(3) and returns the block's last expression, the body length.

    rust
        let request_id = 3;
        let job = async {
            let body = handle(request_id).await;
            body.len()
        };
        println!("job built, nothing ran yet");
        let len = job.await;
        println!("response length: {len}");
    }
  5. Run it. The output, with timing varying slightly:

    • 200 OK: user-1 | 200 OK: user-2 | took 204.246375ms (the exact digits change every run)
    • job built, nothing ran yet
    • response length: 14
Takeaway

Async fns and async blocks both produce futures. Nothing inside them runs until something awaits them, and #[tokio::main] supplies the runtime that makes the top-level awaits possible.

Check yourself

Inside an async handler you write send_welcome_email(&user); with no .await. What happens at runtime?

  • Futures are lazy: without .await (or a spawn) nobody runs the body 2. The unused implementer of Future warning is pointing at exactly this bug.

  • That is how an un-awaited JavaScript Promise or C# Task behaves. A Rust future is not running work; it is a value that runs only when awaited or handed to an executor 3.

  • That is the TypeScript rule for the synchronous part of an async function. In Rust, not even the first line of the body runs until the future is polled.

  • It compiles. The compiler only warns, which is why this bug can slip through if you ignore warnings.

A fresh cargo new project with no dependencies has async fn main() { println!("hi"); }. What happens?

  • Async code needs a runtime and main is not one 5. std defines Future but does not include an executor to run it.

  • There is no built-in executor. Unlike Node or C#, Rust does not bundle an async runtime 8; you add one such as Tokio.

  • Good instinct about laziness, but the compiler stops earlier: an async main is rejected outright.

What does #[tokio::main] turn async fn main() { body } into?

  • The macro rewrites main into a sync function that starts a runtime and runs your async body on it 4.

  • There is no runtime hidden inside std to switch on. The runtime comes from the tokio crate you added to Cargo.toml.

  • Awaits do not create threads. They are points where the future can pause so the runtime can run something else.

Given let b = async { println!("x"); 1 };, what is true right after this line?

  • An async block compiles to an anonymous type that implements Future 6. Its body waits for an await like any other future.

  • A plain { ... } block runs immediately and gives its last value. Adding async changes it into a future instead.

  • This mixes the two models. None of the block runs until it is awaited, including the println.

Try it

Write async fn check_stock(item: &str) -> u32 that prints checking {item}, sleeps 50 ms with tokio::time::sleep, and returns the item name's length as a u32 (a stand-in for a stock count).

In a #[tokio::main] main:

  • call check_stock("lamp") and store the result in _forgotten without awaiting it
  • build an async block that awaits check_stock("desk") and check_stock("chair") and returns their sum
  • print block built, then await the block and print total stock: {n}

Before running it, write down exactly which lines you expect to print, in order. Then run it and compare.

  1. Hint 1Start from load_user in the worked example: an async fn that awaits sleep(Duration::from_millis(50)) before returning.
  2. Hint 2The async block looks like let total = async { let a = ...await; let b = ...await; a + b };
  3. Hint 3Binding to _forgotten silences the unused-future warning, but it does not run the future. Will checking lamp ever print?
Solution

The output, in order:

  • block built
  • checking desk
  • checking chair
  • total stock: 9

checking lamp never prints: that future is built, stored and dropped at the end of main without being awaited. block built prints before any checking line because the async block does not start until total.await. Inside the block the two awaits run one after the other, desk then chair, and 4 + 5 = 9.

rust
use std::time::Duration;
use tokio::time::sleep;

async fn check_stock(item: &str) -> u32 {
    println!("checking {item}");
    sleep(Duration::from_millis(50)).await;
    item.len() as u32
}

#[tokio::main]
async fn main() {
    let _forgotten = check_stock("lamp");
    let total = async {
        let a = check_stock("desk").await;
        let b = check_stock("chair").await;
        a + b
    };
    println!("block built");
    let n = total.await;
    println!("total stock: {n}");
}

Made with byagent

Published with byagent