Async and Await in Rust and What the Compiler Generates
async and await are syntax over futures. Learn what async blocks capture, where await may appear, how errors and ? work, and the common first mistakes.

async and await are two keywords that let you write code that waits without blocking a thread. async turns a function or a block into a value that represents work to be done later; .await says "give the thread back until this is ready, then continue here". They look like ordinary sequential code, and the part worth understanding is what they do not do: they do not start anything concurrent on their own, they do not make a CPU-bound loop faster, and they only switch tasks at the .await points you wrote. This post demonstrates each of those claims with code you can run, using Tokio's paused clock so the timings are exact. Everything compiled on Rust 1.99, edition 2024, with tokio 1.
What the keywords mean
Calling an async fn returns a future immediately and runs none of the body. Applying .await to a future polls it until it completes, suspending the current task whenever the future reports it is not ready. The two statements below are equivalent in meaning:
async fn double(x: u32) -> u32 {
x * 2
}
fn double_explicit(x: u32) -> impl std::future::Future<Output = u32> {
async move { x * 2 }
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let a = double(21).await;
let b = double_explicit(21).await;
println!("{a} {b}");
}42 42An async fn is a function returning an anonymous impl Future, and an async block is the same thing for an expression. The state machine the compiler builds for them is shown in futures as state machines. Two features of this are worth fixing in your head now: the return type of double is u32 as you wrote it, but its type to callers is a future of u32, and .await is only legal inside an async context.
async fn fetch() -> u32 {
7
}
fn not_async() -> u32 {
fetch().await
}
fn main() {
println!("{}", not_async());
}error[E0728]: `await` is only allowed inside `async` functions and blocks
--> src/main.rs:6:13
|
5 | fn not_async() -> u32 {
| --------------------- this is not `async`
6 | fetch().await
| ^^^^^ only allowed inside `async` functions and blocksThe message tells you the one-line fix, and the real fix is design: a sync function cannot wait for an async one without a runtime driving it (see starting a runtime within a runtime for what happens when you try).
Await is sequential by default
The most common misunderstanding is that two awaits in a row run at the same time. They do not. Each one finishes before the next begins:
use tokio::time::{sleep, Duration, Instant};
async fn lookup(name: &str, millis: u64) -> String {
sleep(Duration::from_millis(millis)).await;
format!("{name} done")
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let t = Instant::now();
let a = lookup("users", 300).await;
let b = lookup("orders", 200).await;
let c = lookup("prices", 100).await;
println!("{a}, {b}, {c}");
println!("sequential: {:?}", t.elapsed());
let t = Instant::now();
let (a, b, c) = tokio::join!(lookup("users", 300), lookup("orders", 200), lookup("prices", 100));
println!("{a}, {b}, {c}");
println!("joined: {:?}", t.elapsed());
}users done, orders done, prices done
sequential: 600ms
users done, orders done, prices done
joined: 300msSequential awaits cost the sum of the waits (600 ms); join! costs the longest (300 ms). The clock is paused, so Tokio advances virtual time to the next timer, and the numbers are exact and reproducible. join! runs all three futures on the same task, interleaving them at their await points, with no threads. That is concurrency without parallelism, and it is enough whenever the work is waiting. To run futures on separate tasks, so that multiple threads can work on them, you tokio::spawn them, covered in spawn and join.
Await is the only place a task can switch
Async Rust is cooperative. The runtime cannot interrupt a task in the middle of a computation; it can only take control back when the task awaits something that is not ready. A long CPU loop between awaits therefore holds up everything else on that thread:
use std::time::Duration;
use tokio::time::{sleep, Instant};
fn busy(ms: u64) {
let t = std::time::Instant::now();
while t.elapsed() < Duration::from_millis(ms) {}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
let ticker = tokio::spawn(async move {
for i in 1..=3 {
sleep(Duration::from_millis(50)).await;
println!("tick {i} at {:>3} ms", start.elapsed().as_millis() / 10 * 10);
}
});
tokio::task::yield_now().await;
busy(300);
println!("busy loop finished");
ticker.await.unwrap();
}busy loop finished
tick 1 at 300 ms
tick 2 at 350 ms
tick 3 at 400 msThe ticker wanted to print at 50, 100 and 150 ms. It could not run until the busy loop gave the thread back, so "busy loop finished" came first and the first tick landed at 300 ms, six times late. Because each sleep starts when the previous tick wakes, the later ticks followed at 350 and 400 ms, and the whole schedule shifted. (A tokio::time::interval would behave differently, bursting to catch up; see missed interval ticks.) In a server the same effect means that one slow request starves unrelated ones sharing the thread. The cure is to move blocking or heavy work off the async threads, which is the subject of blocking the Tokio runtime, or to insert yield_now().await in a long loop that cannot leave.
The same program on a multi-thread runtime would show the ticker on time, because another worker thread would pick it up while the first was busy. That is a reason to be careful with test results: a bug that starves tasks can be invisible on a multi-thread runtime with idle cores and obvious on a single-thread one. Running tests with flavor = "current_thread" is a cheap way to find blocking calls in async code.
Async blocks capture variables, and move matters
An async block is a closure-like thing, so it captures by reference unless told otherwise, and a future that holds a reference cannot outlive the thing it borrows. The error most people meet first comes when spawning:
#[tokio::main(flavor = "current_thread")]
async fn main() {
let name = String::from("ferris");
let task = tokio::spawn(async {
println!("hello, {name}");
});
task.await.unwrap();
}error[E0373]: async block may outlive the current function, but it borrows `name`, which is owned by the current function
--> src/main.rs:5:29
|
5 | let task = tokio::spawn(async {
| ^^^^^ may outlive borrowed value `name`
6 | println!("hello, {name}");
| ---- `name` is borrowed here
|
= note: async blocks are not executed immediately and must either take a reference or ownership of outside variables they use
help: to force the async block to take ownership of `name` (and any other referenced variables), use the `move` keyword
|
5 | let task = tokio::spawn(async move {
| ++++The block borrows name, and spawned tasks must be 'static because they may outlive the function that spawned them. async move transfers ownership of the captured values into the block, as move does for a closure:
#[tokio::main(flavor = "current_thread")]
async fn main() {
let name = String::from("ferris");
let copy = name.clone();
let task = tokio::spawn(async move {
println!("hello, {name}");
name.len()
});
println!("{} {}", task.await.unwrap(), copy);
}hello, ferris
6 ferrisThe reasons spawned futures need to own their data are in why spawn needs a static future. Where the future is only awaited in place, a borrowing async block is fine and avoids the clone.
Errors flow with the question mark
Inside an async function returning Result, ? works exactly as in sync code, on the value after .await. The one ordering to remember is that .await binds tighter than ?: write fetch().await?, not fetch()?.await.
use std::num::ParseIntError;
async fn read_count(raw: &str) -> Result<u32, ParseIntError> {
tokio::task::yield_now().await;
raw.trim().parse()
}
async fn total(a: &str, b: &str) -> Result<u32, ParseIntError> {
let x = read_count(a).await?;
let y = read_count(b).await?;
Ok(x + y)
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("{:?}", total("20", "22").await);
println!("{:?}", total("20", "x").await);
}Ok(42)
Err(ParseIntError { kind: InvalidDigit })Costs and limits
An await on an already-ready future costs a function call and a branch; a suspension costs saving the state and a trip through the scheduler, on the order of a hundred nanoseconds in Tokio. A task is a heap allocation sized to its future, which is why thousands of tasks are cheap and millions are possible. The costs show up when you await in a tight loop for no reason, when a future is large because it holds a big buffer across an await, or when a trait object forces a boxed future per call.
Async is the wrong tool for CPU-bound work: it adds overhead and does not add parallelism, and a thread pool such as rayon does the job better (see spawn_blocking versus rayon). It is also overkill for a program with a handful of connections, where threads and blocking I/O are simpler to write and debug. Use it when many things wait at once: a server, a crawler, a client fanning out to services. The runtime that executes all this is the next piece, in the Tokio runtime explained.


