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.
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:
- 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.
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.
