3 min read
On this page

Tokio Basics

Tokio is the dominant async runtime in Rust. It provides the executor that drives futures, plus a rich set of utilities: timers, channels, I/O primitives, and task management. Most production async Rust code runs on Tokio.

#[tokio::main]

The #[tokio::main] attribute macro sets up a Tokio runtime and runs your async main:

#[tokio::main]
async fn main() {
    println!("Running on Tokio");
}

This expands to:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("Running on Tokio");
        });
}

For tests, use #[tokio::test]:

#[tokio::test]
async fn test_something() {
    let result = fetch_data().await;
    assert_eq!(result, "expected");
}

Spawning Tasks

tokio::spawn creates a new task that runs concurrently on the runtime's thread pool. It returns a JoinHandle:

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

#[tokio::main]
async fn main() {
    let handle_a = tokio::spawn(async {
        sleep(Duration::from_millis(200)).await;
        "task A done"
    });

    let handle_b = tokio::spawn(async {
        sleep(Duration::from_millis(100)).await;
        "task B done"
    });

    let a = handle_a.await.unwrap();
    let b = handle_b.await.unwrap();
    println!("{}, {}", a, b);
}
task A done, task B done

Spawned tasks run independently. They can execute on any thread in the pool. The future passed to spawn must be Send + 'static — it cannot borrow from the caller's stack.

tokio::select!

select! waits on multiple futures simultaneously and executes the branch of whichever completes first. The remaining futures are dropped:

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

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

    // Simulate a message arriving after 200ms
    tokio::spawn(async move {
        sleep(Duration::from_millis(200)).await;
        tx.send("incoming message".into()).await.unwrap();
    });

    tokio::select! {
        msg = rx.recv() => {
            println!("Received: {:?}", msg);
        }
        _ = sleep(Duration::from_secs(1)) => {
            println!("Timed out waiting for message");
        }
    }
}
Received: Some("incoming message")

select! is essential for implementing timeouts, cancellation, and multiplexing multiple event sources.

Tokio Channels

Tokio provides async-aware channels that work across tasks:

mpsc (multi-producer, single-consumer)

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(100); // bounded, capacity 100

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

    drop(tx); // drop original so channel closes when spawned tasks finish

    while let Some(msg) = rx.recv().await {
        println!("{}", msg);
    }
}

oneshot (single value, single use)

use tokio::sync::oneshot;

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

    tokio::spawn(async move {
        let result = expensive_computation().await;
        tx.send(result).unwrap();
    });

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

async fn expensive_computation() -> u64 {
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    42
}
Got: 42

broadcast (multi-producer, multi-consumer)

broadcast::channel lets multiple receivers each get a copy of every message. Call tx.subscribe() to create new receivers. Useful for event notification and pub/sub patterns.

Timeouts

Wrap any future in a timeout:

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

#[tokio::main]
async fn main() {
    let result = timeout(
        Duration::from_millis(500),
        slow_operation(),
    ).await;

    match result {
        Ok(value) => println!("Got: {}", value),
        Err(_) => println!("Operation timed out"),
    }
}

async fn slow_operation() -> String {
    tokio::time::sleep(Duration::from_secs(2)).await;
    "done".into()
}
Operation timed out

Building a Simple Async Server

A TCP echo server demonstrates Tokio's core patterns:

use tokio::net::TcpListener;
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 127.0.0.1:8080");

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

        tokio::spawn(async move {
            let mut buf = [0u8; 1024];

            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(0) => return,  // connection closed
                    Ok(n) => n,
                    Err(e) => {
                        eprintln!("Read error: {}", e);
                        return;
                    }
                };

                if let Err(e) = socket.write_all(&buf[..n]).await {
                    eprintln!("Write error: {}", e);
                    return;
                }
            }
        });
    }
}

Each connection gets its own spawned task. The main loop accepts connections without blocking. This handles thousands of concurrent connections with a small thread pool.

Graceful Shutdown

Production servers need clean shutdown. The pattern: create a broadcast channel, subscribe each worker task, then send a shutdown signal on ctrl_c. Each worker uses select! to race its work loop against the shutdown receiver. When the signal arrives, workers finish their current unit of work and return. Use tokio::signal::ctrl_c().await to wait for the OS signal.

Backpressure

Bounded channels provide natural backpressure. When the channel is full, send().await suspends the producer until the consumer catches up. Use mpsc::channel(capacity) with a small capacity to prevent unbounded memory growth. The producer only proceeds when there is room in the buffer — no dropped messages, no OOM.

Don't Block the Runtime

The single most important Tokio rule: never call blocking operations inside async code. Blocking one thread in the pool starves all tasks scheduled on that thread:

// BAD: blocks the runtime thread
async fn bad_read() -> String {
    std::fs::read_to_string("large_file.txt").unwrap() // BLOCKS
}

// GOOD: offload to blocking thread pool
async fn good_read() -> String {
    tokio::task::spawn_blocking(|| {
        std::fs::read_to_string("large_file.txt").unwrap()
    })
    .await
    .unwrap()
}

// ALSO GOOD: use tokio's async file I/O
async fn also_good_read() -> String {
    tokio::fs::read_to_string("large_file.txt").await.unwrap()
}

Blocking operations include: synchronous file I/O, std::thread::sleep, CPU-heavy computation, and synchronous mutex locks held across await points.

Common Pitfalls

  • Blocking the runtime — the most common Tokio mistake. Use spawn_blocking for any operation that might take more than a few microseconds without yielding.
  • Unbounded channelsmpsc::unbounded_channel never applies backpressure. A slow consumer causes unbounded memory growth. Use bounded channels in production.
  • Forgetting to drop senders — if you clone a channel sender and forget to drop the original, the receiver never sees the channel close and hangs forever.
  • Holding MutexGuard across .awaitstd::sync::MutexGuard is not Send. Use tokio::sync::Mutex if you must hold a lock across await points, but prefer designing around it.
  • Too many spawned tasks — while cheaper than threads, spawning millions of tasks still has overhead. Batch work when possible.
  • Not handling JoinErrorhandle.await returns Result<T, JoinError>. The task might have panicked. Handle this in production code.

Key Takeaways

  • #[tokio::main] sets up the runtime. tokio::spawn creates concurrent tasks.
  • select! multiplexes multiple futures — essential for timeouts, cancellation, and event-driven code.
  • Use bounded channels for backpressure. Use oneshot for single-value responses. Use broadcast for pub/sub.
  • Implement graceful shutdown with a broadcast channel and select!.
  • Never block the runtime. Use spawn_blocking or Tokio's async equivalents for blocking operations.
  • Tokio is for I/O-bound concurrency. For CPU-bound work, offload to spawn_blocking or use Rayon.