# Async Rust

> Write concurrent I/O code with async/await and Tokio: lazy futures, spawning tasks, select!, timeouts, and the pitfalls of blocking.

Rust roadmap · Systems & production · 19 min read · https://levelcode.org/roadmaps/rust/async-rust

Threads are great for CPU-heavy work, but a server handling 50,000 open connections can't afford 50,000 OS threads, each with its own stack and scheduling cost. **Async** Rust lets one thread juggle thousands of tasks that spend most of their time waiting on the network. The model is different from JavaScript's or Go's in ways that matter: futures are lazy, the runtime isn't built in, and blocking calls are a real hazard. This lesson covers what a future is, how `async`/`await` works, the Tokio runtime, spawning tasks and sharing state between them, `select!` and timeouts, and the mistakes everyone makes once.

## Futures are lazy

An `async fn` doesn't run when you call it. It returns a **future**: a value representing work that hasn't happened yet. Nothing executes until something **awaits** or **polls** it.

```rust
async fn fetch_greeting() -> String {
    println!("fetching...");
    String::from("hello")
}

#[tokio::main]
async fn main() {
    let fut = fetch_greeting(); // nothing printed yet
    println!("created the future");
    let greeting = fut.await;    // now it runs: prints "fetching..."
    println!("{greeting}");
}
```

```text
created the future
fetching...
hello
```

This is the opposite of JavaScript, where calling an `async` function starts it immediately and returns a running promise. Forgetting `.await` in Rust means the work never happens; the compiler warns that the future is unused.

Under the hood, the compiler turns each `async fn` into a state machine: an enum with one variant per `.await` point, holding the local variables live at that point. It implements the `Future` trait:

```rust
use std::pin::Pin;
use std::task::{Context, Poll};

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

`poll` either finishes with `Poll::Ready(value)` or returns `Poll::Pending` after arranging to be woken up later through the `Waker` in the `Context`. No heap allocation or thread is needed per future. That's why async Rust is cheap: a task is just a state machine waiting in a queue.

## Runtimes and Tokio

The standard library defines the `Future` trait but ships no **executor** to poll futures and no **reactor** to watch sockets. You pick a runtime, and in practice that's almost always **Tokio**:

```bash
cargo add tokio --features full
```

`#[tokio::main]` turns `async fn main` into a normal `main` that starts a multi-threaded runtime (one worker thread per CPU core by default) and blocks on your future. Here is what happens when a task waits on a socket:

```sequence
title: How a task waits for I/O
participants: Executor, Task, Reactor
Executor -> Task: poll()
Task -> Reactor: register socket + waker
> The socket has no data yet, so the task asks to be woken when it does.
Task --> Executor: Poll::Pending
> The worker thread is free to poll other tasks.
Reactor -> Reactor: epoll/kqueue: data arrived
Reactor -> Executor: waker.wake()
> The task goes back into the run queue.
Executor -> Task: poll()
Task --> Executor: Poll::Ready(bytes)
```

Tokio provides async versions of everything that waits: `tokio::net` (TCP, UDP), `tokio::fs`, `tokio::time` (sleep, interval, timeout), `tokio::sync` (channels, mutexes, semaphores) and `tokio::process`. Use these inside async code, not their `std` counterparts.

## Concurrency without threads

`.await` waits for one future. To run several at once, combine them. `tokio::join!` polls futures concurrently on the current task and waits for all of them:

```rust
use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn fetch(name: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await; // simulate a network call
    format!("{name} done")
}

#[tokio::main]
async fn main() {
    let start = Instant::now();

    // Sequential: about 300 ms
    let a = fetch("users", 100).await;
    let b = fetch("orders", 200).await;
    println!("{a}, {b} in {:?}", start.elapsed());

    // Concurrent: about 200 ms, the longest of the two
    let start = Instant::now();
    let (a, b) = tokio::join!(fetch("users", 100), fetch("orders", 200));
    println!("{a}, {b} in {:?}", start.elapsed());
}
```

For fallible futures, `tokio::try_join!` returns the first error and drops the rest.

## Spawning tasks

`tokio::spawn` hands a future to the runtime as an independent **task**, which may run on any worker thread in parallel with others. It returns a `JoinHandle` you can await for the result:

```rust
use std::time::Duration;

#[tokio::main]
async fn main() {
    let mut handles = Vec::new();
    for id in 0..5u64 {
        handles.push(tokio::spawn(async move {
            tokio::time::sleep(Duration::from_millis(50 * (5 - id))).await;
            id * id
        }));
    }
    for h in handles {
        let v = h.await.expect("task panicked"); // JoinError if the task panicked
        print!("{v} ");
    }
    println!();
}
```

