The previous article Asynchronous Programming async introduced how to use Rust’s async
. Today’s article will further explain how async
is implemented in Rust, helping us better utilize its features in practice by understanding its underlying principles.
- In the previous article, Rust’s asynchronous programming was achieved through the
async
approach. However, the standard library also supports multi-threading for this purpose, though it is less suitable for certain scenarios like web scraping, which is why it wasn’t covered in detail. If interested, you can explore it further on your own.
Introduction to Rust async/await
async/await
is built-in syntax in Rust, similar to the go
syntax in Golang, allowing us to write asynchronous logic in a synchronous style.
However, its implementation differs from other languages in several key ways:
async
in Rust is zero-cost. This means only the code you write (your business logic) incurs performance overhead, while the internal implementation ofasync
does not. You won’t pay hidden performance costs for usingasync
.- Rust does not include a built-in runtime for asynchronous calls. Instead, the Rust ecosystem provides this, with
tokio
andasync-std
being the most commonly used. - The runtime supports both multi-threaded and single-threaded execution.
The underlying implementation of async
in Rust is complex. This article focuses on key principles in its implementation. async
is considered syntactic sugar—Rust compiles async
-marked blocks into state machines that implement the Future
trait. These are then executed by a thread in the asynchronous runtime. When a Future
is blocked, it yields control of the current thread, allowing other Future
s to execute on the same thread. This enables asynchronous calls without blocking the thread.
Thus, async/await
relies on the following components:
- Essential keywords, types, functions, and the
Future
trait provided by the standard library. - Compiler support for the
async/await
keywords. - A runtime developed by the community to execute
async
code, handleIO
operations, and manage task creation and scheduling.
Let’s look at a simple demo to get a feel for it:
- Here, we use the
tokio
runtime, which requires adding the following dependency toCargo.toml
:
TOML# [dependencies]
tokio = { version = "1.45.0", features = ["full"] }
The code in main.rs
is as follows:
RUSTuse tokio::time::{sleep, Duration};
async fn eat_dish() {
println!("eating dinner.");
sleep(Duration::from_secs(1)).await;
}
#[tokio::main]
async fn main() {
eat_dish().await;
println!("Finished!");
}
Principle Analysis
The code uses async
, await
, and tokio
to implement an asynchronous feature.
First, async fn eat_dish
is compiled by the Rust compiler into a state machine that implements the Future
trait. The definition of Future
in the source code is as follows:
RUSTpub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
The desugared version of async fn eat_dish
would look something like this:
RUST// async fn eat_dish() is desugared into:
struct EatDishFuture {
state: State, // states of a manually implemented state machine
}
impl Future for EatDishFuture {
type Output = (); // associated type
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<()> {
match self.state {
State::Start => {
println!("eating dinner.");
let sleep_fut = sleep(Duration::from_secs(1));
self.state = State::Sleeping(sleep_fut);
Poll::Pending
}
State::Sleeping(ref mut sleep_fut) => {
// sleep_fut returning Ready upon polling indicates the timer has finished.
match Pin::new(sleep_fut).poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(()) => Poll::Ready(()),
}
}
}
}
}
Next, await
is transformed by the compiler into calls to poll()
and state management. Note that the await
method can only be called within async
functions.
The main
function is annotated with the #[tokio::main]
macro, which starts a tokio
runtime, initializes the task scheduler, and executes the main()
async state machine.
The execution flow is as follows: the tokio
runtime calls async main
, encounters eat_dish().await
, and invokes EatDishFuture.poll
. Since eat_dish()
includes a sleep
to simulate time-consuming business logic, EatDishFuture
is blocked and suspended, allowing the main thread to execute other Future
s (though here there’s only one EatDishFuture
). The main thread then repeatedly calls poll
on all suspended Future
s. Once EatDishFuture
completes, it executes println!("Finished!");
in async main
and finally terminates the process. The entire process is zero-allocation, non-blocking, and entirely driven by poll
.
Custom EatDishFuture
As mentioned earlier, the Rust compiler compiles async fn eat_dish
into a struct that implements the Future
trait. Below, we manually implement this Future
struct to observe its behavior. The code is as follows:
RUSTuse std::pin::Pin;
use std::task::{Context, Poll};
use std::future::Future;
use tokio::time::{sleep, Duration, Sleep};
enum State {
Start,
Sleeping(Pin<Box<Sleep>>),
Done,
}
struct EatDish {
state: State,
}
impl EatDish {
fn new() -> Self {
Self {
state: State::Start,
}
}
}
impl Future for EatDish {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
loop {
match &mut self.state {
State::Start => {
println!("eating dinner.");
let sleep_future = Box::pin(sleep(Duration::from_secs(1)));
self.state = State::Sleeping(sleep_future);
}
State::Sleeping(fut) => {
// manually poll the sleep future
match fut.as_mut().poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(()) => {
self.state = State::Done;
}
}
}
State::Done => {
return Poll::Ready(());
}
}
}
}
}
#[tokio::main]
async fn main() {
EatDish::new().await;
println!("Finished!");
}
The runtime behavior is identical to the previous example. Here, we define a custom EatDish
struct that implements the Future
trait, using a state machine to manage execution states. This achieves the same functionality as async fn eat_dish
. Finally, calling EatDish::new().await
within tokio::main
enables asynchronous execution.
Summary
Rust’s async/await
asynchronous programming is based on the Future
trait, where async fn
blocks are compiled into structs that implement Future
. This elegant approach combines Rust’s powerful type system with zero-cost abstractions, delivering a high-performance and safe asynchronous programming model.
By compiling async fn
into state machines and driving their execution via poll
in the runtime, Rust achieves an efficient, non-blocking asynchronous scheduling model. While Rust’s implementation is more low-level and complex compared to other languages, it offers greater performance advantages and flexibility.
In practice, since Rust does not provide a built-in runtime for async/await
, community-developed runtimes like tokio
and async-std
are essential for simplifying task scheduling and execution. Understanding the underlying principles not only helps in using async/await
more effectively but also provides insights for troubleshooting performance bottlenecks or complex asynchronous workflows.
Building on the foundation of Asynchronous Programming async, this article analyzed Rust’s async
programming model from a principles perspective. Future articles will explore how async
can be leveraged in specific scenarios to maximize its potential.