The bridge between sync and async
You are writing a CLI tool that needs to fetch configuration from a URL. You look at the documentation for your HTTP client and see the function is marked async. You add async to your own function, call .await on the request, and try to run it from main.
The compiler rejects you. main is synchronous. Your function returns a Future. You have a recipe, but you don't have a kitchen. Rust won't execute the recipe until you provide a runtime to drive it.
Converting between sync and async code is about managing that boundary. You cannot call async code directly from sync code, and you cannot block async code with sync operations without risking the entire system. The bridge requires explicit coordination.
What async really is
Async in Rust is not magic threading. It is cooperative multitasking built on state machines.
When you write an async fn, the compiler rewrites it into a struct that implements the Future trait. This struct holds the local variables and the current state of execution. It does not run when you call the function. It returns a Future value.
Think of a Future like a paused video game. The game state exists, but nothing is happening until you press "continue." In Rust, the runtime is the player pressing "continue." The runtime calls the poll method on the Future. The Future does a little bit of work, checks if it can finish, and returns either Poll::Ready(value) or Poll::Pending.
If it returns Pending, the runtime puts the Future aside and works on something else. Later, when an event happens (like data arriving on a socket), the runtime wakes the Future back up and polls it again. This loop continues until the Future returns Ready.
The .await keyword is syntactic sugar for this polling loop. It suspends the current function, saves its state, and tells the runtime to resume here when the awaited value is ready.
Running async from sync
You cannot .await in a synchronous function. Sync functions run to completion without yielding. To run async code from sync code, you need a runtime to poll the Future until it finishes.
The standard way to do this is block_on. This method takes a Future, polls it repeatedly on the current thread, and blocks the thread until the Future completes. It bridges the gap by turning an async operation into a synchronous one.
use std::time::Duration;
/// Simulates an async operation that takes time.
async fn fetch_data() -> String {
// Yield to the runtime.
// In real code, this would be an HTTP request or DB query.
tokio::time::sleep(Duration::from_millis(100)).await;
"Data loaded".to_string()
}
fn main() {
// main is synchronous. It cannot use .await.
// Create a runtime to execute the Future.
let rt = tokio::runtime::Runtime::new().unwrap();
// block_on drives the Future to completion.
// This call blocks the main thread until fetch_data returns.
let result = rt.block_on(fetch_data());
println!("{}", result);
}
Convention aside: In binary crates, you rarely write Runtime::new().block_on manually. The community standard is to use the #[tokio::main] attribute, which generates the runtime setup and converts main into an async function automatically. Use block_on when you are writing a library that needs to expose a sync API, or when you need fine-grained control over the runtime lifecycle.
The runtime is not the OS
Operating systems schedule threads. They switch between threads when a thread blocks or when the time slice expires. The OS handles this transparently.
Rust runtimes schedule tasks. A task is a Future. The runtime maintains a pool of OS threads and runs many tasks on each thread. When a task hits .await, it yields control back to the runtime. The runtime then picks another task to run on that thread. This allows thousands of async tasks to run concurrently on just a few OS threads.
This distinction matters when you cross the sync/async boundary. If you block an OS thread inside an async task, you block every other task running on that thread. The runtime cannot switch away because the thread is stuck. You effectively reduce your async system to a single-threaded bottleneck.
Calling sync from async
The reverse direction is trickier. You are inside an async function, and you need to call a function that is synchronous.
If the sync function is fast and does not block, you can usually call it directly. A hash calculation, a string manipulation, or a small math operation will finish quickly and won't starve the runtime.
If the sync function blocks, you must isolate it. Blocking includes file I/O that doesn't support async, database drivers that are sync-only, or heavy CPU computations that take more than a few milliseconds. Calling these directly will freeze the executor.
Use spawn_blocking to offload blocking work to a separate thread pool. The runtime manages this pool specifically for blocking tasks. It ensures that blocking operations do not consume threads from the async task pool.
use std::thread;
use std::time::Duration;
/// A legacy function that blocks.
/// It simulates heavy CPU work or blocking I/O.
fn heavy_computation() -> String {
thread::sleep(Duration::from_millis(500));
"Computed".to_string()
}
async fn process_data() -> String {
// spawn_blocking moves the closure to a blocking thread pool.
// The async function yields while the blocking work runs.
let handle = tokio::task::spawn_blocking(|| {
heavy_computation()
});
// Await the JoinHandle to get the result.
// This does not block the async executor.
let result = handle.await.unwrap();
format!("Result: {}", result)
}
Convention aside: spawn_blocking requires the closure to be Send and 'static by default. This means the closure cannot capture references to local variables that might be dropped. If you need to pass data, move it into the closure or use owned types. The compiler will enforce this with lifetime errors if you try to capture a reference incorrectly.
Pitfalls and compiler errors
Crossing the sync/async boundary introduces specific failure modes. The compiler catches some, but runtime behavior catches others.
If you mark main as async without a runtime attribute, the compiler rejects you with E0752 (main function is not allowed to be async). The fix is to add #[tokio::main] or another runtime macro.
If you call an async function from sync code and forget to run it on a runtime, you get a type mismatch. The function returns a Future, but you expect a concrete value. The compiler will show E0308 (mismatched types) or complain that the Future trait is not implemented for the expected type. You cannot just call the function; you must drive the Future.
If you try to call block_on from inside an async function, you will panic at runtime. block_on tries to create a nested runtime context. Most runtimes forbid this because it leads to deadlocks and resource exhaustion. The error message will tell you that you are calling block_on from within an async context. Use spawn_blocking instead.
If you forget .await on an async call, the compiler warns you. You are creating a Future and dropping it immediately. The work never happens. The warning looks like warning: unused implementer of Future. Always await async calls unless you intentionally want to fire-and-forget, in which case you should use tokio::spawn to keep the task alive.
Decision matrix
Use #[tokio::main] when you are writing a binary and want the simplest setup for an async entry point.
Use Runtime::block_on when you are writing a library that must expose a synchronous API, or when you need to manage the runtime lifecycle explicitly in a test or utility function.
Use tokio::task::spawn_blocking when you must call a blocking function from async code, such as a CPU-bound calculation, a legacy synchronous library, or blocking file I/O.
Use tokio::spawn when you want to run an async task concurrently and do not need to wait for the result immediately, or when you need to detach a task from the current scope.
Use direct calls for synchronous functions that are fast and non-blocking, such as arithmetic, string manipulation, or hash calculations.
Avoid calling blocking code directly in an async function; it starves the executor and kills throughput.