Async Rust

Async Rust: A Practical Guide to Asynchronous Programming

8 min read

Asynchronous programming in Rust allows you to write concurrent code that's both efficient and safe. In this guide, we'll explore how to use async/await and the Tokio runtime to build responsive, high-performance applications.

What is Async Programming?

Async programming allows you to handle multiple tasks concurrently without using threads. This is particularly useful for I/O-bound operations like:

  • Web servers handling many concurrent requests
  • Database queries that wait for responses
  • File I/O operations
  • Network calls to external services

Key insight

Unlike threads, async tasks are extremely lightweight. A single thread can drive thousands of async tasks because they only consume resources when actively doing work, not while waiting for I/O.

Setting Up Tokio

First, add Tokio to your Cargo.toml:

Cargo.toml
[dependencies]
tokio = { version = "1.35", features = ["full"] }

Your First Async Function

Here's a simple async function:

main.rs
async fn fetch_data() -> String {
    "Hello from async!".to_string()
}

To call an async function, you need to .await it:

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

The #[tokio::main] macro sets up the async runtime for you. It transforms your async fn main() into a synchronous fn main() that starts the Tokio runtime and blocks on your async code.

Concurrent Operations

One of the biggest advantages of async is running multiple operations concurrently:

main.rs
use tokio::time::{sleep, Duration};

async fn task1() {
    sleep(Duration::from_secs(1)).await;
    println!("Task 1 complete");
}

async fn task2() {
    sleep(Duration::from_secs(1)).await;
    println!("Task 2 complete");
}

#[tokio::main]
async fn main() {
    let handle1 = tokio::spawn(task1());
    let handle2 = tokio::spawn(task2());

    // Wait for both to complete
    let _ = tokio::join!(handle1, handle2);
}

Both tasks run concurrently, so the total time is ~1 second, not 2! The tokio::spawn function creates a new async task, and tokio::join! waits for all of them to finish.

Key insight

tokio::spawn creates a new task that runs independently on the runtime's thread pool. tokio::join! runs futures concurrently on the same task. Use spawn when you need true parallelism, and join! when you just need concurrency within a single task.

Error Handling in Async

Async functions can return Result types just like synchronous functions:

main.rs
use std::io;

async fn read_file() -> io::Result<String> {
    tokio::fs::read_to_string("data.txt").await
}

#[tokio::main]
async fn main() {
    match read_file().await {
        Ok(contents) => println!("File contents: {}", contents),
        Err(e) => eprintln!("Error reading file: {}", e),
    }
}

The ? operator works seamlessly inside async functions too, so you can propagate errors just as you would in synchronous code.

Selecting Between Futures

Sometimes you want to wait for the first of multiple operations to complete. The tokio::select! macro handles this:

main.rs
use tokio::time::{sleep, Duration};

async fn operation1() -> &'static str {
    sleep(Duration::from_secs(1)).await;
    "operation 1"
}

async fn operation2() -> &'static str {
    sleep(Duration::from_millis(500)).await;
    "operation 2"
}

#[tokio::main]
async fn main() {
    tokio::select! {
        result = operation1() => println!("First: {}", result),
        result = operation2() => println!("First: {}", result),
    }
}

In this example, operation2 completes first (500ms vs 1s), so the select! macro runs its branch and cancels the other future. This is useful for implementing timeouts, cancellation, and racing between alternatives.

Async Streams

Tokio also supports async streams for processing sequences of values over time. You'll need the tokio-stream crate for this:

main.rs
use tokio_stream::{self as stream, StreamExt};

#[tokio::main]
async fn main() {
    let mut stream = stream::iter(vec![1, 2, 3, 4, 5]);

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

Async streams are the asynchronous equivalent of iterators. They are especially useful for processing data from network connections, message queues, or any source that produces values over time.

Best Practices

Follow these guidelines to write effective async Rust code:

  1. 1 Don't block the runtime: Avoid long-running CPU-intensive operations in async code
  2. 2 Use spawn_blocking for CPU work: If you must do CPU-intensive work, use tokio::task::spawn_blocking
  3. 3 Handle errors properly: Always propagate errors using ? or handle them explicitly
  4. 4 Be careful with locks: Use async-aware locks like tokio::sync::Mutex instead of std::sync::Mutex

Key insight

The async runtime uses a thread pool to execute tasks cooperatively. When you block a thread with synchronous code (like std::thread::sleep or heavy computation), you prevent that thread from running other async tasks. This can starve the entire runtime and cause deadlocks or severe performance degradation.

Common Pitfalls

Even experienced Rust developers run into these issues when working with async code. Here are the most common mistakes and how to avoid them.

Blocking in Async Context

⚠ Pitfall

Using std::thread::sleep or other blocking operations inside an async function will block the entire runtime thread. This prevents other tasks from making progress and can cause your application to appear frozen or unresponsive.

main.rs
// DON'T DO THIS
async fn bad_example() {
    std::thread::sleep(Duration::from_secs(1)); // Blocks the runtime!
}

// DO THIS INSTEAD
async fn good_example() {
    tokio::time::sleep(Duration::from_secs(1)).await; // Async sleep
}

Forgetting to Await

⚠ Pitfall

Calling an async function without .await returns a Future that is never executed. The Rust compiler will warn you about this, but it's a common source of confusing bugs, especially for developers coming from languages like JavaScript where promises start executing immediately.

main.rs
// This doesn't do what you think!
async fn wrong() {
    fetch_data(); // Returns a Future, but doesn't execute!
}

// Correct:
async fn right() {
    fetch_data().await; // Actually executes the future
}

Conclusion

Async Rust with Tokio provides powerful tools for writing concurrent, efficient code. While the learning curve can be steep, the benefits in terms of performance and safety are substantial.

In the next post, we'll explore more advanced async patterns and building a real-world web server with Tokio and Axum.