tokio
23 Feb 2026Tokio is the default async runtime for Rust.
You can write async Rust without Tokio, but in practice a lot of the ecosystem assumes you have it: HTTP clients, servers, database drivers, RPC stacks, tracing integrations — the whole lot.
Tokio solves a very specific problem:
How do you run many async tasks efficiently, schedule them fairly, and provide the core building blocks (timers, I/O, synchronization) without forcing you to write an event loop by hand?
That’s what you’re buying when you add Tokio.
What Problem Does Tokio Solve?
Rust async gives you syntax and state machines.
It does not give you:
- a scheduler
- a reactor for I/O readiness
- timers
- async-aware synchronization primitives
Tokio provides that runtime layer, plus a big toolbox around it.
In other words:
Async Rust is “how to describe work”. Tokio is “how that work actually runs”.
Minimal Example: Spawn Tasks and Join Them
Let’s start with the most important primitive in Tokio:
tokio::spawn
Cargo.toml
[package]
name = "tokio_demo"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }main.rs
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let a = tokio::spawn(async {
sleep(Duration::from_millis(200)).await;
"task A finished"
});
let b = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
"task B finished"
});
// JoinHandle<T> is like std::thread::JoinHandle<T>, but for async tasks.
let ra = a.await.expect("task A panicked");
let rb = b.await.expect("task B panicked");
println!("{ra}");
println!("{rb}");
}You should see task B finished before task A finished.
That’s concurrency: two tasks progress while one is sleeping.
What’s Actually Happening?
Tokio tasks are lightweight, async “green threads”.
When you call tokio::spawn, the task begins running immediately on the runtime’s scheduler. Tokio returns a
JoinHandle<T>, which lets you await the task’s output.
The Part People Miss: Dropping a JoinHandle
A very important semantic detail:
If you drop a JoinHandle, the task is detached — it keeps running, but you’ve lost the ability to join it or get
its return value.
That’s different from how many people assume cancellation works.
So: keep handles if you care about results.
A Practical Pattern: Fan-Out + Collect Results
Here’s a simple pattern you’ll use constantly: spawn a bunch of work, then join it all.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for i in 0..5 {
handles.push(tokio::spawn(async move {
sleep(Duration::from_millis(50 * i)).await;
i * 2
}));
}
let mut results = Vec::new();
for h in handles {
results.push(h.await.expect("task panicked"));
}
println!("results: {results:?}");
}This is the async equivalent of “spawn threads, then join threads” — without paying thread-per-task costs.
Cancellation (The Tokio Way)
In Tokio, cancellation is cooperative.
A task cancels when:
- it observes some cancellation signal (channel closed, oneshot fired, etc), or
- it is explicitly aborted, or
- the runtime shuts down
If you want a simple cancellation mechanism, you can use channels and tokio::select!.
Example: worker runs until we send a stop signal.
use tokio::sync::oneshot;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let worker = tokio::spawn(async move {
tokio::select! {
_ = sleep(Duration::from_secs(10)) => {
println!("worker finished naturally");
}
_ = stop_rx => {
println!("worker received stop signal");
}
}
});
// Let it run briefly, then stop it.
sleep(Duration::from_millis(200)).await;
let _ = stop_tx.send(());
worker.await.expect("worker panicked");
}This is the pattern you’ll see everywhere:
select! between “normal work” and “shutdown”.
Where Tokio Fits
Tokio is a great fit for:
- network services
- CLI tools that do concurrent I/O (HTTP calls, filesystem, DB)
- anything that benefits from many concurrent tasks with bounded threads
Tokio is especially good when your program is I/O bound and wants high concurrency.
Where Tokio Does Not Fit (Or Needs Care)
Tokio is not a magic speed button.
If your workload is CPU bound, you need to be intentional:
- don’t block inside async tasks
- use
spawn_blockingor a dedicated thread pool for heavy CPU work
Tokio can orchestrate CPU work, but it can’t make “expensive compute” disappear.
Trade-offs
Pros
- Mature runtime and ecosystem
- Excellent performance for I/O-heavy workloads
- Good primitives: tasks, timers, channels, async sync
Cons
- It’s a big dependency (especially with
features = ["full"]) - Requires discipline around blocking calls
- Async stacks can make debugging control flow harder early on
Should You Use It?
If you’re building networked tools, concurrent I/O programs, or anything that leans on the modern Rust ecosystem:
Yes.
Tokio is the common runtime layer for a reason.
It gives you a scheduler, an I/O reactor, timers, and the primitives you’ll build everything else on top of.