Async Rust with Tokio: async/await, tasks, and channels

1. Overview

Rust has built-in async/await syntax but no bundled runtime to execute Futures. Tokio is the most popular runtime for this. It provides a scheduler, timer, asynchronous I/O, and synchronization utilities.

This article starts with your first async function and covers common operations: spawning tasks, running them concurrently with join!, setting timeouts with select!, sharing state between tasks, sending data through channels, and handling blocking code. The final section discusses testing and common pitfalls.

2. Setup

Add Tokio to your Cargo.toml:

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

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }

The full feature enables all modules: multi-threaded runtime, time, sync, net, macros… This is convenient for learning. In production, you should enable only what you use to reduce compile time, for example ["rt-multi-thread", "macros", "time", "sync"]. The test-util feature allows controlling time in tests. We’ll use it in section 8.

3. Your first async function

3.1. #[tokio::main] and .await

use std::time::Duration;
use tokio::time::sleep;

async fn fetch_order(id: u32) -> String {
    sleep(Duration::from_millis(100)).await; // simulate a database call
    format!("Order #{id}")
}

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

Explanation:

  • async fn fetch_order doesn’t return a String immediately. It returns an impl Future<Output = String>.
  • tokio::time::sleep is the async version of sleep. While waiting, the thread is handed back to the runtime to do other work.
  • #[tokio::main] is a macro. It creates a multi-threaded runtime and runs main inside it via block_on.
Order #42

3.2. Futures are lazy

This is a major difference from JavaScript Promises: Rust futures do nothing until they are .awaited or polled.

async fn load_customer(id: u32) -> String {
    println!("Loading customer {id}");
    format!("Customer {id}")
}

#[tokio::main]
async fn main() {
    let fut = load_customer(7); // nothing runs in the function yet
    println!("Before await");
    let customer = fut.await;
    println!("{customer}");
}
Before await
Loading customer 7
Customer 7

The “Loading customer 7” line only appears after calling .await. If you forget .await, the function never runs. The compiler will warn unused implementer of Future that must be used, so don’t ignore this warning.

4. Spawning tasks

.await runs futures sequentially. To run them concurrently, use tokio::spawn to create a task. A task is a lightweight “green thread” managed by Tokio.

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

async fn fetch_order(id: u32) -> String {
    sleep(Duration::from_millis(100 * id as u64)).await;
    format!("Order #{id}")
}

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

    for id in 1..=3 {
        handles.push(tokio::spawn(async move { fetch_order(id).await }));
    }

    for handle in handles {
        let order = handle.await.expect("task panicked");
        println!("{order}");
    }
    println!("Total time: ~{} ms", start.elapsed().as_millis());
}
Order #1
Order #2
Order #3
Total time: ~300 ms

Run sequentially, the total time would be 100 + 200 + 300 = 600 ms. When spawned, all three tasks run concurrently, so the total is around 300 ms.

Key points to remember:

  • tokio::spawn returns a JoinHandle<T>. When you .await the handle, you get Result<T, JoinError>. The result is Err if the task panicked or was cancelled.
  • The future passed in must satisfy Send + 'static, because the task may run on a different thread and outlive the current scope. That’s why you need async move.
  • A task starts running immediately when spawned, not when you .await its handle.

Note: Dropping a JoinHandle does not cancel the task. The task continues running in detached mode. To cancel it, call handle.abort().

When the number of tasks varies, you can use tokio::task::JoinSet. It lets you get results in the order tasks complete via join_set.join_next().await.

5. Running concurrently in the same task: join!, select!, timeout

5.1. join! and try_join!

You don’t always need to spawn. tokio::join! polls multiple futures concurrently in the same task and waits for all to complete:

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

async fn fetch_order(id: u32) -> String {
    sleep(Duration::from_millis(200)).await;
    format!("Order #{id}")
}

async fn fetch_customer(id: u32) -> String {
    sleep(Duration::from_millis(150)).await;
    format!("Customer {id}")
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let (order, customer) = tokio::join!(fetch_order(1), fetch_customer(7));
    println!("{order} - {customer} ({} ms)", start.elapsed().as_millis());
}
Order #1 - Customer 7 (200 ms)

