---
title: How to Set Up the Tokio Runtime in Rust
summary: Set up the Tokio runtime in Rust by using the trpl::block_on helper to execute async functions.
lang: en
slug: how-to-set-up-the-tokio-runtime-in-rust
published: 2026-04-17T14:12:14+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-set-up-the-tokio-runtime-in-rust/
---

# How to Set Up the Tokio Runtime in Rust



## The async gap

You write an `async fn fetch_config()` that pauses while waiting for a network response. You try to call it from `main`, and the compiler immediately stops you. Rust's `main` function cannot be async. It needs a synchronous entry point to hand control to the operating system. Your async code needs a driver. That driver is the Tokio runtime.

Async functions do not run themselves. They produce futures, which are just state machines waiting to be polled. Think of a future like a pause button on a video game. The game does not advance until something presses play. The Tokio runtime is that play button. It sits in a tight loop, checking every active future to see if it is ready to make progress. If a future is waiting on I/O, the runtime parks it and moves to the next one. When the operating system signals that the network request finished, the runtime wakes that future back up. You get concurrency without paying for threads.

## How the runtime actually works

Tokio is not a magic box. It is a scheduler, an I/O driver, and a timer driver glued together. When you create a runtime, it allocates a work-stealing scheduler. The scheduler spins up a thread pool sized to your CPU cores. Each thread maintains a local queue of ready futures. The scheduler also sets up an event loop that talks directly to the operating system's I/O notification interface. On Linux this is epoll. On macOS it is kqueue. On Windows it is IOCP.

When you hand a future to the runtime, it gets wrapped in a task. The task contains the future, a waker, and a pointer to the scheduler. The waker is a small struct that knows how to notify the scheduler when the future becomes ready. If your future is waiting on a TCP socket, the runtime registers a callback with the OS. When data arrives, the OS triggers the callback, which calls the waker. The waker pushes the task back into a ready queue. The next time a worker thread looks for work, it pulls that task and calls `poll()` on the future.

The work-stealing algorithm keeps load balanced. If one thread finishes its local queue, it reaches into another thread's queue and steals half the tasks. You never manually assign futures to threads. The runtime handles distribution automatically. This design gives you high throughput with minimal overhead.

Convention aside: Tokio ships with feature flags. The `full` flag enables everything. Production crates usually pick `rt`, `rt-multi-thread`, and `macros` to keep compile times down. Check your `Cargo.toml` dependencies. You only pay for what you import.

## The standard binary setup

The fastest way to start is the `#[tokio::main]` attribute macro. It replaces your synchronous `main` with a runtime setup and a call to `block_on`.

```rust
#[tokio::main]
async fn main() {
    // The macro expands this into a synchronous main that starts a runtime.
    // It hands your async block to the scheduler and waits for completion.
    println!("Hello from Tokio!");
}
```

Under the hood, the macro generates code that looks like this:

```rust
fn main() {
    // Create a new multi-threaded runtime with default settings.
    // The builder uses CPU core count for the worker pool size.
    let rt = tokio::runtime::Runtime::new().unwrap();
    
    // block_on runs the async block to completion on the current thread.
    // It blocks the main thread until the future returns Poll::Ready.
    rt.block_on(async {
        println!("Hello from Tokio!");
    });
}
```

The macro is the community standard for binaries. It keeps boilerplate out of sight and matches every tutorial you will read. You do not need to memorize the expansion. You just need to know that `#[tokio::main]` gives you a multi-threaded scheduler and a blocking entry point.

Trust the macro for your entry point. It handles the heavy lifting correctly.

## Library and test setup

Binaries use the macro. Libraries cannot. A library cannot assume it controls the process lifecycle. If you write a crate that needs async execution, you must accept a runtime or spawn tasks on an existing one. Creating a second runtime inside a library causes resource duplication and scheduler conflicts.

