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_blockingfor any operation that might take more than a few microseconds without yielding. - Unbounded channels —
mpsc::unbounded_channelnever 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
MutexGuardacross.await—std::sync::MutexGuardis notSend. Usetokio::sync::Mutexif 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
JoinError—handle.awaitreturnsResult<T, JoinError>. The task might have panicked. Handle this in production code.
Key Takeaways
#[tokio::main]sets up the runtime.tokio::spawncreates concurrent tasks.select!multiplexes multiple futures — essential for timeouts, cancellation, and event-driven code.- Use bounded channels for backpressure. Use
oneshotfor single-value responses. Usebroadcastfor pub/sub. - Implement graceful shutdown with a broadcast channel and
select!. - Never block the runtime. Use
spawn_blockingor Tokio's async equivalents for blocking operations. - Tokio is for I/O-bound concurrency. For CPU-bound work, offload to
spawn_blockingor use Rayon.