Skip to editor content
learningrust.orglesson 23 of 26

Async/Await

Asynchronous programming lets your program handle multiple tasks concurrently without blocking on slow operations like network requests, file I/O, or timers. Rust's async/await syntax makes this feel as natural as writing sequential code while giving you full control over performance and memory.

In this lesson you will learn how Rust's Future trait works under the hood, how to write and compose async functions, how to run tasks concurrently, and how to handle errors in async contexts.

Before you look for a Run button. Rust's standard library defines the Future trait but ships no executor — something has to drive a future to completion, and that something comes from a crate. Every Tokio example on this page is therefore shown as static code rather than a runnable snippet, so that the runtime stays something you add deliberately rather than something the page supplies for you. The code is correct and idiomatic — copy it into a Cargo project with tokio = { version = "1", features = ["full"] } in its Cargo.toml and it runs exactly as described — and each one quotes its real output so you can see what it produces. The end of the lesson then rebuilds the machinery without any crate at all, in a snippet you really can run, so that "a runtime drives the future" stops being a phrase you take on trust.

What Is a Future?

In Rust, an async operation is represented by a Future — a value that will produce a result at some point. A Future is lazy: it does nothing until something drives it to completion. That "something" is an async runtime.

When you write async fn, the compiler transforms your function body into a state machine that implements the Future trait. The .await keyword suspends the current task and yields control back to the runtime until the future is ready, rather than blocking the entire thread.

async fn compute_answer() -> u32 {
    // This async function returns a Future<Output = u32>
    // It can be awaited by the caller
    42
}

#[tokio::main]
async fn main() {
    // .await drives the future to completion
    let answer = compute_answer().await;
    println!("The answer is: {}", answer);
}

In a Cargo project with tokio as a dependency, that prints a single line:

The answer is: 42

The #[tokio::main] attribute is doing more than it looks. It is not part of the language: it is a macro that rewrites your async fn main into an ordinary fn main which starts a runtime and hands it the future to drive. Without it — or without some other executor — compute_answer() builds a Future and then nothing ever polls it, so 42 is never computed.

"Lazy" is easy to nod along to and hard to actually believe, because calling an async fn looks exactly like calling any other function. The program below calls a plain fn and an async fn whose bodies are identical — each one just prints a line and returns 10 — and it does not .await the async one until three lines later. It needs no crate, so you can run it: the tiny block_on at the top is the whole runtime. Write down the order of the printed lines before you answer, because the ordering is the entire point.

Predict

charge and charge_async have identical bodies — print a line, return 10. The plain one is called, then the async one is called, then 'both calls have returned' prints, and only after that is the future handed to block_on. In what order do the six lines appear?

use std::future::Future;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

// The whole runtime: a waker that does nothing and a loop that re-polls.
fn noop_waker() -> Waker {
  fn clone(_: *const ()) -> RawWaker {
      RawWaker::new(std::ptr::null(), &VTABLE)
  }
  fn noop(_: *const ()) {}
  static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
  unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}

fn block_on<F: Future>(future: F) -> F::Output {
  let mut future = Box::pin(future);
  let waker = noop_waker();
  let mut cx = Context::from_waker(&waker);
  loop {
      if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
          return value;
      }
  }
}

// An ordinary function with a side effect.
fn charge(label: &str) -> u32 {
  println!("  charging {}", label);
  10
}

// The SAME body, but behind async fn.
async fn charge_async(label: &str) -> u32 {
  println!("  charging {} (async)", label);
  10
}

fn main() {
  println!("calling the plain fn");
  let a = charge("plain");

  println!("calling the async fn");
  let pending = charge_async("lazy");

  println!("both calls have returned");

  let b = block_on(pending);
  println!("totals: {} and {}", a, b);
}

The async line prints last but one, after both calls have returned. Calling charge_async("lazy") runs none of its body: it constructs a value implementing Future with that body compiled into it as a state machine sitting in its initial state. The body starts only when something polls it, which here is the first iteration of block_on's loop. Identical source, identical result, completely different when — and the practical consequence is the bug that catches everyone once: a future you build and never await does nothing at all. Rust flags it with a #[must_use] warning on the unawaited future, but a warning is all you get.