The total time equals the slowest future, around 200 ms. Futures don’t need 'static, so they can borrow local variables.

When functions return Result, use tokio::try_join!. This macro stops at the first error and drops the remaining futures:

let (order, customer) = tokio::try_join!(fetch_order_checked(1), fetch_customer_checked(7))?;

Here fetch_order_checked and fetch_customer_checked are async functions returning Result<String, AppError>.

5.2. timeout

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

match timeout(Duration::from_millis(50), fetch_order(1)).await {
    Ok(order) => println!("{order}"),
    Err(_) => println!("Timed out"),
}
Timed out

fetch_order takes 200 ms, exceeding the 50 ms limit. Then timeout returns Err(Elapsed), and the inner future is dropped, meaning it’s cancelled.

5.3. select!

select! waits for the first branch to complete, then cancels the rest:

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();

    tokio::spawn(async move {
        sleep(Duration::from_millis(100)).await;
        let _ = shutdown_tx.send(());
    });

    tokio::select! {
        order = fetch_order(1) => println!("Processing complete {order}"),
        _ = shutdown_rx => println!("Received shutdown signal, stopping processing"),
    }
}
Received shutdown signal, stopping processing

The shutdown signal arrives after 100 ms, before fetch_order (200 ms), so the second branch wins.

Note: Cancellation in Rust means the future is dropped at the nearest .await point. If the cancelled branch was doing a non-atomic operation—like reading half a message from a socket—data might be lost. Check the “cancel safety” section in each function’s documentation before using it with select! in a loop.

6. Sharing data between tasks

6.1. Arc<Mutex<T>>

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

#[tokio::main]
async fn main() {
    let inventory = Arc::new(Mutex::new(HashMap::from([("laptop", 10u32)])));
    let mut handles = Vec::new();

    for _ in 0..5 {
        let inventory = Arc::clone(&inventory);
        handles.push(tokio::spawn(async move {
            let mut stock = inventory.lock().unwrap();
            *stock.get_mut("laptop").unwrap() -= 1;
        }));
    }
    for h in handles {
        h.await.unwrap();
    }
    println!("Remaining: {}", inventory.lock().unwrap()["laptop"]);
}
Remaining: 5

Here we use std::sync::Mutex, not tokio::sync::Mutex. This is recommended when the critical section is short and has no .await inside, because the standard mutex is faster.

You only need tokio::sync::Mutex when you must hold a lock across an .await point, such as holding a shared connection while waiting for I/O.

6.2. Channel mpsc

Instead of sharing memory, you can send messages between tasks through a channel:

use tokio::sync::mpsc;

#[derive(Debug)]
struct Order {
    id: u32,
    amount: u64,
}

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

    for id in 1..=3 {
        let tx = tx.clone();
        tokio::spawn(async move {
            tx.send(Order { id, amount: id as u64 * 100 }).await.unwrap();
        });
    }
    drop(tx); // close the original sender so the loop below can finish

    let mut total = 0;
    while let Some(order) = rx.recv().await {
        println!("Received {order:?}");
        total += order.amount;
    }
    println!("Total: {total}");
}
Received Order { id: 1, amount: 100 }
Received Order { id: 3, amount: 300 }
Received Order { id: 2, amount: 200 }
Total: 600

The order of reception may vary between runs. The parameter 32 is the buffer capacity. When the buffer is full, send waits, creating natural back-pressure. recv() returns None when all senders have been dropped. Forgetting drop(tx) will cause the program to hang in the while loop.

Besides mpsc, Tokio also provides:

  • oneshot: send a single value.
  • broadcast: multiple consumers, each receives every message.
  • watch: consumers only care about the latest value.

7. Handling blocking code

Tokio uses cooperative scheduling. Tasks only yield the thread at .await points. A CPU-intensive operation or synchronous I/O will monopolize a worker thread, starving other tasks.

