Posted on

Most Rust developers meet Tokio through a single line, #[tokio::main], and never look past it. That's fine until something goes wrong: a service that mysteriously stalls under load, a task that never seems to run, or a benchmark that contradicts every claim about async Rust being fast. All three trace back to the same gap: not knowing what the runtime is actually doing between the await points in your code.

What #[tokio::main] Actually Expands Into

The macro is a code generator, not magic. It rewrites your async fn main() into a synchronous fn main() that builds a runtime and blocks on your future:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            // your original main body
        });
}

Builder::new_multi_thread() is the default because it's the safest general-purpose choice, but it's also the piece most tutorials skip past. Once you know it's there, the runtime stops being an implicit black box and becomes a normal object you can configure: worker thread count, thread naming, a custom panic handler, or a shutdown timeout, all through the same Builder.

Two Schedulers, Not One

Tokio ships two distinct schedulers, and picking the wrong one for your workload is the single most common source of "why is my async code slower than sync code" questions.

  • current_thread: a single-threaded executor. All tasks run on the thread that called block_on, cooperatively, with no work-stealing and no cross-thread synchronization overhead. Correct default for CLIs, low-concurrency tools, and anywhere you'd rather pay for a Mutex than a thread pool.
  • multi_thread: a work-stealing scheduler across N OS threads (defaults to the number of logical CPUs). Correct default for servers handling many concurrent connections, where you want CPU-bound and I/O-bound tasks spread across cores instead of queued behind each other.

The two aren't interchangeable performance-wise. A current_thread runtime handling thousands of idle-mostly connections is often faster than multi_thread, because there's no cross-thread task migration cost. A multi_thread runtime with only a handful of tasks wastes cycles on synchronization it doesn't need. Benchmark your actual workload before assuming more threads means more throughput.

Work-Stealing: What "Spawn" Actually Costs

tokio::spawn doesn't create an OS thread. It hands a Future to the scheduler, which stores it as a task on one of several per-worker run queues. Spawning is cheap, on the order of an allocation and a queue push, which is why idiomatic async Rust spawns thousands of tasks without a second thought.

The work-stealing part matters once you have a multi_thread runtime with uneven load: if one worker's queue empties out, it steals tasks from a busier worker's queue rather than sitting idle. This is what makes Tokio's multi-threaded scheduler competitive with hand-tuned thread pools, the load-balancing happens automatically instead of requiring you to reason about which thread handles which connection.

Rust Trends #71 covered Cloudflare's Pingora proxy, which leans on exactly this property: Tokio's async scheduling is what lets a single process handle Cloudflare's connection volume without a process-per-connection model like older nginx deployments used.

The Blocking Trap

This is the failure mode that catches even experienced Rust developers: calling a blocking function, std::fs::read, a synchronous database driver, a Mutex::lock held across an .await, from inside an async task. Because Tokio's scheduler is cooperative, a task that blocks its worker thread doesn't just block itself; it blocks every other task queued on that same worker until the blocking call returns.

Rust Trends #74 covered this exact bottleneck in production async Rust: code that looked correct and passed tests under low load, then stalled under concurrency because a blocking call was quietly starving the worker thread's task queue. The fix pattern is consistent:

  • CPU-bound or blocking I/O work goes through tokio::task::spawn_blocking, which runs it on a separate blocking thread pool instead of a scheduler worker thread.
  • Locks held across .await should be tokio::sync::Mutex, not std::sync::Mutex, or better, restructured so the lock is dropped before the await point.
  • Long CPU-bound loops without any .await should periodically yield with tokio::task::yield_now() if they can't be broken up, so the scheduler gets a chance to run other tasks.

Key insight: an async runtime doesn't make blocking calls non-blocking. It just gives you a very effective way to have one bad blocking call take down every other task sharing that worker thread.

A Minimal Runtime, Built by Hand

Seeing the pieces without the macro makes the mental model concrete:

fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)
        .thread_name("worker")
        .enable_io()
        .enable_time()
        .build()
        .expect("failed to build Tokio runtime");

    rt.block_on(async {
        let handle = tokio::spawn(async {
            println!("running on a worker thread");
        });
        handle.await.unwrap();
    });
}

enable_io() and enable_time() matter because Tokio's I/O and timer drivers aren't started by default. If you build a runtime by hand and skip them, TcpStream::connect or tokio::time::sleep will panic at runtime with a driver-not-enabled error, one of the more confusing early error messages in the ecosystem precisely because the macro hides that these are opt-in.

When You Don't Need Tokio At All

Not every async problem needs a multi-threaded work-stealing runtime. current_thread, async-std, and smol all cover the case of a small number of concurrent I/O operations without the overhead of a full thread pool. And a meaningful share of "should I use async here" questions have a simpler answer: if the work is CPU-bound rather than I/O-bound, a plain std::thread pool or rayon will outperform async Rust with less code, because there's no I/O to overlap in the first place. Async buys you concurrency during waiting, not general-purpose parallelism.

The runtime is doing real, visible work: scheduling tasks across threads, stealing work to balance load, and driving I/O and timers. Once that model is explicit instead of implicit behind a macro, most of the surprising performance and correctness issues in async Rust code stop being surprising.