Laziness is not a new idea on this track, either — you met it in a different costume back in Iterators, where a chain of adaptors builds a description of work and nothing happens until something consumes it. Close the page and answer this from memory before reading on:

Recall

Without scrolling up: in lesson 15-iterators you learned that v.iter().map(expensive) performs no work until a consumer such as .collect() or a for loop drives it, and that dropping the chain unused does nothing. A Future is lazy in the same way. Given that, which statement about let f = fetch_user(7); — an async fn called but not awaited — is true, and how does the analogy break down?

Both are lazy descriptions of work that do nothing until something drives them, and both cost nothing if you throw them away unused. Where they part is the driver: .collect() runs an iterator to completion on your thread, whereas .await advances the future as far as it can and then yields on Pending, handing the thread back to the runtime to spend elsewhere. That yield is the whole source of the concurrency — and it is also why a Rust Future is unlike a JavaScript async function, which starts running its body as soon as you call it.

Writing Async Functions

Any function can be made asynchronous by adding the async keyword before fn. The return type becomes a Future implicitly, and you use .await inside async functions to pause execution until another future resolves.

use tokio::time::{sleep, Duration};

async fn fetch_user(id: u32) -> String {
    // Simulate a network round-trip without blocking the thread
    sleep(Duration::from_millis(50)).await;
    format!("User #{id}")
}

async fn fetch_profile(user: &str) -> String {
    sleep(Duration::from_millis(30)).await;
    format!("Profile for {user}: senior rustacean")
}

#[tokio::main]
async fn main() {
    let user = fetch_user(7).await;
    println!("Fetched: {user}");

    let profile = fetch_profile(&user).await;
    println!("Fetched: {profile}");
}

In a Cargo project this prints, after roughly 80 ms of total waiting:

Fetched: User #7
Fetched: Profile for User #7: senior rustacean

The two calls above are sequential — each waits for the previous one before starting. This is correct when the second call depends on the first, but unnecessary when tasks are independent.

Running Tasks Concurrently with tokio::join!

When multiple futures are independent, you can run them concurrently using tokio::join!. This starts all futures at the same time and waits until all of them complete, which is faster than awaiting each one in sequence.

use tokio::time::{sleep, Duration};

async fn fetch_posts() -> Vec<String> {
    sleep(Duration::from_millis(100)).await;
    vec!["Post A".to_string(), "Post B".to_string()]
}

async fn fetch_comments() -> Vec<String> {
    sleep(Duration::from_millis(80)).await;
    vec!["Comment 1".to_string(), "Comment 2".to_string()]
}

async fn fetch_likes() -> u32 {
    sleep(Duration::from_millis(60)).await;
    42
}

#[tokio::main]
async fn main() {
    // All three run concurrently — total wait is ~100ms, not 240ms
    let (posts, comments, likes) = tokio::join!(
        fetch_posts(),
        fetch_comments(),
        fetch_likes(),
    );

    println!("Posts: {:?}", posts);
    println!("Comments: {:?}", comments);
    println!("Likes: {}", likes);
}

In a Cargo project this prints:

Posts: ["Post A", "Post B"]
Comments: ["Comment 1", "Comment 2"]
Likes: 42

The output looks exactly like the sequential version — which is the point worth pausing on. Concurrency here changes the wall-clock time, not the results or their order: join! returns a tuple in the order you listed the futures, however they happened to finish. The saving is real (about 100 ms rather than 240 ms) and completely invisible in what gets printed, which is why async bugs so often show up as latency rather than as wrong answers.

Error Handling in Async Code

Async functions work naturally with Result. You can use the ? operator inside async functions exactly as you would in synchronous code, and propagate errors up the call chain.

use std::fmt;

#[derive(Debug)]
enum AppError {
    NotFound(u32),
    Unauthorized,
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(id) => write!(f, "Resource {id} not found"),
            AppError::Unauthorized => write!(f, "Access denied"),
        }
    }
}

async fn load_resource(id: u32, token: &str) -> Result<String, AppError> {
    if token.is_empty() {
        return Err(AppError::Unauthorized);
    }
    if id == 0 {
        return Err(AppError::NotFound(id));
    }
    Ok(format!("Resource #{id}"))
}