fn hash_password(password: &str) -> String {
    // simulate a CPU-intensive operation, e.g., bcrypt/argon2
    std::thread::sleep(std::time::Duration::from_millis(500));
    format!("hashed:{password}")
}

#[tokio::main]
async fn main() {
    let password = String::from("s3cret");
    let hashed = tokio::task::spawn_blocking(move || hash_password(&password))
        .await
        .expect("blocking task panicked");
    println!("{hashed}");
}
hashed:s3cret

spawn_blocking pushes the closure to a separate thread pool for blocking work. This keeps the runtime’s worker threads free to handle other requests.

Note: Don’t call std::thread::sleep, std::fs::read, or synchronous HTTP clients directly in an async function. Use async versions (tokio::time::sleep, tokio::fs) or wrap them in spawn_blocking. For large-scale parallel computation, Rayon is often more suitable.

8. Testing async code

The #[tokio::test] macro creates a runtime for each test. By default, it’s single-threaded.

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

pub async fn fetch_order(id: u32) -> String {
    sleep(Duration::from_millis(200)).await;
    format!("Order #{id}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn fetch_order_returns_formatted_id() {
        assert_eq!(fetch_order(42).await, "Order #42");
    }

    #[tokio::test(start_paused = true)]
    async fn timeout_fires_when_order_service_is_slow() {
        let result = timeout(Duration::from_millis(50), fetch_order(1)).await;
        assert!(result.is_err());
    }

    #[tokio::test(start_paused = true)]
    async fn order_arrives_within_generous_timeout() {
        let result = timeout(Duration::from_secs(1), fetch_order(1)).await;
        assert_eq!(result.unwrap(), "Order #1");
    }
}
cargo test
running 3 tests
test tests::fetch_order_returns_formatted_id ... ok
test tests::timeout_fires_when_order_service_is_slow ... ok
test tests::order_arrives_within_generous_timeout ... ok

start_paused = true (requires the test-util feature) freezes Tokio’s clock. When all tasks are waiting for timers, the runtime automatically jumps time to the next event. This makes timeout tests run nearly instantaneously with stable results, independent of CI machine speed.

If you need to test behavior on multiple threads, use #[tokio::test(flavor = "multi_thread", worker_threads = 2)].

9. Common pitfalls

Holding std::sync::MutexGuard across .await. This guard is not Send, so the future isn’t Send either. Then tokio::spawn reports a compilation error: future cannot be sent between threads safely. Fix it by narrowing the guard’s scope with a block { ... } before .await, or switch to tokio::sync::Mutex.

Nesting runtimes. Calling Runtime::new().block_on(...) inside code already running on Tokio panics with Cannot start a runtime from within a runtime. In an async context, just use .await.

Using async unnecessarily. A CLI tool reading a few files, or a pure computation job, gains nothing from Tokio. You only pay extra compilation time and complexity. Async shines when you have many concurrent I/O operations: web servers, crawlers, proxies, clients calling multiple services.

Spawning without limits. Tasks are cheap but not free. Spawning a task for each of a million items can starve downstream resources, like a database connection pool. Limit concurrency with tokio::sync::Semaphore or use buffer_unordered from the futures crate.

Ignoring JoinError. If you .await a handle with let _ =, panics in the task get swallowed. Log the error or propagate it instead.

10. Conclusion

This article has covered the core parts of async Rust with Tokio:

  • Futures are lazy and only run when .awaited.
  • tokio::spawn creates tasks that run concurrently. The passed-in future must be Send + 'static.
  • join! and try_join! run multiple futures in the same task. select! and timeout handle racing and cancellation.
  • Shared data can be shared via Arc<Mutex<T>>, or passed through channels like mpsc and oneshot.
  • Blocking code must be moved to spawn_blocking.
  • #[tokio::test(start_paused = true)] makes timing tests run fast and stable.

Once you’re comfortable with these concepts, you can move on to networking I/O with tokio::net, or build web services on Tokio using frameworks like Axum.

rusttokioasyncconcurrency