Intro to Async Rust
Differences between async & sync
- sync programming often has imperative behaviour
- async programming is an abstraction that allows developers to define yield points where the execution can be paused and later resumed
Note:
- Imperative: Statements in synchronous code are just executed step-by-step.
Async language support
asyncfunctions can define yield points where the execution can be paused, represented by.awaitsyntax.- Built into the language: The compiler generates the state machines required to do this automatically.
Building blocks
- Built from various important building blocks
- Executors, Tasks, Futures, Reactors, and more
An async Rust function
use tokio::{fs::File, io::AsyncReadExt};
async fn read_from_disk(path: &str) -> std::io::Result<String> {
let mut file = File::open(path).await?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).await?;
Ok(buffer)
}
Note:
- function must be
asyncsoawaitcan be used inside it. - The code between two
awaition points has a regular synchronous flow.
(sketch) Desugaring the return type
use std::future::Future;
use tokio::{fs::File, io::AsyncReadExt};
fn read_from_disk<'a>(path: &'a str)
-> impl Future<Output = std::io::Result<String>> + 'a
{
async move {
let mut file = File::open(path).await?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).await?;
Ok(buffer)
}
}
Note:
- Helpful mental model: A
asyncfunction is a regular synchronous functions that returns aFuture.
Executor
- Manages, schedules and executes a collection of asynchronous tasks
- Examples of executors on host systems:
tokioandsmol. - Examples of executors on no-std systems:
rticandembassy
Note:
- The compiler generates Future-implementing state machines from our
asynccode. The executor polls them - This is inversion of control: the runtime drives our code, not the other way around.
Futures
Represent a data structure that - at some point in the future - gives us the value that we are waiting for. The Future may be:
- delayed
- immediate
- infinite
Futures are operations
Futures are complete operations that can be awaited for.
Examples:
read: Read (up to) a number of bytesread_to_end: Read a complete input streamconnect: Connect a socket
Note:
- A future can be resolved or polled to completion, or canceled by
droping them.
Futures are poll-based
They can be checked if they are done, and are usually mapped to readiness based APIs. Some examples:
- On a UNIX based OS: Using the
epollmechanism. - On an Embedded ARM Cortex-M: Using architecture specific wakeup and sleep instructions.
.await registers interest in completion
use tokio::{fs::File, io::AsyncReadExt};
async fn read_from_disk(path: &str) -> std::io::Result<String> {
let mut file = File::open(path).await?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).await?;
Ok(buffer)
}
Note:
- Reminder:
awaitare the yield/pause points where the execution might be paused and later resumed
Futures are cold
fn main() {
// This code will not start reading from the disk on its own.
let read_from_disk_future = read_from_disk();
}
Note:
- You can still create objects that implement Future using a regular
fn— for example, afn newconstructor that returns aFuture-implementing type. That synchronous function might do some setup work.
Futures need to be executed
use tokio::{fs::File, io::AsyncReadExt};
#[tokio::main]
async fn main() {
let read_from_disk_future = read_from_disk();
// We resolve the future by awaiting it. The runtime handles this for us.
let result = read_from_disk_future.await;
// When we get here, the read has been finished.
println!("{:?}", result);
}
async fn read_from_disk(path: &str) -> std::io::Result<String> {
let mut file = File::open(path).await?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).await?;
Ok(buffer)
}
Futures all the way down: Combining Futures
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::time::Duration;
#[tokio::main]
async fn main() {
let read_from_disk_future = read_from_disk("Cargo.toml");
let timeout = Duration::from_millis(1000);
let timeout_read = tokio::time::timeout(timeout, read_from_disk_future);
let result = async {
let task = tokio::task::spawn(timeout_read);
task.await
}
.await;
println!("{:?}", result);
}
Tasks
- A task connects a future to the executor
- The task is the concurrent unit!
- A task is similar to a thread, but is user-space scheduled
Reactors
- How do we avoid busy polling async tasks? Is it possible to only poll on interesting events?
- Reactors are the mechanism for this
- They map interest in completion to operating system or platform specific event mechanisms
Example of reactors
- In
tokio: The low-levelmiolibrary provides a cross-platform API wrapping OS mechanisms likeepoll/kqueue/IOCP. - On embedded systems, interrupts are the primary reactors for event handling.
Tying everything together
Categories of executors - Single threaded
- Generally better latency, no synchronisation requirements
- Highly susceptible to accidental blockades
- Harmed by accidental pre-emption
Categories of executors - Multi threaded
- Generally better resource use, synchronisation requirements
- Harmed by accidental pre-emption
Categories of executors - Deblocking
- Actively monitor for blocked execution threads and will spin up new ones
Ownership/Borrowing Memory in concurrent systems
- Ownership works just like expected - it flows in and out of tasks/futures
- Borrows work over
.awaitpoints- This means all owned memory in a Future must remain at the same place
- Sharing between tasks is often done using
RcorArc
Reference Counting
- Reference counting on single-threaded executors can be done using
Rc - Reference counting on multi-threaded executors can be done using
Arc
Streams
- Streams are async iterators
- They represent potentially infinite arrivals
- They cannot be executed, but operations on them are futures
Classic Stream operations
- iteration
- merging
- filtering
Async iteration
while let Some(item) = stream.next().await {
//...
}