background-shape
Async Rust with Tokio in 2022
March 14, 2022 · 6 min read · by Muhammad Amal programming

TL;DRasync fn compiles to a state machine implementing the Future trait. Tokio is the runtime that polls those futures. Use tokio::spawn for concurrent tasks, tokio::select! to race futures, and tokio::sync primitives for shared state. Don’t block in async functions.

After error handling and workspaces, the next conceptual step for backend Rust is async. It’s where the language gets meaningfully different from Go — Go’s “everything is a goroutine” model versus Rust’s “futures + a runtime you pick.”

Tokio is the default runtime in 2022. Nearly every backend Rust library targets it (axum, sqlx, reqwest, tonic). This post is what I needed to understand to stop being scared of async Rust.

What async/await compiles to

An async fn doesn’t return a value. It returns a Future — a struct that, when polled, will eventually produce a value (or pend).

async fn fetch(url: &str) -> Result<String, reqwest::Error> {
    let body = reqwest::get(url).await?.text().await?;
    Ok(body)
}

The compiler rewrites this into a state machine. Each .await is a suspension point. When poll() is called and the inner future isn’t ready, the state machine returns Pending; when it’s ready, it advances to the next state.

You don’t write the state machine. The compiler does. But knowing it exists explains why:

  • async fn doesn’t run until something polls it (futures are lazy)
  • async fn requires .await — without it, you have a future you’ve ignored, not a result
  • Each .await is a yield point; the runtime can switch tasks there

In Go, every function is implicitly preemptible. In Rust, only .await points are preemption points. This matters for blocking calls — see below.

Picking a runtime

Tokio is what everyone uses. There are alternatives (async-std, smol), but in 2022 the ecosystem effectively requires Tokio compatibility. Unless you have a specific reason, pick Tokio.

Within Tokio, two flavors:

  • Multi-threaded scheduler (rt-multi-thread): default for backend services. Spreads tasks across worker threads.
  • Current-thread scheduler (rt): single-threaded. For embedded, CLI, or specific use cases.

For an HTTP server, you want multi-threaded:

tokio = { version = "1.17", features = ["macros", "rt-multi-thread", "signal", "net", "time"] }

Then:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // server code
    Ok(())
}

#[tokio::main] is a proc macro that sets up the runtime. Behind it, equivalent to:

fn main() -> anyhow::Result<()> {
    tokio::runtime::Runtime::new()?.block_on(async {
        // server code
        Ok(())
    })
}

Sometimes you want the explicit form (to configure the runtime). Usually the macro is fine.

Tasks: spawn for concurrency

To run things concurrently, spawn them as tasks:

async fn process_orders() {
    let h1 = tokio::spawn(send_emails());
    let h2 = tokio::spawn(update_inventory());
    let h3 = tokio::spawn(notify_warehouse());

    // wait for all
    let _ = tokio::join!(h1, h2, h3);
}

tokio::spawn schedules a future on the runtime. It returns a JoinHandle<T> that resolves to the future’s output when awaited. Tasks run concurrently, possibly on different threads.

Two gotchas:

  • The future passed to spawn must be 'static — it can’t borrow from the parent scope. Pass owned values, or use Arc for shared state.
  • The future must be Send if the runtime is multi-threaded. Most things are; non-Send things (like Rc) need to use the current-thread runtime or get wrapped.

For “wait for any one of N futures to finish”:

tokio::select! {
    result = fetch_a() => println!("a finished first: {:?}", result),
    result = fetch_b() => println!("b finished first: {:?}", result),
}

select! polls multiple futures concurrently and runs the branch for whichever finishes first. The others are dropped (and their state cleaned up). Useful for timeouts, cancellation, and racing IO.

Don’t block in async functions

A single blocking call in an async function stalls the whole worker thread. On a multi-threaded runtime that means one worker is dead until the blocking call returns; on a current-thread runtime, the whole runtime is dead.

The two worst offenders:

  • std::thread::sleep — blocks the OS thread. Use tokio::time::sleep.
  • std::fs::* — blocking IO. Use tokio::fs::*.

For genuinely CPU-bound work or unavoidable blocking calls, use spawn_blocking:

let result = tokio::task::spawn_blocking(|| {
    // some heavy synchronous work, e.g., bcrypt
    bcrypt::hash("password", 12)
}).await?;

