Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 024 / 100

Rust async programming: a practical guide for developers

async/await with Tokio.

Expona Admin6 min read

The async revolution in Rust

If you've built web services or data-heavy applications in Rust, you've likely hit a wall: synchronous code forces you to wait for I/O operations, wasting CPU cycles and limiting throughput. Rust async programming: a practical guide for developers shows us how to handle thousands of concurrent operations on a single thread without the complexity of threads themselves. In this guide, we'll work through async/await syntax, the Tokio runtime, spawning concurrent tasks, and the critical rule of never blocking the async executor.

Async functions—Return futures that run when awaited

An async function in Rust doesn't execute immediately. Instead, it returns a Future trait object that describes work to be done. The actual execution only happens when you call .await on it. This is fundamental: marking a function async is a promise that it can be paused and resumed without blocking the current thread.

Consider a scenario where we fetch user data from an API and log the response:

rust
async fn fetch_user(id: u32) -> anyhow::Result<String> {
    let url = format!("https://api.example.com/users/{}", id);
    let response = reqwest::get(&url).await?;
    let body = response.text().await?;
    Ok(body)
}

async fn main() -> anyhow::Result<()> {
    let user_data = fetch_user(42).await?;
    println!("User: {}", user_data);
    Ok(())
}

Without the .await, fetch_user(42) returns a future but does nothing. The .await expression yields control to the runtime, which polls the future until it completes. This is where the magic happens: while waiting for the network response, the runtime can execute other tasks.

The key insight for senior developers: every .await point is a potential suspension. The compiler ensures that all data held across an .await must be Send if you want to move the future between threads. This is why Rust's ownership system and async work so well together—safety is built in.

A runtime is the scheduler that drives your futures forward. Tokio is the de facto standard in the Rust ecosystem, used in production by companies running trading platforms, ride-hailing services, and high-scale SaaS products. It provides a multi-threaded work-stealing scheduler, timer management, and I/O integration via mio.

The simplest way to start a Tokio program is the #[tokio::main] attribute macro:

rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let body = reqwest::get("https://example.com").await?.text().await?;
    println!("{}", body.len());
    Ok(())
}

This macro expands to create a runtime, block on the async main function, and clean up afterwards. Under the hood, it's equivalent to:

rust
fn main() -> anyhow::Result<()> {
    let rt = tokio::runtime::Runtime::new()?;
    rt.block_on(async {
        let body = reqwest::get("https://example.com").await?.text().await?;
        println!("{}", body.len());
        Ok(())
    })
}

For production services, you may want to customize the runtime. Use tokio::runtime::Builder to tune worker threads, stack size, or I/O driver configuration:

rust
use tokio::runtime;

fn main() -> anyhow::Result<()> {
    let rt = runtime::Builder::new_multi_thread()
        .worker_threads(4)
        .thread_name("worker")
        .build()?;

    rt.block_on(async {
        println!("Running on custom runtime");
    });

    Ok(())
}

Tokio's scheduler is work-stealing: when one worker thread runs out of tasks, it steals from others, keeping load balanced. This efficiency is why services handling millions of concurrent connections use Tokio.

Concurrent tasks—tokio::spawn and join!

Awaiting a single future is useful, but the power of async lies in concurrency. You can launch multiple tasks that progress independently without waiting for one to finish before starting another.

Use tokio::spawn to launch a task on the runtime. It returns a JoinHandle that you can await later:

rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let handle1 = tokio::spawn(async {
        println!("Task 1 starting");
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
        println!("Task 1 done");
        42
    });

    let handle2 = tokio::spawn(async {
        println!("Task 2 starting");
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
        println!("Task 2 done");
        "hello"
    });

    let (result1, result2) = tokio::join!(handle1, handle2);
    println!("Results: {:?}, {:?}", result1?, result2?);
    Ok(())
}

The tokio::join! macro awaits multiple futures concurrently. Notice that both tasks start immediately; Task 2 finishes first even though we define Task 1 first. Total wall-clock time is ~2 seconds, not 3.

For collecting results from a dynamic number of tasks, use futures::stream::FuturesUnordered or FuturesOrdered:

rust
use futures::stream::{FuturesUnordered, StreamExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut handles = FuturesUnordered::new();

    for i in 0..5 {
        let handle = tokio::spawn(async move {
            println!("Processing {}", i);
            i * 2
        });
        handles.push(handle);
    }

    while let Some(result) = handles.next().await {
        println!("Got: {:?}", result?);
    }

    Ok(())
}

This pattern is common in microservices: spawn a task per incoming request, collect results as they complete, and respond to clients without waiting for all tasks. It scales far better than thread-per-request.

Do not block—Use spawn_blocking for CPU or blocking I/O

The golden rule: never call blocking operations on a Tokio task. Blocking functions freeze the entire worker thread, preventing other tasks from making progress. This destroys throughput.

Common blocking operations include:

  • Synchronous I/O: std::fs::read_to_string, database drivers without async support.
  • CPU-intensive work: hashing, encryption, image processing.
  • Thread synchronization: std::sync::Mutex held across .await points.

For these, use tokio::task::spawn_blocking:

rust
use std::fs;
use std::time::Instant;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let start = Instant::now();

    // Spawn blocking work on a dedicated thread pool
    let file_content = tokio::task::spawn_blocking(|| {
        fs::read_to_string("large_file.txt")
    }).await??;

    println!("Read {} bytes in {:?}", file_content.len(), start.elapsed());
    Ok(())
}

Tokio maintains a separate thread pool for blocking tasks. This keeps async threads free to service other requests. For a web service handling 10,000 concurrent connections, one blocking operation per request on the main pool would starve others—spawning blocking work elsewhere solves it.

If you're using a database, prefer async drivers: sqlx with the postgres feature, mongodb's async API, or redis-rs with the aio module. They integrate seamlessly with Tokio.

Common mistakes

Mistake 1: Forgetting .await

The compiler will warn you, but it's easy to miss. A future without .await compiles but does nothing, leading to silent logic bugs. Always check that you're actually awaiting.

Mistake 2: Holding a Mutex across .await

rust
// WRONG: Can deadlock or panic
let guard = mutex.lock().unwrap();
some_async_operation().await;
drop(guard);

Use tokio::sync::Mutex instead, which is async-aware:

rust
// RIGHT
let guard = async_mutex.lock().await;
some_async_operation().await;
drop(guard);

Mistake 3: Spawning unbounded tasks

If you spawn a task for each incoming request without limits, you'll exhaust memory under load. Use a semaphore or bounded channel to control concurrency:

rust
let semaphore = Arc::new(tokio::sync::Semaphore::new(100));

for _ in 0..10000 {
    let permit = semaphore.acquire().await?;
    tokio::spawn(async move {
        // Work here
        drop(permit); // Release slot
    });
}

Key takeaways

  • Async functions return futures—they don't run until you .await them.
  • Tokio is the standard runtime—use #[tokio::main] to bootstrap and tokio::runtime::Builder for tuning.
  • Launch concurrent tasks with tokio::spawn—progress independently without threads.
  • Never block the async executor—use spawn_blocking for I/O and CPU work, or async drivers for databases.
  • Await multiple futures with tokio::join!—run them concurrently and collect results in one expression.
  • Be careful with locks and unbounded concurrency—use async-aware primitives and bound spawning with semaphores.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles