Tokio Runtime
TL;DR Tokio's multi_thread runtime runs a fixed pool of OS threads. Each thread has a local task queue. When a thread runs out of work, it steals tasks from other threads. I/O events are delivered via epoll/kqueue/IOCP, not busy-polling.
Runtime Flavors
// Multi-thread (default for servers) — N OS threads, work-stealing
#[tokio::main]
async fn main() { ... }
// Equivalent explicit construction:
tokio::runtime::Builder::new_multi_thread()
.worker_threads(num_cpus::get())
.enable_all()
.build()
.unwrap()
.block_on(async { ... });
// Current-thread — single OS thread, good for tests and CLI tools
#[tokio::main(flavor = "current_thread")]
async fn main() { ... }
Task Scheduling
Each worker thread has:
- A local run queue (LIFO) — fast path for newly spawned tasks
- A global injection queue — external tasks go here first
- A steal queue — half-open ring buffer that other threads can steal from
When a thread's local queue is empty, it:
- Checks the global queue
- Steals from a random other thread's steal queue
- Polls for I/O events
The LIFO local queue gives cache-friendly locality: a task that just unblocked typically runs on the same thread that woke it.
I/O Driver
Tokio wraps mio, which wraps OS-level async I/O:
Linux: epoll
macOS: kqueue
Windows: IOCP
When you call TcpStream::read().await, Tokio registers interest with the I/O driver. The task is parked. When the OS signals readiness, Tokio wakes the task's Waker, which queues it for polling.
Timer Wheel
Tokio implements timers using a hierarchical timing wheel. tokio::time::sleep doesn't spawn a thread or use OS timers — it registers a deadline in the wheel. The I/O thread checks expired timers each tick.
tokio::time::sleep(Duration::from_millis(100)).await;
// efficient: no thread created, no system call until expiry
Blocking Work
CPU-bound or blocking syscalls block the current OS thread, starving other tasks. Use the blocking pool:
let result = tokio::task::spawn_blocking(|| {
// runs on a separate thread pool (default: 512 max threads)
std::fs::read_to_string("big_file.txt")
}).await?;
spawn_blocking threads are distinct from worker threads. They're created on demand, reused, and capped by max_blocking_threads (default 512).
Metrics and Tuning
tokio::runtime::Builder::new_multi_thread()
.worker_threads(4) // default: num CPUs
.max_blocking_threads(128) // default: 512
.thread_name("adarsh-worker")
.on_thread_park(|| {}) // hook when thread parks
.enable_all()
.build()?;
For observability: tokio-console provides a live view of task states, poll times, and waker activity.
[dependencies]
console-subscriber = "0.4"
// in main, before runtime starts
console_subscriber::init();
Common Mistakes
Spawning too many tasks for CPU-bound work — tasks are cheap but not free. For CPU work, use rayon or spawn_blocking and join a fixed number of threads.
Not sizing the blocking pool — the default 512 means 512 simultaneous blocking threads. If you have a tight blocking loop, cap it.
select! without cancellation safety — cancelling a branch in select! drops the future. If the future holds state across yields (e.g., partially-written buffer), this is a bug. Check docs for cancellation safety.