Skip to content
← All Rust articles

Rust explainer

Choosing between blocking and async code

About 6 min to read. Allow extra time to work through examples.

Understand the boundary between blocking and async APIs, what cancellation does, and how to choose an approach for your workload.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Async lets a task give up its execution thread while it waits, so other tasks can make progress. That can be useful for many concurrent I/O operations. It also introduces choices about executors, cancellation and the boundary between synchronous and asynchronous code.

The phrase function colouring describes that boundary: code that needs to await an asynchronous result usually requires an async interface of its own. The distinction can spread through callers, even when their main job has little to do with concurrency.

Calling an async function is not the same as awaiting it

In Rust, synchronous code can call an async fn. The call creates a future; it does not run the function body to completion. An executor must poll that future to make progress. .await is available inside async functions and async blocks, and can suspend the enclosing future while a result is pending.

So the boundary is not "synchronous code cannot call async code". It is how that synchronous caller obtains the eventual result. At an application boundary, a runtime's block_on can drive a future while blocking the caller. Inside an executor worker, doing that can be invalid or prevent other work from progressing. Tokio's Runtime::block_on, for example, panics when called from an asynchronous execution context. Check the particular runtime's API; not every executor has identical restrictions.

Where the boundary costs effort

Blocking and asynchronous I/O interfaces often need different implementations and adapters. A synchronous read returns its result before the call ends; an asynchronous read may return control while waiting. Libraries may expose both interfaces, which increases the surface users and maintainers must learn.

Ordinary Drop::drop is synchronous. If a resource requires asynchronous cleanup, an explicit close or shutdown method may need to be awaited before dropping it. Starting a detached cleanup task does not guarantee that cleanup finishes before the process or runtime exits.

Native async fn in traits also does not automatically make a trait usable through dyn Trait. Dynamic dispatch may require a different interface, such as a method returning a boxed future. These are API-design questions, not just syntax changes.

What Rust's explicit futures provide

Rust does not attach a built-in scheduler to every async function. A future is a value whose execution can be driven by a suitable executor. Some futures and executors work without heap allocation; others use allocation, threads or platform-specific I/O facilities. The syntax alone does not guarantee runtime independence or portability to every environment.

This design makes suspension visible through .await. An await does not necessarily suspend: a ready future can continue immediately. Conversely, an ordinary blocking call inside async code can occupy the worker thread without yielding. Marking a function async does not make its work nonblocking.

Cancellation needs its own design

Dropping a directly owned future prevents further polling of that future and drops the state it owns. It does not roll back completed side effects, nor does it guarantee that work started elsewhere has stopped.

A task handle can have different semantics. Dropping a Tokio JoinHandle detaches the task; it does not cancel it. Similarly, a timeout around a request may stop waiting without undoing a request already sent to a server. Read the operation's cancellation contract before retrying it or discarding a partially completed result.

When racing operations, establish what happens to the unfinished ones. Cancellation safety depends on their state and API, not on the presence of async alone. Cleanup, retries and partial progress belong in the design.

Choose based on the work and its dependencies

For a small tool that performs a few I/O operations, blocking APIs may be straightforward. Scoped threads can overlap independent operations while borrowing local data. They still have creation and stack costs, so avoid creating an unbounded number of them.

For many overlapping waits, async can reduce the number of blocked OS threads. Existing async libraries, request cancellation or integration with an async application can also justify it at smaller scales. There is no fixed connection count at which one design becomes better.

CPU-heavy computation needs CPU time in either design. Long computations should not monopolize cooperative executor workers. A bounded compute pool or a runtime's blocking-work facility can isolate them; choose concurrency limits appropriate to the workload. An async application may use both async I/O and parallel computation.

If the program has no useful overlapping work, sequential code may be enough. Measure latency, throughput, memory and implementation complexity where they matter, rather than counting how many decision-table rows favor each model.

Check your understanding A CLI fetches three independent APIs, merges their results and prints a table. Should it use async? Show answer

Either approach can work. Overlapping the requests can reduce waiting time compared with sending them sequentially, but the final latency still depends on the slowest request, connection setup and other overhead.

With an existing blocking client, a few scoped threads may be a small change. With an existing async client or runtime, concurrent futures may be simpler. In both designs, decide how failures, timeouts and unfinished requests are handled. Async does not inherently require every future to be Send + 'static; those requirements come from particular spawning APIs.

The useful question is: what is waiting, who drives progress, and what happens if we stop waiting? Those answers help you choose an interface and diagnose stalled tasks without treating either threads or async as a default solution for every program.

step 25 of 25

Async From First Principles

You've reached the last step

Revisit the track to review its lessons and exercises, or explore another topic.

Explore more tracks