Skip to Content
Course content

153: Tokio Runtime Configuration

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, you've probably been using #[tokio::main] to kick off your async functions. It's convenient, and for 90% of apps, it's all you need. But as you move into production environments—especially when you're dealing with constrained resources or very specific performance targets—you'll find that the default "one thread per CPU core" strategy isn't always the right move.

Do I really need to stop using the #[tokio::main] macro?

You don't have to, but the macro is essentially just a wrapper that builds a multi-threaded runtime with default settings. If you need to tweak how the runtime behaves, you have to step away from the macro and use the tokio::runtime::Builder.

I usually switch to the manual Builder when I'm writing a library that shouldn't dictate the runtime to the end-user, or when I'm building a service where I need to strictly limit resource consumption to play nice with Kubernetes CPU limits. Here is the basic pattern for replacing the macro:

use tokio::runtime::Builder;

fn main() {
    let rt = Builder::new_multi_thread()
        .worker_threads(4) // Explicitly set the number of threads
        .enable_all()      // Necessary to enable both I/O and Time drivers
        .build()
        .unwrap();

    rt.block_on(async {
        println!("Hello from a custom runtime!");
    });
}

How do I prevent the runtime from hogging every core on my machine?

By default, Tokio detects how many cores your machine has and spawns that many worker threads. If you're running a telemetry aggregator in a container restricted to 2 cores, but the underlying VM has 64, Tokio might try to spawn 64 threads. This leads to massive context-switching overhead and can actually tank your throughput.

You can use .worker_threads(n) to cap this. But there's a nuance here: this only affects the async worker threads. If you're using spawn_blocking for heavy disk I/O or synchronous database calls, those run on a separate pool of threads. If you find your app is stalling because too many blocking tasks are queued, you'll want to configure the blocking thread limit too:

let rt = Builder::new_multi_thread()
    .worker_threads(2) 
    .max_blocking_threads(512) // Allow more threads for synchronous I/O
    .enable_all()
    .build()
    .unwrap();

I've seen cases where increasing the blocking pool saved a service from death-spiraling during a database slowdown, while keeping the worker threads low kept the CPU cache happy.

When should I use a single-threaded runtime instead?

The current_thread runtime is often overlooked. It runs everything on the thread that called block_on. There's no work-stealing, no synchronization overhead between threads, and no need to wrap every single piece of state in an Arc<Mutex<T>> if you're using Rc and RefCell (though you still need to be careful with async boundaries).

You'll want this for:

  • Small CLI tools where the overhead of spawning a thread pool is larger than the actual work.
  • WASM targets, where multi-threading is either unsupported or requires complex setup.
  • Highly specialized low-latency loops where you want total control over which core the code executes on.
Here is how you set that up:
let rt = Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();

Just remember: if you use the current-thread runtime and you accidentally call a long-running synchronous function inside an async block, you've just frozen your entire application. There are no other workers to pick up the slack.




📋 Practical Task

Exercise: Tuning a Telemetry Collector's Worker Pool

You are building a telemetry collector that runs on a very small sidecar container. To ensure it doesn't interfere with the main application, you must strictly limit its resource usage. Your task is to replace the #[tokio::main] macro in the provided logic with a manual tokio::runtime::Builder configuration.

Requirements:

  • The runtime must use the multi_thread scheduler.
  • The number of worker threads must be exactly 2.
  • The maximum number of blocking threads must be set to 100.
  • Both the I/O and Time drivers must be enabled.
  • The program should execute the run_collector async function.
// Use this as your starting point
async fn run_collector() {
    println!("Collector is running on a tuned runtime...");
}

fn main() {
    // Your implementation here:
    // Replace the macro logic with a Builder
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.