async fn handle_request(id: u32, token: &str) -> Result<(), AppError> {
    // The ? operator works inside async functions
    let resource = load_resource(id, token).await?;
    println!("Loaded: {resource}");
    Ok(())
}

#[tokio::main]
async fn main() {
    let cases = vec![
        (1, "valid-token"),
        (0, "valid-token"),
        (5, ""),
    ];

    for (id, token) in cases {
        match handle_request(id, token).await {
            Ok(_) => {}
            Err(e) => println!("Error: {e}"),
        }
    }
}

In a Cargo project this prints three lines — the successful case logs from inside handle_request, the two failures are caught by the caller:

Loaded: Resource #1
Error: Resource 0 not found
Error: Access denied

Nothing about the ? here is async-specific. load_resource(id, token).await? awaits first, producing a Result<String, AppError>, and then ? does its ordinary job on that Result — unwrap the Ok or return the Err from handle_request. Async changes when the value arrives, never what ? does with it once it has.

Spawning Background Tasks

When you want truly independent background work rather than a set of futures you wait on together, reach for tokio::spawn instead of join!. It hands the task to the runtime's thread pool and lets you continue immediately: the spawned task runs whether or not you await it, and you get back a JoinHandle you can await later to collect the result or detect a panic.

use tokio::time::{sleep, Duration};

async fn background_sync(label: &'static str) -> String {
    sleep(Duration::from_millis(50)).await;
    format!("{label}: sync complete")
}

#[tokio::main]
async fn main() {
    // Spawn two tasks that run in parallel
    let handle_a = tokio::spawn(background_sync("cache"));
    let handle_b = tokio::spawn(background_sync("index"));

    // Do other work here while the tasks run...
    println!("Tasks are running in the background");

    // Collect results when needed
    let result_a = handle_a.await.expect("Task A panicked");
    let result_b = handle_b.await.expect("Task B panicked");

    println!("{result_a}");
    println!("{result_b}");
}

In a Cargo project this prints:

Tasks are running in the background
cache: sync complete
index: sync complete

The first line appears immediately, before either task has slept — which is the difference between spawn and join!. tokio::spawn hands the future to the runtime and returns a JoinHandle straight away, so main keeps going; join! would have parked main until every future finished. Note also that awaiting a JoinHandle yields a Result, because a spawned task can panic independently of its parent, and that panic has to be reported somewhere.

Which brings us to the bug that gets shipped most often, because it produces perfectly correct answers. Everything you have seen so far says that join! overlaps work and sequential .awaits do not — but nothing has shown you the cost, because Tokio's saving is measured in wall-clock milliseconds that never appear in the output. The program below makes the cost countable: it uses no crate, and its executor reports how many polls (call them ticks) the whole job needed. The two requests it fires are independent, so their latencies ought to overlap — and the tick count says they are not. Work out what the generated state machine is doing on those two .await lines, and commit that hypothesis, before you change a line.

Debug

fetch_both requests two INDEPENDENT endpoints, one with 4 ticks of latency and one with 3. Overlapped, that should cost 5 ticks in total; the total size is right but the program reports 8. The join2 helper it needs is already written for you and unused. Explain what .await is actually doing on each of those two lines before you touch anything, then make the requests overlap.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

// --- Provided: a do-nothing waker and a busy-polling executor. ---
fn noop_waker() -> Waker {
  fn clone(_: *const ()) -> RawWaker {
      RawWaker::new(std::ptr::null(), &VTABLE)
  }
  fn noop(_: *const ()) {}
  static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
  unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}

/// Drives a future to completion and reports (value, number of polls).
/// One poll stands for one tick of the runtime's clock.
fn block_on<F: Future>(future: F) -> (F::Output, u32) {
  let mut future = Box::pin(future);
  let waker = noop_waker();
  let mut cx = Context::from_waker(&waker);

  for poll_number in 1.. {
      if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
          return (value, poll_number);
      }
  }
  unreachable!()
}

/// A request that reports Pending for latency ticks, then yields its size.
struct Request {
  latency: u32,
  size: u32,
}

impl Future for Request {
  type Output = u32;

  fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u32> {
      if self.latency == 0 {
          Poll::Ready(self.size)
      } else {
          self.latency -= 1;
          Poll::Pending
      }
  }
}

