Rust tokio: Async Runtime Guide

Rust's async/await syntax is just half the story. The language provides the primitives for writing asynchronous code, but you need a runtime to actually execute it. That's where Tokio comes in.

Key Insights

  • Tokio provides the runtime infrastructure that executes Rust’s async/await syntax, handling task scheduling, I/O polling, and thread management automatically
  • The #[tokio::main] macro is syntactic sugar that creates a runtime and blocks on your async main function, but manual runtime configuration gives you fine-grained control over thread pools and behavior
  • Use spawn_blocking() for CPU-intensive work to avoid starving the async runtime, and leverage tokio::select! for efficient multiplexing of concurrent operations

Introduction to Tokio and Async Rust

Rust’s async/await syntax is just half the story. The language provides the primitives for writing asynchronous code, but you need a runtime to actually execute it. That’s where Tokio comes in.

Tokio is the de facto async runtime for Rust. It provides a multi-threaded, work-stealing task scheduler, async I/O primitives, timers, and synchronization utilities. When you write async fn in Rust, you’re creating a future—a value that represents a computation that may not be complete yet. Tokio’s executor polls these futures, driving them to completion.

The relationship is straightforward: Rust gives you the syntax, Tokio gives you the execution engine. Without a runtime, your async functions won’t run. With Tokio, you get production-ready concurrency that can handle thousands of concurrent connections efficiently.

Here’s the simplest possible Tokio program:

#[tokio::main]
async fn main() {
    println!("Hello from async!");
    
    let result = async_operation().await;
    println!("Result: {}", result);
}

async fn async_operation() -> String {
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
    "Operation complete".to_string()
}

The #[tokio::main] macro expands to code that creates a runtime, blocks the current thread, and runs your async main function to completion. It’s the easiest way to get started.

Setting Up Tokio and Runtime Configuration

Add Tokio to your Cargo.toml with the features you need:

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

The full feature is convenient for development, but in production, be explicit about what you need:

[dependencies]
tokio = { version = "1.35", features = [
    "rt-multi-thread",  # Multi-threaded runtime
    "macros",           # #[tokio::main] and #[tokio::test]
    "io-util",          # Async read/write utilities
    "net",              # TCP/UDP networking
    "time",             # Timers and delays
    "sync",             # Async synchronization primitives
    "fs",               # Async file operations
] }

For more control, create the runtime manually:

use tokio::runtime::Runtime;

fn main() {
    let runtime = Runtime::new().unwrap();
    
    runtime.block_on(async {
        println!("Running on custom runtime");
        async_work().await;
    });
}

async fn async_work() {
    // Your async code here
}

The builder pattern gives you fine-grained configuration:

use tokio::runtime::Builder;

fn main() {
    let runtime = Builder::new_multi_thread()
        .worker_threads(4)
        .thread_name("my-pool")
        .thread_stack_size(3 * 1024 * 1024)
        .enable_all()
        .build()
        .unwrap();
    
    runtime.block_on(async {
        // Your async code
    });
}

You can also create a current-thread runtime for single-threaded applications:

let runtime = Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();

This is useful for applications that don’t need parallelism or when you want predictable, deterministic scheduling.

Tasks and Concurrency Primitives

Tokio tasks are lightweight, green threads managed by the runtime. Spawn them with tokio::spawn():

#[tokio::main]
async fn main() {
    let handle1 = tokio::spawn(async {
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
        "Task 1 complete"
    });
    
    let handle2 = tokio::spawn(async {
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
        "Task 2 complete"
    });
    
    let result1 = handle1.await.unwrap();
    let result2 = handle2.await.unwrap();
    
    println!("{}, {}", result1, result2);
}

For task communication, use channels. The mpsc (multi-producer, single-consumer) channel is most common:

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);
    
    // Spawn multiple senders
    for i in 0..5 {
        let tx_clone = tx.clone();
        tokio::spawn(async move {
            tx_clone.send(i).await.unwrap();
        });
    }
    
    drop(tx); // Close the channel when all senders are done
    
    // Receive all messages
    while let Some(msg) = rx.recv().await {
        println!("Received: {}", msg);
    }
}