```rust
use tokio::runtime::Runtime;

/// Initialize the database connection pool.
/// Returns the pool handle or an error if the runtime fails to start.
pub fn init_db_pool() -> Result<DatabasePool, Box<dyn std::error::Error>> {
    // Libraries should not force a multi-threaded scheduler.
    // Creating a runtime here is a fallback for standalone usage.
    let rt = Runtime::new()?;
    
    // block_on runs the async setup on the current thread.
    // This prevents thread starvation in library contexts.
    rt.block_on(async {
        // Simulate async database initialization.
        // The sleep yields to the runtime, proving the loop is active.
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        Ok(DatabasePool::new())
    })
}
```

When you are inside an already running application, you should grab a handle instead of building a new runtime. The handle gives you access to the existing scheduler without duplicating resources.

```rust
use tokio::runtime::Handle;

/// Spawn a background health check on the current runtime.
/// Returns a JoinHandle so the caller can track completion.
pub fn start_health_check(handle: &Handle) -> tokio::task::JoinHandle<()> {
    // Handle::spawn attaches the task to the existing scheduler.
    // It does not block the calling thread.
    handle.spawn(async {
        loop {
            // Check service status every five seconds.
            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
            println!("Health check passed.");
        }
    })
}
```

Convention aside: always pass `&Handle` to library functions that need to spawn tasks. It makes your crate compatible with any Tokio application. Do not force callers to create their own runtimes.

Keep your library async boundaries clean. Accept handles, not runtimes.

## What happens when things go wrong

The most common mistake is calling `block_on` from inside an already running runtime. Tokio detects this and panics. The runtime uses thread-local state to track active contexts. Nesting `block_on` creates a deadlock because the inner call tries to drive the scheduler while the outer call already holds the steering wheel.

If you trigger this, you get a panic with the message `Cannot start a runtime from within a runtime`. The stack trace points directly to the nested call. Fix it by switching to `Handle::block_on` or restructuring your code so async boundaries do not overlap.

Another frequent issue involves `Send` bounds. The default multi-threaded scheduler moves futures between worker threads. Any future you spawn must be `Send`. If you capture a `Rc` or a raw pointer in a closure and try to spawn it, the compiler rejects you with `E0277` (trait bound not satisfied). The error message explicitly states that the closure cannot be sent between threads safely. Switch to `Arc` or isolate the non-`Send` data in a `current_thread` runtime.

You will also see `E0308` (mismatched types) if you forget to `.await` a future. Rust treats futures as lazy. Calling `fetch_data()` without `.await` returns a future struct, not the result. The compiler complains because you are trying to use a future where a concrete type is expected. Add the `.await` keyword to hand control back to the runtime.

Memory leaks happen when you drop a `JoinHandle` without awaiting it. The task continues running in the background until it finishes or the process exits. If you need to cancel a task, call `abort()` on the handle. The runtime will drop the future and clean up its resources.

Never ignore `JoinHandle` results. Abort them if you do not need the output.

## Choosing your runtime configuration

Tokio gives you control over the scheduler shape. The defaults work for ninety percent of workloads. You only need to change settings when profiling shows a bottleneck or when your architecture has strict constraints.

Use `#[tokio::main]` when you are writing a binary and want the standard multi-threaded scheduler with zero boilerplate. Use `Runtime::new()` when you are building a library or a test harness that needs to create its own isolated execution context. Use `Runtime::builder().worker_threads(1).build()` when you need a single-threaded scheduler for deterministic testing or when your workload cannot be shared across threads. Use `Handle::block_on` when you are inside an async context but need to call a synchronous blocking function that cannot be made async. Use `Handle::spawn` when you want to detach a long-running task from the current future's lifetime. Reach for `current_thread` runtime when you are writing a CLI tool that does heavy CPU work and wants to avoid thread scheduling overhead.

The scheduler configuration is a tuning knob, not a magic fix. Profile first. Change settings only when the data tells you to.

## Where to go next

- [How to Profile Async Rust Applications (tokio-console)](/en/how-to-profile-async-rust-applications-tokio-console/)
- [How to use tokio mpsc channel](/en/how-to-use-tokio-mpsc-channel/)
- [How to Use tokio::spawn for Spawning Async Tasks](/en/how-to-use-tokiospawn-for-spawning-async-tasks/)