// --- Provided: a hand-rolled join for two futures, the same idea as the
// join! macro. It polls BOTH on every pass and reports Ready only once
// both have landed. ---
#[allow(dead_code)]
struct Join2 {
  left: Request,
  right: Request,
  left_done: Option<u32>,
  right_done: Option<u32>,
}

impl Future for Join2 {
  type Output = (u32, u32);

  fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<(u32, u32)> {
      let this = &mut *self;
      if this.left_done.is_none() {
          if let Poll::Ready(size) = Pin::new(&mut this.left).poll(cx) {
              this.left_done = Some(size);
          }
      }
      if this.right_done.is_none() {
          if let Poll::Ready(size) = Pin::new(&mut this.right).poll(cx) {
              this.right_done = Some(size);
          }
      }
      match (this.left_done, this.right_done) {
          (Some(l), Some(r)) => Poll::Ready((l, r)),
          _ => Poll::Pending,
      }
  }
}

#[allow(dead_code)]
fn join2(left: Request, right: Request) -> Join2 {
  Join2 { left, right, left_done: None, right_done: None }
}

/// Fetch two INDEPENDENT endpoints and total their sizes. Neither request
/// needs anything from the other, so they should overlap.
async fn fetch_both() -> u32 {
  let a = Request { latency: 4, size: 100 }.await;
  let b = Request { latency: 3, size: 23 }.await;
  a + b
}

fn main() {
  let (total, ticks) = block_on(fetch_both());

  println!("total size = {}", total);
  println!("ticks      = {}", ticks);
}

Expected output: total size = 123 ticks = 5

The total is right and the shape of the work is wrong. Writing let a = ….await; let b = ….await; compiles into a state machine with three states — awaiting a, awaiting b, done — and in the first state a poll advances only the first request; the second has not been constructed yet, never mind polled. So the latencies add rather than overlap: 4 ticks, then 3, then one final poll that finds everything ready, giving 8. Replacing both awaits with a single join2(…).await polls both requests on every pass, so the cost becomes the slowest latency plus the finishing poll — 5 ticks, for the same answer. In real code that helper is tokio::join!. Notice what this bug does not do: no panic, no warning, no wrong number. It shows up only as latency, which is why this class of bug is usually found in production rather than in review.

Try It Yourself

Build a small async pipeline that fetches multiple data sources concurrently, filters the results, and reports a summary. Modify it to introduce an error path and handle it gracefully.

use tokio::time::{sleep, Duration};

#[derive(Debug)]
struct Record {
    id: u32,
    value: i64,
}

async fn fetch_batch(start: u32, count: u32) -> Vec<Record> {
    sleep(Duration::from_millis(40)).await;
    (start..start + count)
        .map(|id| Record { id, value: (id as i64) * 10 - 50 })
        .collect()
}

async fn process(records: Vec<Record>) -> (usize, i64) {
    let positive: Vec<_> = records.into_iter().filter(|r| r.value > 0).collect();
    let total: i64 = positive.iter().map(|r| r.value).sum();
    (positive.len(), total)
}

#[tokio::main]
async fn main() {
    // Fetch three batches concurrently
    let (batch_a, batch_b, batch_c) = tokio::join!(
        fetch_batch(0, 5),
        fetch_batch(5, 5),
        fetch_batch(10, 5),
    );

    let all_records: Vec<Record> = batch_a
        .into_iter()
        .chain(batch_b)
        .chain(batch_c)
        .collect();

    println!("Fetched {} records total", all_records.len());

    let (count, total) = process(all_records).await;
    println!("Positive records: {count}, sum: {total}");
}

In a Cargo project this prints:

Fetched 15 records total
Positive records: 9, sum: 450

The fifteen records carry value = id * 10 - 50, so the six with id from 0 to 4 are zero or negative and drop out, leaving nine positives summing to 450. Try extending it by wrapping fetch_batch to return Result<Vec<Record>, String>, simulating a failure for one of the batches, and collecting only the successful ones.

Building the Runtime Yourself

Everything above needed a crate for one reason: the standard library gives you the Future trait and the async/.await syntax, but no executor to drive a future. That gap is smaller than it sounds, and closing it by hand is the fastest way to stop treating "a runtime polls the future" as a phrase and start seeing it as a loop.

