← All articles
LANGUAGES Tokio: Async Runtime for Rust 2026-03-04 · 4 min read · tokio · rust · async

Tokio: Async Runtime for Rust

Languages 2026-03-04 · 4 min read tokio rust async concurrency async-await axum networking developer tools

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.

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.

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.