Performant Software Systems with Rust — Lecture 13
Entering Asynchronous Rust
Asychronous Programming
Multi-threaded programming \(\rightarrow\) asynchronous programming
CPU-bound vs. I/O-bound operations: video export vs. downloading from the Internet
Blocking System Calls
Many operating system APIs — in the form of system calls are blocking
We can certainly spawn new threads to use these blocking system calls
but watch out on the overhead of spawning threads
it is not a good idea to have too many threads
Non-Blocking Calls
spawn followed by a join, it would be great to have something like this:Threads \(\rightarrow\) Stackless Coroutines
await keywordFutures and the async / await syntaxRevisiting the Idea of Parallelism vs. Concurrency
Analogy: a team splitting up work for the course project
Concurrency: assigning each member multiple tasks, can switch but only make progress one at a time — a single CPU core
Parallelism: each member takes one task, making progress at the same time — multiple CPU cores
Futures
A future: is a value that may not be ready now, but will become ready at some point in the future
Rust provides a Future trait as a building block from the Rust standard library (std::future::Future)
Futures are simply types that implement the Future trait
Each future holds its own information about the progress that has been made and what “ready” means
The Async Syntax
You can apply the async keyword to blocks and functions to specify that they can be interrupted and resumed
Within an async block or async function, you can use the await keywoard to await an future — waiting for it to become “ready”
Anywhere you await a future, that async block or function can pause and resume
The process of checking with a future to see if its value is available yet is called polling
Hello, world! in Async Rust
// Define an async function.
async fn say_hello() {
println!("Hello, world!");
}
// Boilerplate which lets us write `async fn main`, needs
// `tokio = { version = "1", features = ["full"] }` in Cargo.toml
#[tokio::main]
async fn main() {
// Call an async function and await its result.
say_hello().await;
}Learn more: The Hello, world! Example
The Async Runtime
recv() or await), another must be picked to runLearn more: The Async Runtime
The Async Runtime
Learn more: The Async Runtime
Choice of Async Runtimes
tokio is a full-featured, multi-threaded runtime that we often use
async-std and smolbytedance/monoio
Sync and SendLearn more: The Async Runtime
async and await
Calling an async function directly — without await — returns a future, which is a struct or enum that implements the Future trait and represents deferred computation
Learn more: await
A Real-World Example — page_title()
use reqwest;
use scraper::{Html, Selector};
/// Asynchronously fetch the page at `url` and return the content of
/// the <title> tag, or `None` if anything fails.
async fn page_title(url: &str) -> Option<String> {
// Send GET request, `get(url)` returns
// `Future<Output = Result<Response, reqwest::Error>>`
// `ok()` converts it into `Option<Response>`
let response = reqwest::get(url).await.ok()?;
// Extract the response body as text
let response_text = response.text().await.ok()?;
// Parse HTML
let document = Html::parse_document(&response_text);
// CSS selector for the <title> element
let selector = Selector::parse("title").ok()?;
// Find the first <title> element and return its inner HTML
document
.select(&selector)
.next()
.map(|title| title.inner_html())
}Learn more: Our First Async Program
A Real-World Example — main()
Learn more: Our First Async Program
Desugared async function
fn page_title(url: &str) -> impl Future<Output = Option<String>> + '_ {
async move {
let response = reqwest::get(url).await.ok()?;
let response_text = response.text().await.ok()?;
let document = Html::parse_document(&response_text);
let selector = Selector::parse("title").ok()?;
document
.select(&selector)
.next()
.map(|title| title.inner_html())
}
}Learn more: Our First Async Program
Desugaring async function
Uses the impl Trait syntax — Traits as parameters
Returns a Future with an associated type of Output
type is Option<String>
the same as the original return type from the async fn version of page_title
Learn more: Our First Async Program
Desugaring async function
Everything is wrapped in an async move block
This async block is a anonymous compiler-generated type
Future<Output = Option<String>>.await on this future → produces a value with the type Option<String>
Output type in the return typeLearn more: Our First Async Program
What Is + `_ Doing Here?
What Is + `_ Doing Here?
The returned future captures url, which is a &str
The future borrows from url
So the future cannot outlive the reference it holds
Therefore its lifetime is tied to the lifetime of url
+ `_ implies “This impl Future may contain borrows, and its lifetime is some anonymous lifetime that’s at most as long as the references it captures (like url).”
Learn more: Our First Async Program
Good news: Only needed in older versions of the Rust compiler (before v1.7), no longer needed now.
Learn more: Our First Async Program
Desugaring #[tokio::main]
Learn more: Our First Async Program
Desugaring #[tokio::main]
fn main() {
// Create a multi-threaded Tokio runtime
let rt = Runtime::new().expect("failed to create Tokio runtime");
let url = "https://www.rust-lang.org";
rt.block_on(async {
match page_title(url).await {
Some(title) => println!("Title of {} is: {}", url, title),
None => eprintln!("Could not fetch or parse title for {}", url),
}
});
}Learn more: Our First Async Program
Deep dive: What’s a Future in Rust anyway?
Learn more: A Closer Look at the Traits for Async
A value that represents a computation that may not be finished yet.
Future: Formal Definition
Learn more: A Closer Look at the Traits for Async
Key Points
type Output: the type the future will eventually produce (like u32, Result<T, E>, etc.)
poll(...): asks the future “are you ready yet?”
Returns Poll::Pending if it’s not done
Returns Poll::Ready(value) when it has the final result
You almost never write poll by hand in normal code — the async/await syntax and executors do that for you
Learn more: A Closer Look at the Traits for Async
Futures are Lazy
Just like iterators and unlike threads
A std::thread::spawn starts running immediately on another OS thread
A Future does nothing until an executor polls it
Learn more: A Closer Look at the Traits for Async
Executors and Tasks: The Runtime
A runtime (like Tokio, async-std, etc.) provides an executor that
Keeps a queue of futures (often called tasks)
Repeatedly calls poll on them
Uses a Waker to be notified when I/O or timers are ready, so it can poll again
Learn more: A Closer Look at the Traits for Async
What Does self: Pin<&mut Self> Mean?
Learn more: A Closer Look at the Traits for Async
What Does self: Pin<&mut Self> Mean?
poll takes self by Pin<&mut Self> instead of by &mut Self
Pin<T> is a wrapper that says “Through this pointer, you are not allowed to move the value T in memory.”
Learn more: A Closer Look at the Traits for Async
Pin<&mut T>
Logically a mutable reference, but with an extra guarantee:
you can mutate T
but you must not move T to a different memory location via this reference
Why Do We Care About Moving?
Because async/await futures are often self-referential
The compiler turns an async fn into a state machine struct
That struct may contain internal references (e.g., a field borrowing another field)
If you move the struct after those references are created, those internal references would dangle
So Rust says: “Once we start polling a future, we must be able to guarantee it won’t move anymore.” That guarantee is expressed with Pin
Learn more: A Closer Look at the Traits for Async
What Does self: Pin<&mut Self> Enforce?
Before calling poll, the executor must have pinned the future
Inside poll, the future is treated as immovable (through this handle)
Learn more: A Closer Look at the Traits for Async
What Does an Executor Do?
Learn more: A Closer Look at the Traits for Async
The Unpin Marker Trait
If the future is not Unpin (e.g., most async fn futures), the executor must pin it in a stable place first:
The Unpin Marker Trait
“Is it still safe to move this thing around in memory, even if it has been pinned?”
Yes: the type is Unpin
No: the type is not Unpin (written !Unpin)
The Unpin Marker Trait
If Self: Unpin, then Pin<&mut Self> is the same as &mut Self in practice
Most “normal” types are Unpin
Compiler-generated async fn futures are usually not Unpin, so they need pinning
So self: Pin<&mut Self> is a general signature:
Works for both Unpin and !Unpin futures
Enforces “must be pinned before polling” at the type level
What’s a Waker?
A Waker is basically the doorbell for a future
A Waker is a handle created by the executor that a future can store and later call wake() on, to say:
“Hey runtime, I might be ready now — please poll me again soon.”
Where Does Waker Live?
The Context carries a Waker:
poll()
Why Do We Need a Waker?
Without Waker, if a future isn’t ready yet, the executor would have to:
keep polling it in a loop (busy-waiting), or
poll every future all the time “just in case”
That’s terrible for CPU!
Why Do We Need a Waker?
The contract in async Rust is:
When poll returns Poll::Pending, the future is not ready yet
If it ever becomes ready to make progress, it must ensure that someone calls waker.wake()
The executor, when wake() is called, will schedule that future to be polled again
Why Do We Need a Waker?
So Waker is how a future tells the executor:
Spawning Tasks
use tokio::{spawn, time::{sleep, Duration}};
async fn say_hello() {
// Wait for a while before printing
sleep(Duration::from_millis(100)).await;
println!("hello");
}
async fn say_world() {
sleep(Duration::from_millis(100)).await;
println!("world!");
}
#[tokio::main]
async fn main() {
spawn(say_hello());
spawn(say_world());
// Wait for a while to give the tasks time to run
sleep(Duration::from_millis(1000)).await;
}Learn more: Spawning Tasks
Joining Tasks
Learn more: Joining Tasks
The Rust Programming Language, Chapter 17
The Rustonomicon, Chapter 8
Asynchronous Programming in Rust, Chapter 1-4