For shared state, use tokio::sync::Mutex (not std::sync::Mutex):

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

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    
    for _ in 0..10 {
        let counter_clone = Arc::clone(&counter);
        let handle = tokio::spawn(async move {
            let mut num = counter_clone.lock().await;
            *num += 1;
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.await.unwrap();
    }
    
    println!("Final count: {}", *counter.lock().await);
}

The key difference: tokio::sync::Mutex yields control when waiting for the lock instead of blocking the thread.

Async I/O Operations

Tokio excels at I/O-bound workloads. Here’s a simple TCP echo server:

use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Server listening on port 8080");
    
    loop {
        let (socket, addr) = listener.accept().await?;
        println!("New connection from {}", addr);
        
        tokio::spawn(async move {
            handle_client(socket).await;
        });
    }
}

async fn handle_client(mut socket: TcpStream) {
    let mut buf = vec![0; 1024];
    
    loop {
        match socket.read(&mut buf).await {
            Ok(0) => return, // Connection closed
            Ok(n) => {
                if socket.write_all(&buf[..n]).await.is_err() {
                    return;
                }
            }
            Err(_) => return,
        }
    }
}

Async file operations work similarly:

use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Write to file
    let mut file = File::create("output.txt").await?;
    file.write_all(b"Hello, async file I/O!").await?;
    
    // Read from file
    let mut file = File::open("output.txt").await?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).await?;
    
    println!("File contents: {}", contents);
    Ok(())
}

Common Patterns and Best Practices

The select! macro lets you wait on multiple futures simultaneously:

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

#[tokio::main]
async fn main() {
    let mut interval = tokio::time::interval(Duration::from_secs(1));
    
    loop {
        tokio::select! {
            _ = interval.tick() => {
                println!("Timer tick");
            }
            _ = tokio::signal::ctrl_c() => {
                println!("Shutdown signal received");
                break;
            }
        }
    }
}

Timeouts prevent operations from hanging indefinitely:

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

#[tokio::main]
async fn main() {
    let result = timeout(Duration::from_secs(5), slow_operation()).await;
    
    match result {
        Ok(value) => println!("Completed: {:?}", value),
        Err(_) => println!("Operation timed out"),
    }
}

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

For graceful shutdown, use channels to signal tasks:

use tokio::sync::broadcast;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let (shutdown_tx, _) = broadcast::channel(1);
    let mut handles = vec![];
    
    for i in 0..3 {
        let mut shutdown_rx = shutdown_tx.subscribe();
        let handle = tokio::spawn(async move {
            loop {
                tokio::select! {
                    _ = sleep(Duration::from_secs(1)) => {
                        println!("Worker {} tick", i);
                    }
                    _ = shutdown_rx.recv() => {
                        println!("Worker {} shutting down", i);
                        break;
                    }
                }
            }
        });
        handles.push(handle);
    }
    
    sleep(Duration::from_secs(5)).await;
    shutdown_tx.send(()).unwrap();
    
    for handle in handles {
        handle.await.unwrap();
    }
}

Performance Considerations and Debugging

Never block the async runtime with synchronous operations. Use spawn_blocking() for CPU-intensive work:

#[tokio::main]
async fn main() {
    let result = tokio::task::spawn_blocking(|| {
        // CPU-intensive computation
        let mut sum = 0u64;
        for i in 0..1_000_000_000 {
            sum += i;
        }
        sum
    }).await.unwrap();
    
    println!("Result: {}", result);
}

For debugging async code, use console-subscriber:

[dependencies]
console-subscriber = "0.2"
fn main() {
    console_subscriber::init();
    
    let runtime = tokio::runtime::Runtime::new().unwrap();
    runtime.block_on(async {
        // Your async code
    });
}

Then run tokio-console in a separate terminal to see real-time task metrics.

Avoid common pitfalls: don’t hold locks across await points unnecessarily, don’t create too many tasks (use semaphores to limit concurrency), and always handle errors properly in spawned tasks—panics in tasks don’t crash the runtime but they do kill that specific task.

Tokio gives you the tools to build high-performance async applications. Master these fundamentals, and you’ll write concurrent Rust code that’s both fast and maintainable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.