Async Runtime: tokio
tokio is Rust's most widely used asynchronous runtime. It provides the executor that
drives async/await futures to completion, plus a rich ecosystem of async-native
utilities: I/O, timers, task scheduling, synchronisation primitives, and networking.
Rust's async model is poll-based and lazy — a future does nothing until something polls it. tokio is the engine that does the polling, running hundreds of thousands of lightweight tasks concurrently on a small thread pool.
Adding tokio to Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }full feature enables everything: I/O, timers, networking, sync primitives, and macros. In production you can trim this to only the features you use (e.g. features = ["rt-multi-thread", "macros", "time"]) to reduce compile times.#[tokio::main]
The #[tokio::main] attribute macro transforms an async fn main() into a
synchronous entry point by starting the tokio runtime and blocking until the future
completes. It is the standard way to enter async Rust.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("start");
sleep(Duration::from_millis(500)).await;
println!("done after 500ms");
}start done after 500ms
#[tokio::main] expands to roughly: fn main() { tokio::runtime::Runtime::new().unwrap().block_on(async { ... }) }. You can also build the runtime manually for more control over its configuration.tokio::spawn — Concurrent Tasks
tokio::spawn spawns a new async task that runs concurrently with the current task.
It returns a JoinHandle<T> that you can .await to get the result, similar
to std::thread::spawn but for async tasks.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(async {
sleep(Duration::from_millis(200)).await;
println!("task 1 done");
42
});
let handle2 = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("task 2 done");
99
});
// Both tasks run concurrently
let result1 = handle1.await.unwrap();
let result2 = handle2.await.unwrap();
println!("results: {} and {}", result1, result2);
}task 2 done task 1 done results: 42 and 99
'static and Send — they may be moved to a different thread in the pool. Use Arc to share data across spawned tasks.tokio::join! — Wait for All
tokio::join! runs multiple futures concurrently within the same task and waits until
all of them finish. Unlike spawning, join does not create new tasks — it interleaves
the futures on the current task, which is more efficient for small amounts of work.
use tokio::time::{sleep, Duration};
async fn fetch_users() -> Vec<String> {
sleep(Duration::from_millis(100)).await;
vec![String::from("alice"), String::from("bob")]
}
async fn fetch_config() -> String {
sleep(Duration::from_millis(80)).await;
String::from("config_v2")
}
#[tokio::main]
async fn main() {
// Both futures run concurrently — total time ≈ 100ms, not 180ms
let (users, config) = tokio::join!(fetch_users(), fetch_config());
println!("users: {:?}", users);
println!("config: {}", config);
}users: ["alice", "bob"] config: config_v2
tokio::select! — First One Wins
tokio::select! runs multiple futures concurrently and proceeds with whichever
completes first, cancelling the rest. This is useful for timeouts, racing alternative
data sources, or responding to a shutdown signal.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let slow = async {
sleep(Duration::from_secs(5)).await;
"slow result"
};
let fast = async {
sleep(Duration::from_millis(100)).await;
"fast result"
};
let winner = tokio::select! {
r = slow => r,
r = fast => r,
};
println!("winner: {}", winner);
}winner: fast result
Timer Utilities
The tokio::time module provides async-aware timer primitives that yield control back
to the runtime instead of blocking the thread.
use tokio::time::{sleep, interval, timeout, Duration};
#[tokio::main]
async fn main() {
// One-shot delay
sleep(Duration::from_millis(50)).await;
println!("slept 50ms");
// Repeating interval — tick every 200ms, print 3 times
let mut ticker = interval(Duration::from_millis(200));
for i in 0..3 {
ticker.tick().await; // first tick fires immediately
println!("tick {}", i);
}
// Timeout — cancel a future if it takes too long
let slow_op = async {
sleep(Duration::from_secs(10)).await;
"done"
};
match timeout(Duration::from_millis(100), slow_op).await {
Ok(val) => println!("got: {}", val),
Err(_) => println!("operation timed out"),
}
}slept 50ms tick 0 tick 1 tick 2 operation timed out
Async File I/O
tokio::fs mirrors std::fs but with async versions of every function. File
operations yield to the runtime while waiting, so other tasks can continue.
use tokio::fs;
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Write a file
let mut file = fs::File::create("hello.txt").await?;
file.write_all(b"Hello, tokio!
").await?;
// Read the whole file as a String
let contents = fs::read_to_string("hello.txt").await?;
println!("contents: {}", contents.trim());
// Clean up
fs::remove_file("hello.txt").await?;
Ok(())
}contents: Hello, tokio!
Async TCP Networking
tokio::net provides async TCP and UDP sockets. The pattern for a TCP server is:
bind a TcpListener, accept connections in a loop, and spawn a task per connection.
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn handle_connection(mut stream: TcpStream) {
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await.unwrap_or(0);
if n > 0 {
// Echo the data back
stream.write_all(&buf[..n]).await.ok();
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("listening on :8080");
loop {
let (stream, addr) = listener.accept().await?;
println!("connection from {}", addr);
tokio::spawn(handle_connection(stream));
}
}Synchronisation Primitives
tokio provides async-aware versions of the standard synchronisation types. Using
std::sync::Mutex inside async code works but will block the runtime thread while
waiting — always use tokio::sync equivalents instead.
Type | Use case |
|---|---|
tokio::sync::Mutex | Async-aware mutex — will not block the runtime thread while waiting |
tokio::sync::RwLock | Async read-write lock — many readers or one writer |
tokio::sync::oneshot | Send exactly one value between tasks |
tokio::sync::mpsc | Async multi-producer, single-consumer channel |
tokio::sync::broadcast | One sender, many receivers (fan-out) |
tokio::sync::Notify | Wake a waiting task without sending a value |
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
#[tokio::main]
async fn main() {
// --- Shared Mutex across tasks ---
let counter = Arc::new(Mutex::new(0u32));
let mut handles = vec![];
for _ in 0..5 {
let c = Arc::clone(&counter);
handles.push(tokio::spawn(async move {
let mut lock = c.lock().await;
*lock += 1;
}));
}
for h in handles { h.await.unwrap(); }
println!("counter: {}", *counter.lock().await);
// --- mpsc channel ---
let (tx, mut rx) = mpsc::channel::<String>(32);
tokio::spawn(async move {
tx.send(String::from("hello")).await.unwrap();
tx.send(String::from("world")).await.unwrap();
// tx dropped here — channel closes
});
while let Some(msg) = rx.recv().await {
println!("received: {}", msg);
}
}counter: 5 received: hello received: world
oneshot and broadcast
use tokio::sync::{oneshot, broadcast};
#[tokio::main]
async fn main() {
// oneshot — send one value from one task to another
let (tx, rx) = oneshot::channel::<u32>();
tokio::spawn(async move {
tx.send(42).ok();
});
println!("oneshot received: {}", rx.await.unwrap());
// broadcast — multiple receivers get every message
let (btx, mut brx1) = broadcast::channel::<String>(16);
let mut brx2 = btx.subscribe();
btx.send(String::from("broadcast message")).unwrap();
println!("rx1: {}", brx1.recv().await.unwrap());
println!("rx2: {}", brx2.recv().await.unwrap());
}oneshot received: 42 rx1: broadcast message rx2: broadcast message
spawn_blocking — Running CPU-Bound Work
The tokio runtime uses a small thread pool for async tasks. Long-running synchronous
(CPU-bound) work would block a thread and starve other tasks. Use
tokio::task::spawn_blocking to run blocking work on a dedicated thread pool that
does not interfere with the async executor.
#[tokio::main]
async fn main() {
println!("before blocking");
// Runs on a separate blocking thread pool
let result = tokio::task::spawn_blocking(|| {
// Simulate CPU-heavy work
let mut sum = 0u64;
for i in 0..1_000_000u64 {
sum += i;
}
sum
}).await.unwrap();
println!("sum: {}", result);
println!("after blocking");
}before blocking sum: 499999500000 after blocking
spawn_blocking: hashing, compression, synchronous database drivers, blocking file reads, and any third-party library that does not support async.Runtime Flavors
tokio supports two runtime configurations, selectable via the #[tokio::main] macro
or when constructing the runtime manually.
Flavor | Threads | Best for |
|---|---|---|
multi_thread (default) | One per CPU core | Production servers, high concurrency workloads |
current_thread | 1 (caller thread) | CLIs, tests, wasm, single-threaded environments |
// Multi-threaded (default) — spawns one thread per CPU core
#[tokio::main]
async fn main() { /* ... */ }
// Single-threaded — useful for tests and CLIs
#[tokio::main(flavor = "current_thread")]
async fn main() { /* ... */ }
// Manual runtime with custom config
fn main() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.unwrap();
rt.block_on(async {
println!("running on custom runtime");
});
}Common Mistake: Blocking the Runtime
std::thread::sleep or other blocking calls inside an async function. They block the runtime thread and prevent other tasks from progressing — the entire thread pool can grind to a halt.// WRONG — blocks the runtime thread
async fn bad_delay() {
std::thread::sleep(std::time::Duration::from_secs(1)); // blocks!
}
// CORRECT — yields control back to the runtime
async fn good_delay() {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
// CORRECT for blocking stdlib code — run it off the async thread pool
async fn read_blocking_file(path: &str) -> String {
let path = path.to_string();
tokio::task::spawn_blocking(move || {
std::fs::read_to_string(path).unwrap()
}).await.unwrap()
}Putting It Together — a Task Pipeline
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<u32>(100);
// Producer: sends 5 items then closes
let producer = tokio::spawn(async move {
for i in 0..5u32 {
sleep(Duration::from_millis(50)).await;
tx.send(i * i).await.unwrap();
println!("sent: {}", i * i);
}
// tx dropped — channel closes
});
// Consumer: processes until channel is closed
let consumer = tokio::spawn(async move {
while let Some(val) = rx.recv().await {
println!("processed: {}", val);
}
println!("channel closed");
});
tokio::join!(producer, consumer).0.unwrap();
}sent: 0 processed: 0 sent: 1 processed: 1 sent: 4 processed: 4 sent: 9 processed: 9 sent: 16 processed: 16 channel closed