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 Hide 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.