Skip to content
intermediate Phase 13 · Async Rust with Tokio

The Tokio Runtime

Bootstrap async programs with tokio main and spawn tasks on the scheduler.

45m
0 problems
Topic Progress 0%

#[tokio::main] Is Builder::new_multi_thread().block_on()

async fn main is illegal on its own — the language has no idea who would poll the future you return. The #[tokio::main] attribute fixes that by rewriting your entry point into a synchronous main that builds a runtime and blocks on your async body:

#[tokio::main]
async fn main() { println!("hi"); }

// expands to roughly:
fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("hi");
        });
}

Two details in that expansion carry real consequences. enable_all() turns on the I/O and timer drivers, and new_multi_thread() decides the execution model. Build the runtime yourself and you control both:

let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(4)          // default: number of CPU cores
    .enable_all()               // io + time drivers
    .build()
    .unwrap();

rt.block_on(async {
    let handle = tokio::spawn(async { 21 * 2 });
    println!("{}", handle.await.unwrap());
});

block_on runs the future you pass it on the calling thread until it completes; tokio::spawned tasks are scheduled onto the worker threads. If you forget enable_all() (or enable_time()), the program compiles and then panics the first time a timer or socket is created — the runtime simply has no driver to register with.

The other half of the decision is the flavor:

Builder Threads running your tasks Use it when
new_multi_thread (the #[tokio::main] default) N workers, N = CPU cores by default Servers, anything that benefits from parallel polling
new_current_thread one thread: the one inside block_on CLI tools, embedded, tests, single-connection jobs
#[tokio::main(flavor = "current_thread")]
async fn main() { /* one thread runs everything */ }

Who polls what is worth fixing in memory, because every async bug eventually asks it:

Future Polled by Runs on
the body of async fn main block_on the thread that called block_on
tokio::spawn(..) tasks the scheduler worker threads (multi_thread) or that same thread (current_thread)
spawn_blocking(..) closures the blocking pool dedicated threads, capped at 512 by default
.awaited inline futures the enclosing future's poll wherever that task currently is

Two practical follow-ons. Inside async code you rarely build a runtime — you ask for the ambient one with tokio::runtime::Handle::current() (or Handle::try_current() when absence is legal), which is how you hand a runtime to a library that needs to spawn from a plain thread. And tests get their own entry point: #[tokio::test] builds a current_thread runtime per test, because a test wants deterministic single-threaded scheduling, not a pool.

The failure mode of nesting is explicit: calling block_on while already inside a runtime panics with "Cannot start a runtime from within a runtime" — the message explains that a function tried to block the thread that is busy driving asynchronous tasks. It is the same category mistake as std::thread::sleep in an async fn, one level up.

Whichever you pick, one rule is invariant: calling tokio::spawn or awaiting Tokio I/O outside a runtime panics. Run a plain fn main that spawns, or move work into std::thread::spawn and await there, and you get must be called from the context of a Tokio 1.x runtime. The runtime is thread-local state; it does not travel with the future. That is exactly the trap async fn and await sets up: a future is inert until a runtime polls it, and the runtime has to be present on that thread.

Interview question: "What does #[tokio::main] actually do?" Do not say "makes async work." Say: it generates a synchronous main that constructs a multi-threaded runtime with all drivers enabled and calls block_on on your async block — the async-ness ends there.

tokio::spawn, JoinHandles, and What Detached Really Means

tokio::spawn is how one task becomes several. It takes a future, hands it to the scheduler, and immediately returns a JoinHandle<T> — itself a future you can await for the output.

async fn serve() -> Result<(), Box<dyn std::error::Error>> {
    let user  = tokio::spawn(fetch_user(1));
    let orders = tokio::spawn(fetch_orders(1));

    let (user, orders) = tokio::try_join!(user, orders)?;
    println!("{user:?} {orders:?}");
    Ok(())
}

Spawning runs tasks on the runtime's worker threads, so the work overlaps in parallel, not just interleaved. The price is the bound: the future must be Future + Send + 'static.

Bound violated Diagnostic you see
not Send future cannot be sent between threads safely + note future is not Send as this value is used across an await
not 'static borrowed data escapes outside of function, or does not live long enough
not a Future expected future, found ... at the spawn call

'static is the one that surprises people: tokio::spawn does not borrow your stack, so a future capturing &request cannot be spawned — clone into the task (let url = url.clone(); tokio::spawn(async move { ... })) or take the value by move.

Now the handle semantics, which is where production bugs hide:

  • Awaiting the handle returns Result<T, JoinError>. JoinError::is_panic() means the task panicked — the panic was caught and stored, the process survives. is_cancelled() means it was aborted.
  • Dropping the handle detaches the task. It keeps running; you just lost the ability to see its output or its panic. Detaching is legal, but it should be a decision, not an accident.
  • handle.abort() requests cancellation. The task is dropped the next time it is polled — typically at its next .await. It does not stop mid-instruction, and a task in a tight loop with no .await keeps running until it yields.
let worker = tokio::spawn(long_job());
tokio::time::sleep(Duration::from_secs(1)).await;
worker.abort();
match worker.await {
    Err(e) if e.is_cancelled() => println!("aborted"),
    Ok(v) => println!("finished first: {v}"),
    other => println!("panicked: {other:?}"),
}

A spawned task does not start when you call spawn — it starts when a worker gets to it. On #[tokio::main(flavor = "current_thread")] that means a spawned task cannot run while the driving future is busy: a loop with no .await starves everything else on the runtime. Even on the multi-thread flavor, ten thousand spawns are ten thousand futures queued for polling, not ten thousand threads.

Two companion APIs round out the picture. JoinSet collects handles so you can wait for them as a stream — structured concurrency with an exit:

let mut set = tokio::task::JoinSet::new();
for id in ids { set.spawn(process(id)); }
while let Some(res) = set.join_next().await { res??; }   // all results, or the first error

and dropping the JoinSet aborts whatever is still running — no orphans. LocalSet is the escape hatch for futures that are not Send (a Rc, a RefCell, a !Sync handle): it runs spawn_localed futures on one thread, dropping the Send bound while keeping 'static. Pair it with the current_thread runtime — on a multi-thread runtime a LocalSet still needs its own driving .await, which surprises people who expect it to behave like a pool.

One more production habit: spawning is unbounded by default. Ten thousand spawn calls are ten thousand queued futures with no backpressure, so a burst of requests can out-memory your box before any of them runs. Bound the in-flight count with a tokio::sync::Semaphore (acquire_owned().await before spawn) or push work through a bounded mpsc channel and let the queue depth apply the brakes.

This is also why block_on(spawn(...))-style patterns matter less than understanding who polls what: .await runs a future inline as part of the current task; spawn gives it to the scheduler. Both drive futures — only one adds threads. The concurrency-vs-parallelism line is the same line join! and select! for Concurrent Futures walks from the other side.

Why One std::thread::sleep Inside an async Task Freezes Everything

Tokio's workers are OS threads with a fixed count — default available_parallelism(), i.e. your CPU cores. Each worker runs many tasks, and a task only stops running when it returns Pending at an .await. A blocking call inside an async fn never yields, so it occupies an entire worker for its whole duration.

async fn handle(id: usize) {
    std::thread::sleep(Duration::from_secs(2));     // blocks a worker: no yield
    tokio::time::sleep(Duration::from_secs(2)).await; // yields: timer fires elsewhere
}

Start eight handle(0..8) tasks on an 8-core machine and the first wave occupies every worker for two seconds; the second wave waits for a free worker. Total wall time stretches toward 16 seconds while a sleep — the most trivially non-blocking operation there is — is involved. In production the call is subtler: std::fs::read_to_string, a synchronous database client, serde_json::to_string on a 20 MB payload, a base64 decode. The symptom is a latency cliff: p50 looks fine because most requests avoid the hot path, p99 explodes because unlucky requests queue behind blocked workers.

The toolkit:

Work Use Runs on
std::thread::sleep tokio::time::sleep(..).await timer driver; task suspends
blocking read/parse/compute tokio::task::spawn_blocking(|| ..) dedicated blocking pool (cap 512 threads)
genuinely parallel CPU work spawn_blocking per unit, or rayon blocking pool / rayon threads
file system via tokio tokio::fs::read_to_string built on spawn_blocking
let raw = tokio::task::spawn_blocking(move || {
    std::fs::read_to_string(path)          // blocking is fine HERE
})
.await??;

spawn_blocking moves the closure off the worker pool entirely, so the scheduler keeps polling other tasks while it runs; it returns a JoinHandle just like spawn, and its task cannot be cancelled once the closure has started. There is also block_in_place, which keeps the work on the current worker by moving the rest of that worker's queue elsewhere — but it only exists on the multi-threaded flavor and panics under current_thread.

Blocking shows up in non-obvious costumes too. std::sync::mpsc::Receiver::recv() inside an async fn parks the worker thread until a message arrives — the classic mix-up, because the async version's call looks identical:

let (tx, rx) = std::sync::mpsc::channel();   // recv() blocks a worker
let (tx, rx) = tokio::sync::mpsc::channel(); // recv().await suspends the task

Same name, opposite behavior: the std channel has no waker to register, so it must block; the Tokio channel hands the waker to the sender side and returns Pending. The same split applies to locks (std::sync::Mutex::lock vs tokio::sync::Mutex::lock().await) and to DNS, file reads, and synchronous clients — anything without an .await in its signature is, by definition, blocking a thread somewhere.

Diagnosing it in production does not require a profiler at first: Runtime::metrics() (ready-queue depth, per-worker poll and idle counts) plus the tokio-console view of tasks and workers shows workers saturated while tasks sit ready. Then grep the hot path for calls with no .await in the expression that touch disk, network, or a std lock.

On the single-threaded runtime there is no escape hatch: one blocking call stalls the only thread. That is a real reason to choose new_current_thread only for I/O-light programs, and a real reason interviewers ask "how would you notice this in production?" — the answer is worker saturation: watch tokio::metrics / tokio-console, look for tasks ready but no idle workers, then grep the hot async fn for .await-less calls that touch the disk, the network, or a lock.

Rule of thumb to quote: any call in an async fn that can take more than a few milliseconds and contains no .await is a blocking call, whatever its signature says. For the laziness and suspension model these workers are built on, revisit async fn and await; for running several futures on those workers at once, continue to join! and select! for Concurrent Futures.

Quiz

1. What does `#[tokio::main]` on `async fn main` actually expand to?

Question 1 options

2. You drop the JoinHandle returned by tokio::spawn. What happens to the task?

Question 2 options

3. Eight tasks each call std::thread::sleep(2s) on an 8-core machine. What does the runtime experience?

Question 3 options

4. A manually built runtime calls tokio::time::sleep and panics immediately, though it was built with new_multi_thread().build(). What is missing?

Question 4 options

Flashcards

Question

What does #[tokio::main] generate, and what does block_on do in it?

Answer

It generates a synchronous fn main that constructs Builder::new_multi_thread().enable_all().build().unwrap() and calls block_on(async { ... }) on your async body. block_on drives that one future on the calling thread until Ready, while tokio::spawned tasks are polled by the runtime's worker threads.

Question

What bounds does tokio::spawn impose, and which diagnostics signal each violation?

Answer

Future + Send + 'static. Missing Send gives `future cannot be sent between threads safely` with a note that the future is not Send because a value is used across an await; missing 'static gives `borrowed data escapes outside of function` or `does not live long enough`; a non-future argument fails at the call itself.

Question

Drop vs abort on a JoinHandle — what is the difference?

Answer

Dropping the handle detaches: the task keeps running, its JoinHandle-based result and any panic become unobservable. abort() requests cancellation and the task's future is dropped at its next poll — usually the next .await — after which awaiting the handle yields Err with JoinError::is_cancelled().

Question

Why does std::thread::sleep inside an async fn stall a Tokio runtime, and what is the fix?

Answer

A worker thread only stops running a task at an await; a blocking call never yields, so the worker is occupied for its full duration and, once all workers are blocked, the whole runtime stops polling. Fix: use tokio::time::sleep for delays, or move the blocking work to tokio::task::spawn_blocking (or tokio::fs / a rayon for CPU work).

Question

When do you choose new_current_thread instead of the multi_thread runtime?

Answer

When the program is I/O-light and single-connection — CLI tools, embedded targets, tests — because one thread means zero worker-coordination overhead and deterministic scheduling. Costs: no parallel polling, a blocking call stalls everything, and block_in_place panics. Everything runs only while that one block_on thread drives it.

Revision Notes

Key Takeaways

  • 1. [tokio::main] is sugar for building a multi_thread runtime with all drivers enabled and calling block_on — the async entry point still needs a synchronous caller
  • 2. spawn requires Future + Send + 'static; dropping a JoinHandle detaches the task while abort() cancels it at the next await
  • 3. Blocking work inside an async fn occupies a worker thread entirely; N blocked workers means a fully frozen runtime
  • 4. spawn_blocking moves blocking work to a dedicated pool (default cap 512) so the scheduler keeps polling tasks
  • 5. Tokio context is thread-local: spawn or Tokio I/O from outside a runtime, or on a foreign thread, panics

Interview Tips

  • • "What does #[tokio::main] do?" — quote the expansion: Builder::new_multi_thread().enable_all().build().block_on(async { .. }), then note the flavor parameter swaps the builder
  • • "How do you run blocking code in async Rust?" — spawn_blocking for I/O and compute, tokio::time::sleep instead of thread::sleep, tokio::fs for files; mention block_in_place as the multi-thread-only middle ground and its panic on current_thread
  • • "What happens when a spawned task panics?" — the panic is caught, the JoinHandle yields Err(JoinError) with is_panic(); the process survives unless panic=abort, so you must await handles or install a supervisor to notice
  • • "How would you size the runtime?" — workers ≈ cores for CPU+I/O mixes, more workers for I/O-heavy loads; cap blocking threads for filesystem-heavy work; justify with the worker-starvation argument, not folklore

Cheat Sheet

The Tokio Runtime — Cheat Sheet

What #[tokio::main] generates:

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

Flavors:

new_multi_thread (default) new_current_thread
Threads N workers = CPU cores 1 (the block_on thread)
Parallelism yes none
block_in_place allowed panics
Best for servers CLI, embedded, tests

Task API:

let h = tokio::spawn(fut);   // needs Future + Send + 'static
h.await?;                     // Result<T, JoinError> — is_panic() / is_cancelled()
drop(h);                      // detaches: task keeps running, result lost
h.abort();                    // dropped at its next .await (not mid-instruction)

Blocking rules:

std::thread::sleep / std::fs / sync DB / heavy CPU  -> worker starves
tokio::time::sleep(...)                             -> suspends the task
tokio::task::spawn_blocking(|| ...)                 -> dedicated pool (default cap 512)
tokio::fs::*                                        -> spawn_blocking under the hood

Runtime presence: tokio::spawn or Tokio I/O outside a runtime panics with must be called from the context of a Tokio 1.x runtime; timers also need .enable_time() / .enable_all().

Continue Learning