A Future has exactly one method:

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

poll returns Poll::Ready(value) when the work is done, or Poll::Pending when it is not. That is the entire contract. A real runtime, when it sees Pending, parks the task and waits for a Waker to signal that progress is possible — which is what stops it from burning a CPU core. A minimal runtime can simply poll again immediately. That is wasteful and completely correct, and it is enough to run real async fn code with no dependencies at all. The snippet below runs.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

/// The smallest waker that compiles: every callback does nothing. That is
/// enough for an executor which re-polls in a loop instead of sleeping.
fn noop_waker() -> Waker {
    fn clone(_: *const ()) -> RawWaker {
        RawWaker::new(std::ptr::null(), &VTABLE)
    }
    fn noop(_: *const ()) {}
    static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
    unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}

/// Drives a future to completion, printing what each poll returned.
fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = Box::pin(future);
    let waker = noop_waker();
    let mut cx = Context::from_waker(&waker);

    for poll_number in 1.. {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => {
                println!("  ready on poll {}", poll_number);
                return value;
            }
            Poll::Pending => println!("  poll {} -> Pending", poll_number),
        }
    }
    unreachable!()
}

/// A future that reports Pending a fixed number of times before finishing.
/// This is what a real timer or socket does, minus the operating system.
struct Countdown {
    remaining: u32,
    label: &'static str,
}

impl Future for Countdown {
    type Output = String;

    fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<String> {
        if self.remaining == 0 {
            Poll::Ready(format!("{} finished", self.label))
        } else {
            self.remaining -= 1;
            Poll::Pending
        }
    }
}

async fn run_job(label: &'static str, ticks: u32) -> String {
    let outcome = Countdown { remaining: ticks, label }.await;
    format!("[{}] {}", label, outcome)
}

fn main() {
    println!("building the future");
    let job = run_job("sync", 2);
    println!("built — nothing has run yet");

    let result = block_on(job);
    println!("{}", result);
}

That prints:

building the future
built — nothing has run yet
  poll 1 -> Pending
  poll 2 -> Pending
  ready on poll 3
[sync] sync finished

Read those six lines carefully, because every claim this lesson has made is visible in them. run_job("sync", 2) performs no work — "built — nothing has run yet" prints after the future exists, and the body has not begun. block_on then polls it three times: twice the Countdown reports Pending, and the third time it reports Ready and the async fn resumes past its .await to build the final string. The .await is not a call; it is a suspension point the compiler turned into a state in a machine, and each poll advances that machine by at most one state. Swap Countdown for something that reads a socket and swap the busy loop for a real waker, and you have Tokio's shape.

Now finish one yourself. This is a build task: a small program that reports its own pass/fail. Two pieces are stubbed out — a hand-written Future and an async fn that composes two of them — and a battery of assert_eq! calls in fn main drives them through the same block_on above. Run it as-is and it panics immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.

Nothing here needs a crate, so you can genuinely run every attempt. The checks count polls as well as values, because the poll count is the only place laziness is actually observable.

Build

Finish the build. A Future implementation and an async fn are stubbed out, and the checks below them fail until each behaves correctly. Run it as-is to see which check fails first, decide what that piece is missing, then implement both until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

// --- Provided: a do-nothing waker and a busy-polling executor. ---
fn noop_waker() -> Waker {
  fn clone(_: *const ()) -> RawWaker {
      RawWaker::new(std::ptr::null(), &VTABLE)
  }
  fn noop(_: *const ()) {}
  static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
  unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}

/// Drives a future to completion and reports (value, number of polls).
fn block_on<F: Future>(future: F) -> (F::Output, u32) {
  let mut future = Box::pin(future);
  let waker = noop_waker();
  let mut cx = Context::from_waker(&waker);

  for poll_number in 1.. {
      if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
          return (value, poll_number);
      }
  }
  unreachable!()
}

/// A future that is Pending for remaining polls, then Ready with value.
struct Delayed {
  remaining: u32,
  value: u32,
}

// TODO 1: implement poll so that Delayed reports Pending exactly remaining
//   times and then reports Ready(self.value). Decrement remaining on each
//   Pending poll. Output is u32.
impl Future for Delayed {
  type Output = u32;

  fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u32> {
      // Placeholder: touches both fields so the starter compiles cleanly.
      self.remaining = 0;
      let _ = self.value;
      Poll::Ready(0)
  }
}

// TODO 2: await BOTH delays in sequence and return the sum of their values.
//   Awaiting one after the other means the poll counts add up.
async fn sum_of(a: Delayed, b: Delayed) -> u32 {
  let _ = (a, b);
  0
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  // A Delayed with remaining = 0 is ready on its very first poll.
  let (value, polls) = block_on(Delayed { remaining: 0, value: 7 });
  assert_eq!(value, 7, "Delayed should resolve to its own value");
  assert_eq!(polls, 1, "remaining = 0 means Ready on the first poll");

  // remaining = 3 means three Pending polls, then Ready on the fourth.
  let (value, polls) = block_on(Delayed { remaining: 3, value: 5 });
  assert_eq!(value, 5, "Delayed should still resolve to its own value");
  assert_eq!(polls, 4, "three Pending polls, then Ready on the fourth");

  // Awaiting two in sequence: 2 + 1 Pending polls, then one final Ready poll.
  let (total, polls) = block_on(sum_of(
      Delayed { remaining: 2, value: 10 },
      Delayed { remaining: 1, value: 4 },
  ));
  assert_eq!(total, 14, "sum_of should await both and add their values");
  assert_eq!(polls, 4, "2 Pending + 1 Pending + 1 final Ready poll = 4");

  println!("All checks passed.");
  println!("Delayed(0, 7)  -> {:?}", block_on(Delayed { remaining: 0, value: 7 }));
  println!("Delayed(3, 5)  -> {:?}", block_on(Delayed { remaining: 3, value: 5 }));
}

Expected output: All checks passed. Delayed(0, 7) -> (7, 1) Delayed(3, 5) -> (5, 4)

Once it passes, try two variations and predict each before running:

  1. Make Delayed never finish. Change TODO 1 to return Poll::Pending unconditionally, without decrementing. Predict what the program does before running. It hangs: block_on polls forever and no check is ever reached. That is the honest cost of a busy-poll executor, and it is precisely why a real runtime waits for a Waker instead of re-polling — a Pending future that is never woken should consume no CPU at all, not one core.
  2. Await the same future twice. After let x = a.await; add let y = a.await;. Decide what the compiler says before running. It refuses to build: .await consumes the future by value, so a has been moved and the second use is a use-after-move (E0382) — the ownership rule from Ownership & Borrowing applying to futures like anything else. A future is a one-shot value, not a re-runnable recipe.

Key Takeaways

  • async fn transforms a function into one that returns a Future, which is lazy until driven by a runtime
  • .await suspends the current task and yields to the runtime without blocking the OS thread
  • Use tokio::join! to run independent futures concurrently and collect all results
  • Use tokio::spawn to fire-and-forget background tasks that return a JoinHandle
  • The ? operator works inside async functions, making error propagation idiomatic
  • Async in Rust adds no runtime you did not ask for — futures do nothing until an executor polls them, and you add that executor (such as tokio) yourself as a dependency
  • An async runtime (like Tokio) is required; the standard library provides the Future trait but no executor — which is exactly why every Tokio example here is shown as static code while the hand-rolled executor at the end is runnable
  • Future has one method, poll, returning Poll::Ready(value) or Poll::Pending; a runtime is a loop that calls it until it is Ready, and a Waker is how a real runtime avoids re-polling a future that cannot make progress
  • Awaiting futures in sequence makes their work add up — the poll counts in the build task make that literal. Concurrency needs join! or spawn; .await on its own never provides it

Pro Tip: Avoid holding a std::sync::MutexGuard across an .await. The compiler only rejects it when the future must be Send — the tokio::spawn case; elsewhere it builds fine and deadlocks only under contention, when a task suspends still holding the guard and another task on that same thread wants the lock. Keep std::sync::Mutex for plain data; use tokio::sync::Mutex when the lock must span an await.

Next Steps

Now that you understand async programming, we'll learn about serde — Rust's powerful serialization framework for working with JSON, TOML, and other data formats.

Next lesson

Serde and JSON

Learn how to serialize and deserialize data structures to and from JSON using the serde and serde_json crates

25 min