Because a spawned task can run on another thread and outlive the code that spawned it, its future must be **`Send + 'static`**, exactly like `thread::spawn`'s closure. In practice: use `async move`, share data with `Arc`, and don't hold a non-`Send` value (an `Rc`, a `RefCell` borrow, a `std::sync::MutexGuard`) across an `.await`.

For a dynamic set of tasks, `tokio::task::JoinSet` collects them and yields results as they finish.

## Sharing state between tasks

The same tools as threads apply, with one async twist. For short, synchronous critical sections, `std::sync::Mutex` is fine and faster, as long as you never hold its guard across an `.await`:

```rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

#[derive(Clone, Default)]
struct Cache {
    inner: Arc<Mutex<HashMap<String, String>>>,
}

impl Cache {
    fn get(&self, k: &str) -> Option<String> {
        self.inner.lock().unwrap().get(k).cloned() // lock, copy out, unlock
    }
    fn put(&self, k: String, v: String) {
        self.inner.lock().unwrap().insert(k, v);
    }
}

#[tokio::main]
async fn main() {
    let cache = Cache::default();
    let c = cache.clone();
    tokio::spawn(async move { c.put("lang".into(), "rust".into()) }).await.unwrap();
    println!("{:?}", cache.get("lang"));
}
```

When you must hold a lock across an `.await` (say, to serialize access to a connection), use `tokio::sync::Mutex`, whose `lock()` is itself async. Often a better design is a single task that owns the resource and receives requests over a `tokio::sync::mpsc` channel: the actor pattern.

Tokio's channels: `mpsc` (many producers, one consumer, bounded for backpressure), `oneshot` (a single reply), `broadcast` (every receiver gets every message) and `watch` (receivers see the latest value, ideal for config reloads and shutdown signals).

## select! and timeouts

`tokio::select!` waits on several futures and runs the branch of whichever completes **first**, dropping the others:

```rust
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{sleep, timeout};

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(16);
    tokio::spawn(async move {
        sleep(Duration::from_millis(30)).await;
        tx.send("job-1".into()).await.ok();
    });

    loop {
        tokio::select! {
            Some(job) = rx.recv() => println!("got {job}"),
            _ = sleep(Duration::from_millis(100)) => {
                println!("idle for 100ms, stopping");
                break;
            }
        }
    }

    // timeout wraps any future and returns Err(Elapsed) if it takes too long.
    match timeout(Duration::from_millis(50), sleep(Duration::from_secs(5))).await {
        Ok(()) => println!("finished"),
        Err(_) => println!("timed out"),
    }
}
```

Dropping a future **cancels** it: it simply stops being polled, and its locals are dropped. That's how timeouts and `select!` cancel work, and why cancellation in Rust needs no tokens for the common case (compare Go's [context cancellation](https://levelcode.org/concepts/context-cancellation)). The flip side is **cancellation safety**: if a future is dropped halfway through, any work done before its last `.await` stays done. Reading half a message from a socket and then being cancelled loses that half. Tokio's docs mark which methods are cancellation-safe; `mpsc::Receiver::recv` is, for example, so it's safe in a `select!` loop.

## Don't block the runtime

A Tokio worker thread runs one task at a time between `.await` points. If a task does something that blocks the thread — `std::thread::sleep`, synchronous file or network I/O, a long CPU loop — every other task scheduled on that worker stalls. A few of those and the whole server freezes, while CPU usage looks low.

```rust
async fn handler_bad() {
    std::thread::sleep(std::time::Duration::from_secs(1)); // blocks a worker thread
}

async fn handler_good() {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await; // yields to other tasks
}

async fn hash_password(pw: String) -> String {
    // CPU-heavy or blocking work goes to a dedicated thread pool.
    tokio::task::spawn_blocking(move || {
        // e.g. argon2 hashing, a blocking SDK call, compressing a big file
        format!("hashed:{}", pw.len())
    })
    .await
    .expect("blocking task panicked")
}
```

A rough rule: if something can take more than 10–100 microseconds without hitting an `.await`, move it to `spawn_blocking` (for blocking I/O) or a `rayon` pool (for heavy CPU work).

> [!WARNING]
> `async fn` in traits works natively since Rust 1.75, but the returned future isn't automatically `Send`, which matters when you call it from `tokio::spawn`. If you hit "future cannot be sent between threads safely" with trait methods, declare the method as `fn call(&self) -> impl Future<Output = T> + Send` or use the `async-trait` crate. Async closures (`async || {}`) are stable since Rust 1.85.

## Practice

Write a concurrent URL "checker" with simulated latency (no real network needed).

1. Write `async fn check(url: String) -> Result<u64, String>` that fails quickly for URLs containing `"bad"`, and otherwise sleeps for a pseudo-random time based on the URL's length (e.g. `(url.len() as u64 * 37) % 400` ms) and succeeds.
2. Check 20 URLs concurrently with `tokio::spawn`, but allow at most 5 in flight at once, using `tokio::sync::Semaphore` (shared via `Arc`, acquired inside each task).
3. Apply a 300 ms timeout per check with `tokio::time::timeout`; report timeouts separately from errors.
4. Print a summary: OK count, error count, timeout count, and total elapsed time.

**Solution**

```rust
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::{sleep, timeout};

async fn check(url: String) -> Result<u64, String> {
    if url.contains("bad") {
        sleep(Duration::from_millis(20)).await; // servers that fail tend to fail fast
        return Err(format!("{url}: 500"));
    }
    let ms = (url.len() as u64 * 37) % 400;
    sleep(Duration::from_millis(ms)).await;
    Ok(ms)
}

enum Outcome {
    Ok,
    Failed,
    TimedOut,
}

#[tokio::main]
async fn main() {
    let urls: Vec<String> = (0..20)
        .map(|i| if i % 7 == 0 { format!("https://bad{i}.example") } else { format!("https://site{i}.example/{}", "x".repeat(i)) })
        .collect();

    let limit = Arc::new(Semaphore::new(5));
    let start = Instant::now();
    let mut set = JoinSet::new();

    for url in urls {
        let limit = Arc::clone(&limit);
        set.spawn(async move {
            let _permit = limit.acquire_owned().await.expect("semaphore closed");
            match timeout(Duration::from_millis(300), check(url)).await {
                Ok(Ok(_)) => Outcome::Ok,
                Ok(Err(_)) => Outcome::Failed,
                Err(_) => Outcome::TimedOut,
            }
        });
    }

    let (mut ok, mut failed, mut timed_out) = (0, 0, 0);
    while let Some(res) = set.join_next().await {
        match res.expect("task panicked") {
            Outcome::Ok => ok += 1,
            Outcome::Failed => failed += 1,
            Outcome::TimedOut => timed_out += 1,
        }
    }
    println!("ok={ok} failed={failed} timed_out={timed_out} in {:?}", start.elapsed());
}
```

All 20 tasks are spawned immediately, but each waits for a permit, so at most 5 run `check` at once. The permit is dropped when the task finishes, letting the next one in. `timeout` returns a nested `Result`: the outer one says whether it finished in time, the inner one is `check`'s own result. Dropping a timed-out `check` future cancels it. `JoinSet::join_next` yields results in completion order.

## Key takeaways

- Futures are lazy state machines; nothing runs until it's awaited or spawned.
- The standard library defines `Future`; a runtime such as Tokio supplies the executor, the I/O reactor, timers and async channels.
- `join!` runs futures concurrently within a task; `tokio::spawn` creates independent `Send + 'static` tasks that can run in parallel.
- `select!` and `timeout` cancel by dropping futures; be aware of what's lost when a future is cancelled midway.
- Never block a runtime thread: use async APIs, `spawn_blocking` for blocking calls, and don't hold a `std` mutex guard across `.await`.

## Quiz

1. What prints first? let f = work(); println!("A"); f.await; — where async fn work() { println!("B") }

   A. B, then A
   B. A, then B
   C. Only A; B never prints
   D. The order is random

   **Answer: B.** Calling an async fn only creates a future. It runs when awaited, after A has printed.

2. A handler calls std::thread::sleep(Duration::from_secs(2)) inside an async fn on Tokio. What's the effect?

   A. Other tasks on that worker thread are stalled for 2 seconds
   B. Tokio converts it to an async sleep automatically
   C. It's a compile error
   D. Only that request is delayed; nothing else is affected

   **Answer: A.** A blocking call holds the worker thread, so no other task scheduled there can be polled. Use tokio::time::sleep, or spawn_blocking for unavoidable blocking work.

3. tokio::spawn(async { use_rc(rc.clone()).await }) fails with 'future cannot be sent between threads safely'. Why?

   A. Spawned futures must return ()
   B. Tokio only runs on one thread
   C. An Rc is held across an .await, so the future isn't Send
   D. Async blocks can't capture variables

   **Answer: C.** Spawned tasks may move between worker threads, so their futures must be Send. Anything alive across an .await is part of the future's state; an Rc makes it !Send. Use Arc.

4. How does tokio::time::timeout cancel the future it wraps when time runs out?

   A. It sends the future a cancellation token
   B. It kills the worker thread
   C. It panics inside the future
   D. It drops the future, so it's never polled again

   **Answer: D.** Cancellation in async Rust is dropping: the future stops being polled and its state is dropped. Work done before its last completed .await stays done, which is why cancellation safety matters.