spawn_blocking runs the closure on Tokio’s blocking-thread pool (default 512 threads), separate from the async worker pool. The async runtime keeps working.

The rule: an async fn should never block for more than a few microseconds without yielding. If it does, you’re doing it wrong; spawn_blocking is the escape hatch.

Shared state: Mutex, RwLock, Arc

Go has channels and sync.Mutex. Rust async has Arc<Mutex<T>> for shared mutable state across tasks.

use std::sync::Arc;
use tokio::sync::Mutex;

let counter = Arc::new(Mutex::new(0u64));

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    tokio::spawn(async move {
        let mut n = counter.lock().await;
        *n += 1;
    });
}

Two notes:

  • Use tokio::sync::Mutex, not std::sync::Mutex, for state held across .await points. std::sync::Mutex will deadlock if held during an await; tokio::sync::Mutex won’t (it’s async-aware).
  • Arc<Mutex<T>> is the Go-equivalent of mu sync.Mutex; sharedState. Same caveats: keep critical sections short, never call out into other locks from within.

For “channels,” Tokio has several:

  • mpsc — multi-producer, single-consumer. The Go-channel equivalent.
  • oneshot — single send, single receive. For request-reply patterns.
  • broadcast — multi-producer, multi-consumer, all receivers see all messages.
  • watch — for config / state distribution where new receivers see the latest value.

Cancellation

Futures in Rust are cancel-safe by default in a way Go isn’t. Dropping a future stops it. You don’t have to plumb a context.Context everywhere — dropping the JoinHandle cancels the task.

let handle = tokio::spawn(long_running_task());
tokio::time::sleep(Duration::from_secs(5)).await;
handle.abort();   // task cancelled

That’s it. The task’s drop runs (closing connections, releasing resources). Cancellation is structurally simpler than Go’s context-passing model.

Caveat: cancellation happens at .await points. If your future is doing synchronous CPU work between awaits, abort doesn’t stop it mid-loop. Add tokio::task::yield_now().await in long loops.

A tiny working example

End-to-end. Spawn three concurrent HTTP fetches, race for the first to complete, ignore the others:

use anyhow::Result;
use std::time::Duration;
use tokio::time::timeout;

#[tokio::main]
async fn main() -> Result<()> {
    let urls = vec![
        "https://api.example.com/a",
        "https://api.example.com/b",
        "https://api.example.com/c",
    ];

    let result = timeout(Duration::from_secs(2), async {
        let (tx, mut rx) = tokio::sync::mpsc::channel(urls.len());
        for url in urls {
            let tx = tx.clone();
            tokio::spawn(async move {
                if let Ok(resp) = reqwest::get(url).await {
                    if let Ok(body) = resp.text().await {
                        let _ = tx.send(body).await;
                    }
                }
            });
        }
        drop(tx);
        rx.recv().await
    }).await;

    match result {
        Ok(Some(body)) => println!("first response ({} bytes)", body.len()),
        Ok(None)       => println!("all fetches failed"),
        Err(_)         => println!("timed out"),
    }
    Ok(())
}

Three spawned tasks, channel for first-wins coordination, top-level timeout. Cleaner than the equivalent in any other language I know.

Common Pitfalls

Calling .lock() on std::sync::Mutex and holding across .await. Compile error (Rust 1.59+ has improved diagnostics here). Use tokio::sync::Mutex instead.

Forgetting .await. let fut = fetch(); creates a future but doesn’t run it. Lints catch most cases; not all.

Spawning futures that borrow from local scope. Compile error: future must be 'static. Move values into the future with async move { ... }.

Using block_on inside an already-running runtime. Panics. If you need sync code to call into async code, structure the call site differently or use tokio::task::block_in_place.

Treating Tokio’s blocking pool as unlimited. Default 512 threads; spawn-blocking thousands at once and you’ll exhaust it. For genuinely high-volume CPU work, use a Rayon thread pool sized to CPU count.

Choosing futures by their first .await. A future does nothing before it’s polled. tokio::spawn(some_future) starts it; some_future.await runs it inline. Be deliberate.

Wrapping Up

Async Rust + Tokio is a more explicit model than Go’s goroutines, but the control over scheduling, cancellation, and shared state is genuinely worth the learning curve. Once #[tokio::main], tokio::spawn, tokio::select!, and Arc<Mutex<T>> are reflex, you can write any concurrent backend pattern. Wednesday: building a JSON API with Axum — putting Tokio to work for real.