Tokio: Async Runtime for Rust
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:
- Multi-thread (default): Full thread pool, work stealing, best for servers
- Current-thread: Single thread, better for embedded or testing
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.