15. Async and Await

📋 Jump to Takeaways

🎁 What if you could run 10,000 network requests at once on just a handful of threads, and write the code as if it ran plainly top to bottom?

Threads give you parallelism, but each one costs an OS thread and real memory. For I/O-bound work (network calls, file reads, database queries) most of that thread just sits waiting. Async lets thousands of waiting tasks share a small pool of threads. You write sequential-looking code, and the runtime juggles who runs when.

What Is a Future

Mark a function async and it no longer returns its value directly. It returns a Future: a value that represents "this result, eventually."

// `async` makes this return a Future<Output = String>, not a String
async fn greeting() -> String {
    String::from("Hello from async!")
}

Here's the part that trips people up: calling greeting() runs none of its body. Futures are lazy. Nothing happens until something drives the future forward.

Awaiting a Future

The thing that drives a future is .await. It runs the future to completion and hands you the value, but only inside an async context.

// Add to Cargo.toml: tokio = { version = "1", features = ["full"] }

async fn greeting() -> String {
    String::from("Hello from async!")
}

#[tokio::main]
async fn main() {
    let msg = greeting().await; // now the future actually runs
    println!("{}", msg); // Hello from async!
}

While a future is waiting (say, on a slow network reply), .await yields control back to the runtime so other tasks can run. That yielding is the whole trick.

The Tokio Runtime

Futures need something to poll them. That something is a runtime, and Tokio is the most popular one. The #[tokio::main] attribute wraps your async fn main so it starts a runtime and blocks until your top-level future finishes.

// Add to Cargo.toml: tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("start");
    sleep(Duration::from_millis(100)).await; // non-blocking sleep
    println!("100ms later");
}

Notice this sleep is Tokio's, not std::thread::sleep. It parks the task without blocking the thread, so other tasks keep running during those 100ms.

Running Futures Concurrently

Awaiting two futures one after another runs them in sequence. To run them at the same time, hand them to tokio::join!, which polls all of them together.

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

async fn task(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;
    format!("{} done", name)
}

#[tokio::main]
async fn main() {
    // Both run concurrently: total wait is ~100ms, not 150ms
    let (a, b) = tokio::join!(task("A", 100), task("B", 50));
    println!("{} | {}", a, b); // A done | B done
}

If you had .awaited each task separately, you'd wait 100 then 50, for 150ms total. join! overlaps them, so the slowest one sets the pace.

Spawning Tasks

tokio::spawn hands a future to the runtime as an independent task, then returns a JoinHandle. The task starts running immediately while your code moves on.

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

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_millis(50)).await;
        42
    });

    println!("doing other work meanwhile...");

    let result = handle.await.unwrap(); // JoinHandle resolves to Result
    println!("task returned {}", result); // task returned 42
}

Awaiting the JoinHandle gives you a Result, because a spawned task can panic. That's why you see .unwrap() here.

Async vs Threads

Both give you concurrency, so which do you reach for?

Workload Reach for
Heavy computation (CPU-bound) Threads or Rayon
Network, files, databases (I/O-bound) Async / await
A handful of long-running jobs Threads
Thousands of tasks that mostly wait Async

The rule of thumb: threads for work that keeps a CPU busy, async for work that spends its life waiting on something else.

Key Takeaways

  • An async fn returns a Future, and futures are lazy, nothing runs until you .await
  • .await drives a future to completion and yields control while it waits
  • Futures need a runtime to execute them; #[tokio::main] sets Tokio up for you
  • tokio::join! runs multiple futures concurrently, pacing to the slowest one
  • tokio::spawn launches an independent task and returns a JoinHandle
  • Use async for I/O-bound concurrency, threads or Rayon for CPU-bound work

🎁 You've learned ownership, structs, error handling, iterators, and concurrency. Now it's time to put them all together and build a real CLI tool from scratch that reads files, counts words, and handles errors gracefully.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy