← All articles
a laptop computer sitting on top of a desk

Tokio: Async Runtime for Rust

Languages 2026-03-04 · 4 min read tokio rust async concurrency async-await axum networking developer tools
By DevTools Guide Editorial TeamSoftware engineers and developer advocates covering tools, workflows, and productivity for modern development teams.

Rust's async/await syntax is built into the language but requires a runtime to execute futures. Tokio is the dominant async runtime in the Rust ecosystem — used by Axum, Reqwest, SQLx, Tonic, and most production Rust services. Understanding Tokio's model is essential for writing effective async Rust.

Photo by Rahul Mishra on Unsplash

The Runtime Model

Tokio uses a thread pool of worker threads (one per CPU core by default) with work stealing — idle threads take tasks from busy ones. This gives high throughput for I/O-bound work without the overhead of OS threads per connection.

Two modes:

Setup

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

features = ["full"] enables everything. In production, you can enable only what you use: rt-multi-thread, io-util, net, time, sync, fs, process.

The #[tokio::main] Macro

#[tokio::main]
async fn main() {
    println!("Hello from async Rust!");

    // Async operations work here
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
    println!("Done after 1 second");
}

#[tokio::main] expands to creating the runtime and calling block_on on your async main function.

Like what you're reading? Subscribe to DevTools Guide — free weekly guides in your inbox.

Spawning Tasks

use tokio::task;

#[tokio::main]
async fn main() {
    // Spawn a task — runs concurrently
    let handle = task::spawn(async {
        println!("Running in a spawned task");
        42  // return value
    });

    // Do other work...
    println!("Main continues while task runs");

    // Wait for the spawned task
    let result = handle.await.unwrap();
    println!("Task returned: {result}");
}

Spawned tasks run concurrently. JoinHandle::await waits for completion.

Running Multiple Tasks Concurrently

use tokio::task;

#[tokio::main]
async fn main() {
    let handles: Vec<_> = (0..10)
        .map(|i| {
            task::spawn(async move {
                // Simulate I/O work
                tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
                i * 2
            })
        })
        .collect();

    // Wait for all to complete
    let results: Vec<i32> = futures::future::join_all(handles)
        .await
        .into_iter()
        .map(|r| r.unwrap())
        .collect();

    println!("Results: {:?}", results);
}

select! — Race Multiple Futures

select! polls multiple futures and returns when any one completes:

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

#[tokio::main]
async fn main() {
    let result = tokio::select! {
        _ = sleep(Duration::from_millis(100)) => {
            "fast branch"
        }
        _ = sleep(Duration::from_secs(10)) => {
            "slow branch"
        }
    };

    println!("Completed: {result}");  // "fast branch"
}

Common use: race a computation against a timeout.

use tokio::time::timeout;

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

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

Channels for Task Communication

mpsc (multiple producers, single consumer)

use tokio::sync::mpsc;

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

    // Spawn producers
    for i in 0..5 {
        let tx = tx.clone();
        tokio::spawn(async move {
            tx.send(format!("message from task {i}")).await.unwrap();
        });
    }

    // Drop original sender so receiver knows when all senders are done
    drop(tx);

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

oneshot (single value, single use)

use tokio::sync::oneshot;

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

    tokio::spawn(async move {
        // Do work and send result
        let result = 42;
        tx.send(result).unwrap();
    });

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

broadcast (one sender, multiple receivers)

use tokio::sync::broadcast;

let (tx, _) = broadcast::channel::<String>(16);
let mut rx1 = tx.subscribe();
let mut rx2 = tx.subscribe();

tx.send("hello all".to_string()).unwrap();

// Both receivers get the message
println!("{}", rx1.recv().await.unwrap());
println!("{}", rx2.recv().await.unwrap());

Shared State with Mutex

Tokio provides an async-aware Mutex (unlike std::sync::Mutex which blocks the thread):

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

#[tokio::main]
async fn main() {
    let counter = Arc::new(Mutex::new(0u64));

    let handles: Vec<_> = (0..100)
        .map(|_| {
            let counter = Arc::clone(&counter);
            tokio::spawn(async move {
                let mut lock = counter.lock().await;
                *lock += 1;
            })
        })
        .collect();

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

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

For read-heavy workloads, use RwLock instead.

TCP 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!("Listening on port 8080");

    loop {
        let (socket, addr) = listener.accept().await?;
        println!("Connection from {addr}");

        // Spawn a task per connection
        tokio::spawn(async move {
            handle_connection(socket).await;
        });
    }
}

async fn handle_connection(mut socket: TcpStream) {
    let mut buf = vec![0; 1024];
    loop {
        match socket.read(&mut buf).await {
            Ok(0) => break,  // Connection closed
            Ok(n) => {
                socket.write_all(&buf[0..n]).await.unwrap();  // Echo back
            }
            Err(e) => {
                eprintln!("Error: {e}");
                break;
            }
        }
    }
}

File I/O

use tokio::fs;
use tokio::io::AsyncWriteExt;

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    // Read file
    let contents = fs::read_to_string("config.toml").await?;
    println!("{contents}");

    // Write file
    let mut file = fs::File::create("output.txt").await?;
    file.write_all(b"Hello, file!").await?;

    // Read directory
    let mut entries = fs::read_dir(".").await?;
    while let Some(entry) = entries.next_entry().await? {
        println!("{}", entry.file_name().to_string_lossy());
    }

    Ok(())
}

Common Pitfalls

Blocking in async context: Don't call blocking functions (std::fs, std::thread::sleep, CPU-intensive work) directly in async tasks — they block the thread and starve other tasks. Use tokio::task::spawn_blocking for blocking work:

let result = tokio::task::spawn_blocking(|| {
    // Blocking work here
    std::fs::read_to_string("large-file.txt")
}).await.unwrap();

Holding locks across awaits: Holding a Mutex lock while awaiting can cause deadlocks or prevent the lock from being acquired on other tasks. Release locks before awaiting:

// Bad:
let lock = mutex.lock().await;
some_async_fn().await;  // Lock held across await

// Good:
{
    let lock = mutex.lock().await;
    // use lock
} // lock dropped here
some_async_fn().await;

Tokio Console (Debugging)

cargo add tokio-console
fn main() {
    console_subscriber::init();  // Must be first
    // ... rest of main
}

Run tokio-console to see real-time task visualization — which tasks are running, which are blocked, task tree.

Tokio is the foundation for the Rust async ecosystem. The Tokio tutorial covers the full API in depth.

Get free weekly tips in your inbox. Subscribe to DevTools Guide