The trap at the door
You write a function to fetch data from a URL. You mark it async because network I/O takes time. You paste the call into main and hit run. The compiler rejects you with E0728 (async fn in main is not supported). You try adding .await to the call. Now you get a "cannot await in a synchronous function" error. You're stuck. You have async code, but you can't run it. You need to understand the boundary between synchronous and asynchronous worlds.
await and block_on defined
await suspends the current async task and yields control back to the executor. It allows other tasks to run while waiting for I/O or computation. block_on takes a Future and runs it on the current thread until it completes. It blocks the thread. await is for writing non-blocking code inside async functions. block_on is for running async code from synchronous entry points.
use std::time::Duration;
fn main() {
// block_on bridges the gap: it runs the async block on this thread
// and refuses to return until the block finishes.
trpl::block_on(async {
println!("Starting task");
// await pauses this async block, yields to the executor,
// and resumes when the sleep is done.
trpl::sleep(Duration::from_millis(500)).await;
println!("Task complete");
});
}
In the Rust playground, trpl is a shim that provides a minimal runtime. In real projects, you'll see tokio::runtime::Runtime::new().block_on or the #[tokio::main] macro. The macro is the community standard for main because it hides the boilerplate. Write the macro. It expands to the correct block_on call behind the scenes.
The state machine behind await
When you write await, the compiler doesn't just pause the function. It rewrites the function into a state machine. Each await point becomes a state. The Future struct holds the local variables and the current state. When the executor polls the future, the state machine advances. If it hits await, it saves the state and returns Pending. Next time it's polled, it jumps back to the saved state and continues.
This is why await doesn't use a new stack frame. It reuses the heap allocation of the Future. The function's locals move into the Future struct. The compiler generates code to save and restore them across await points. Think of it like a video game save file. You save your position and variables. You pause. Later, you load the save and continue exactly where you left off. The thread never leaves; the task just yields control.
await is a save-and-restore mechanism. The thread never leaves; the task just yields control.
The Future trait
await works on types that implement Future. Future has one method: poll. poll takes a Context and returns Poll<T>. Poll is Ready(T) or Pending. The Context holds the Waker. The Waker tells the executor to poll the future again when the event happens.
When you call await, the compiler generates code to call poll, check the result, and if Pending, register the Waker and yield. The registration is crucial. It tells the executor, "When the network request finishes, wake me up." Without registration, the executor wouldn't know when to resume the task. The Waker is the signal wire between the I/O driver and the task.
You don't write poll or Waker code manually. The compiler and the runtime handle it. Understanding this helps you see why await is efficient. It's not a thread switch. It's a callback mechanism wrapped in clean syntax.
block_on: the bridge
block_on is simple but heavy. It creates a loop. It calls poll on the future. If the future returns Ready, block_on extracts the value and returns it. If the future returns Pending, block_on waits for events. It checks the event queue. If an event arrives, it wakes the corresponding task and polls it again. If no events arrive, block_on sleeps the thread until something happens.
block_on ties the thread to the future. If the future never completes, the thread is stuck forever. This is why block_on is called "blocking." It blocks the current thread until the async work is done. It's the entry point from sync to async. It's also the exit point. When block_on returns, the async world is cleaned up, and control goes back to synchronous code.
block_on is a sledgehammer. It freezes the thread. Use it only at the edges.
Realistic usage
You often need to call async code from a synchronous function. This happens in tests, callbacks, or library boundaries. You can't use await in a sync function. You use block_on to run the async work.
use std::time::Duration;
// A helper that fetches data.
async fn fetch_data() -> String {
// Simulate network delay.
trpl::sleep(Duration::from_millis(100)).await;
"Data".to_string()
}
// A synchronous function that needs the data.
fn process_sync() {
// We can't use .await here because this is not async.
// We use block_on to run the async work.
let data = trpl::block_on(fetch_data());
println!("Got: {}", data);
}
fn main() {
process_sync();
}
If you try to write fetch_data().await inside process_sync, the compiler rejects you with "cannot await in a synchronous function". await is syntactic sugar that only works inside async fn or async {} blocks. block_on is the escape hatch. It lets you run async code anywhere, as long as you have a runtime.
Pitfalls
Calling block_on inside an async function is a trap. Most runtimes will panic with "Cannot start a runtime within a runtime". You're trying to block a thread that the runtime is already using to drive tasks. You create a deadlock. The thread waits for the future, but the future can't make progress because the thread is blocked. The runtime can't poll other tasks because the thread is stuck in block_on. Everything freezes.
block_on blocks the current thread. If you call it on a thread that should be doing other work, you stall that work. In a multi-threaded runtime, you might have spare threads. But block_on doesn't know that. It blocks the thread you give it. If you call block_on on the main thread of a single-threaded runtime, you stop the entire application.
Never nest block_on. If you're in async land, use await. If you're in sync land, use block_on. Mixing them breaks the world.
Decision matrix
Use await when you are inside an async fn or async {} block and need to suspend execution while waiting for a result. Use block_on when you are in a synchronous context, such as main or a synchronous callback, and need to drive an async task to completion. Use block_on when writing unit tests for async functions and your test framework doesn't provide an async runner. Reach for tokio::spawn when you want to fire off async work in the background and continue doing other things without waiting.