Skip to content
Codeloom
Rust

Async/Await in Rust

Learn async Rust — futures, async/await syntax, the tokio runtime, spawning tasks, channels, and common async patterns.

·4 min read · By Codeloom
Advanced 13 min read

What you'll learn

  • How async/await works in Rust (futures, polling, runtimes)
  • Setting up and using the tokio runtime
  • Spawning tasks, joining, and selecting
  • Async channels and common patterns

Prerequisites

  • Solid Rust fundamentals (ownership, traits, generics)
  • Understanding of concurrency concepts

Rust’s async/await lets you write concurrent code that looks sequential. Unlike Go’s goroutines or JavaScript’s event loop, Rust’s async model is zero-cost — futures do nothing until polled, and the compiler transforms async functions into state machines.

The basics

An async fn returns a Future instead of executing immediately.

async fn fetch_data() -> String {
    "data".to_string()
}

The future does nothing until you .await it:

#[tokio::main]
async fn main() {
    let data = fetch_data().await;
    println!("{data}");
}

Setting up tokio

Add tokio to Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }

The #[tokio::main] macro sets up the runtime:

#[tokio::main]
async fn main() {
    println!("Running on tokio!");
}

Making HTTP requests

use reqwest;

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

#[tokio::main]
async fn main() {
    match fetch_page("https://httpbin.org/get").await {
        Ok(body) => println!("Response: {}", &body[..100]),
        Err(e) => eprintln!("Error: {e}"),
    }
}

Spawning tasks

tokio::spawn runs a future on a new task (similar to goroutines).

use tokio::task;

#[tokio::main]
async fn main() {
    let handle = task::spawn(async {
        // runs concurrently
        expensive_computation().await
    });

    // do other work...

    let result = handle.await.unwrap();
    println!("Result: {result}");
}

Running tasks in parallel

use tokio::join;

async fn fetch_users() -> Vec<String> {
    vec!["Alice".into(), "Bob".into()]
}

async fn fetch_posts() -> Vec<String> {
    vec!["Post 1".into(), "Post 2".into()]
}

#[tokio::main]
async fn main() {
    let (users, posts) = join!(fetch_users(), fetch_posts());
    println!("Users: {users:?}, Posts: {posts:?}");
}

join! runs both futures concurrently and waits for both to complete.

Select: racing futures

tokio::select! runs multiple futures and acts on the first one to complete.

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

#[tokio::main]
async fn main() {
    select! {
        _ = sleep(Duration::from_secs(1)) => {
            println!("Timeout!");
        }
        result = fetch_data() => {
            println!("Got data: {result}");
        }
    }
}

Async channels

Tokio provides mpsc (multi-producer, single-consumer) channels.

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);

    let tx2 = tx.clone();

    tokio::spawn(async move {
        tx.send("from task 1").await.unwrap();
    });

    tokio::spawn(async move {
        tx2.send("from task 2").await.unwrap();
    });

    while let Some(msg) = rx.recv().await {
        println!("Received: {msg}");
    }
}

oneshot channels

For a single response:

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    tokio::spawn(async move {
        tx.send(42).unwrap();
    });

    let value = rx.await.unwrap();
    println!("Got: {value}");
}

Shared state with Mutex

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

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

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

    for handle in handles {
        handle.await.unwrap();
    }

    println!("Counter: {}", *counter.lock().await);
}

Use tokio::sync::Mutex for async code, std::sync::Mutex for sync code.

Timeouts

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

async fn slow_operation() -> String {
    tokio::time::sleep(Duration::from_secs(10)).await;
    "done".to_string()
}

#[tokio::main]
async fn main() {
    match timeout(Duration::from_secs(3), slow_operation()).await {
        Ok(result) => println!("Got: {result}"),
        Err(_) => println!("Timed out!"),
    }
}

Streams

Streams are the async equivalent of iterators.

use tokio_stream::{self as stream, StreamExt};

#[tokio::main]
async fn main() {
    let mut stream = stream::iter(vec![1, 2, 3, 4, 5])
        .map(|n| n * 2)
        .filter(|n| *n > 4);

    while let Some(value) = stream.next().await {
        println!("{value}");
    }
}

Common pitfalls

Holding a MutexGuard across an await point. The guard is not Send, so the task cannot be moved to another thread.

// Bad
let lock = mutex.lock().await;
some_async_fn().await; // lock held across await!
drop(lock);

// Good
{
    let mut lock = mutex.lock().await;
    *lock += 1;
} // lock dropped before await
some_async_fn().await;

Blocking the async runtime. Never call blocking operations (file I/O, CPU-heavy work) directly in async code. Use tokio::task::spawn_blocking.

let result = tokio::task::spawn_blocking(|| {
    std::fs::read_to_string("large_file.txt")
}).await.unwrap();

Summary

Rust async/await gives you concurrent I/O without garbage collection or runtime overhead. Use tokio as your runtime, spawn for concurrency, join! for parallel work, select! for racing, and channels for communication. Avoid blocking the runtime and holding locks across await points.