The engine behind async Rust
You're building a chat server. One user connects, you spawn a thread. Ten users, ten threads. Fine. A thousand users, a thousand threads. The operating system starts thrashing context switches, memory balloons, and your server chokes. You don't need a thousand threads. You need a way to pause a task when it's waiting for network data and jump to another task that has work to do. That's the job of the Tokio runtime.
Tokio is the most popular async runtime for Rust. It provides the execution engine that manages your asynchronous tasks. It handles the scheduling, the thread pool, and the communication with the operating system. Without a runtime, async functions in Rust are just static data structures. The runtime brings them to life.
How the runtime manages your tasks
Think of the runtime as a single chef in a busy kitchen. The chef has one pair of hands but manages dozens of dishes. When a steak goes on the grill, the chef doesn't stand there staring at it. The chef walks away to chop vegetables for another order. A timer tells the chef when the steak is ready. The chef jumps back to the steak, flips it, and moves on.
The runtime does this with your code. It runs a task until that task hits an await point, like waiting for a database response or a network packet. The runtime pauses that task, saves its state, and switches to another task that is ready to run. When the database responds, the runtime wakes the paused task back up. This allows a single thread to handle thousands of concurrent connections. The CPU stays busy doing work instead of waiting for I/O.
Minimal setup
The standard way to start a Tokio runtime is the #[tokio::main] macro. This macro transforms your async fn main into a standard fn main that sets up the runtime and blocks the main thread until your async code finishes.
// The #[tokio::main] macro expands this function.
// It creates a multi-threaded runtime and blocks the main thread.
#[tokio::main]
async fn main() {
// Code here runs as a task within the runtime.
// You can use `await` to pause and resume execution.
println!("Running inside the Tokio runtime");
}
The macro hides the boilerplate. Under the hood, it builds a Runtime object, starts the event loop, and calls block_on on your future. For most binaries, this is all you need. The community convention is to use the macro for application entry points. Reserve manual runtime construction for tests or libraries where you need to control the runtime lifecycle.
Inside the runtime: executor and reactor
The runtime has two main parts: the executor and the reactor. They work together to keep your tasks moving.
The executor manages the thread pool and runs tasks. It holds a queue of futures for each worker thread. When a worker thread is free, it pops a future from the queue and polls it. Polling a future asks it to make progress. If the future can finish immediately, it returns Poll::Ready. If it needs to wait, it returns Poll::Pending and registers a waker. The waker tells the executor how to wake this task up later. The worker thread then moves to the next task.
The reactor talks to the operating system. When you create a TCP listener or a timer, the reactor registers the resource with the OS event loop. On Linux, this uses epoll. On macOS, it uses kqueue. The reactor stores the registration and associates it with a task. When data arrives on a socket, the OS notifies the reactor. The reactor uses the waker to push the associated task back onto the executor's queue. The executor wakes the task, and it resumes from the await point.
This separation lets the runtime scale. One reactor can serve multiple worker threads. The reactor handles I/O notifications, and the executor distributes the work. The reactor listens. The executor runs. Together they keep the CPU busy and the latency low.
Realistic example: concurrent requests
Here's a closer look at how tasks run concurrently. This example spawns multiple tasks to simulate fetching data. Each task sleeps to mimic network delay. The runtime switches between them so they all finish in roughly one second, not three.
use tokio::task;
/// Simulates a network request that takes time.
/// Returns a message when the simulated request completes.
async fn simulate_request(id: u32) -> String {
// Sleep yields control to the runtime.
// Other tasks can run while this one waits.
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
format!("Request {} completed", id)
}
#[tokio::main]
async fn main() {
// Spawn three independent tasks.
// They run concurrently on the runtime's thread pool.
let handle1 = task::spawn(simulate_request(1));
let handle2 = task::spawn(simulate_request(2));
let handle3 = task::spawn(simulate_request(3));
// Wait for all tasks to finish.
// join! pauses main until all handles resolve.
let (res1, res2, res3) = tokio::join!(handle1, handle2, handle3);
// Unwrap the results.
// spawn returns a JoinHandle<Result<T, JoinError>>.
println!("{:?}", res1.unwrap());
println!("{:?}", res2.unwrap());
println!("{:?}", res3.unwrap());
}
The tokio::spawn function takes a future and returns a JoinHandle. The handle lets you wait for the task to finish or cancel it. The tokio::join! macro waits for multiple futures concurrently. It inlines the waiting logic without spawning extra tasks. This is efficient for a small, fixed set of futures.
Pitfalls and compiler errors
The runtime gives you high concurrency, but it requires discipline. The biggest mistake is blocking the runtime thread. If you put a heavy CPU loop or a blocking I/O call inside an async task, you hold the worker thread hostage. The runtime can't switch to other tasks. Your latency spikes, and throughput drops. The compiler won't stop you from doing CPU work. It stops you from moving non-thread-safe data across tasks.
Tasks can migrate between worker threads. If a task moves from thread A to thread B, any data it holds must be safe to move. The runtime requires tasks to be Send. Send is a trait that guarantees the data can be transferred across thread boundaries. If you try to spawn a task with an Rc, the compiler rejects it with E0277 (trait bound not satisfied). Rc is not Send because its reference count isn't thread-safe. Use Arc instead. Arc uses atomic operations for the count, so it is Send.
Another trap is calling block_on from inside an async task. This causes a deadlock. The task holds the worker thread and tries to run the runtime on the same thread. The runtime waits for the task to finish. The task waits for the runtime. Nothing moves. Tokio detects this in debug mode and panics. In release mode, it might hang silently. Never call block_on inside async code. If you must run blocking code, use tokio::task::block_in_place. This tells the runtime to park the current worker thread and spawn a replacement. The parked thread runs the blocking code. When done, it unparks and rejoins the pool. This prevents the blocking code from starving other tasks.
Decision matrix
Use #[tokio::main] when you are writing a binary and want the simplest setup. The macro handles runtime creation and blocking the main thread automatically.
Use Runtime::new() when you need to configure the runtime, like setting the number of worker threads or enabling the I/O driver manually. This is useful for libraries or testing where you need control over the runtime lifecycle.
Use tokio::spawn when you want to run a task concurrently and detach it from the current flow. The task runs independently and you can check its result later via the JoinHandle.
Use tokio::task::block_in_place when you must run blocking code inside an async task. This tells the runtime to park the current worker thread and spawn a replacement, preventing the blocking code from starving other tasks.
Use tokio::join! when you need to wait for a small, fixed set of futures to complete. It inlines the waiting logic without spawning extra tasks.
Use tokio::select! when you want to race multiple futures and proceed as soon as one finishes. This is the pattern for timeouts, cancellation, and multiplexing streams.
Pick the tool that matches the concurrency pattern. The runtime scales when you let it switch tasks.