Blog post

A short introduction to async Rust

2022-06-16T15:00:00

8 minute read

This blog post is powered by shuttle! The serverless platform built for Rust.

In this post, we'll be going over the state of async code. Before this post the only async Rust I had written had been copied and pasted from Stack Overflow so I wanted to dig deeper into what async code is and how to start writing it.

What is asynchronous code?

To understand what asynchronous code is - let's first talk about synchronous code.

In synchronous code, statements run in a sequential order:

1println!("Hello World");
2let cargo_toml_content = std::fs::read_to_string("Cargo.toml").unwrap();
3println!("'Cargo.toml':\n{}", cargo_toml_content);
4

The above statements are executed in a well defined order - one after the other, top to bottom. Hello World is printed, followed by the contents of Cargo.toml being read and then printed.

This paradigm is perfectly fine under normal operation - but sometimes our code requires the current context to stop while it waits for something else - this is generally known as blocking.

1for index in 1..=100 {
2    let result = sync_http_client.get(format!("www.example.com/items/{}", index));
3}
4

In the above example, every loop iteration a request is made to the infamous example.com.

The problem here is that sync_http_client.get is blocking. Blocking can occur for lots of reasons:

  • waiting for the file system
  • waiting for the network
  • waiting for some database transaction
  • waiting for some time to occur
  • etc.

When a program is blocked it is doing nothing but waiting for a response to return to continue execution. If we need to work on anything else - we're kinda stuck. In this example the loop cannot run the next iteration / index until the request in the previous one has fully finished. While making and reading a single request is relatively fast, the code in the loop runs 100 times and makes 100 requests so the whole loop takes a while to run.

What if there was a way to start additional requests without having to wait for the previous to have finished its request?

This is where asynchronous programming comes in. Asynchronous programming is about not blocking. Let's say you've ordered a mountain bike for a ride on the weekend. You don't need to spend all your time on the doorstep waiting for the delivery - you can continue living your life doing whatever. An async runtime allows you to continue whatever you are doing and acts as a doorbell, awaking you to the door when the delivery arrives.

We will get more in to how to write async later but the essence is that we can change the loop to the following to start up 100 requests without having requiring the previous request to have finished:

1let mut handles = Vec::new();
2for index in 1..=100 {
3    let handle = tokio::spawn(
4        async_http_client.get(format!("www.example.com/items/{}", index))
5    );
6    handles.push(handle);
7}
8for handle in handles {
9    let result = handle.await;
10}
11

Parallelization and concurrency

Before we go further we should note that async is not for processing expensive operations. It's only beneficial for IO in which data comes from somewhere further away than the RAM and when there is a lot of it. For process expensive operations parallelization is beneficial.

Parallelization is running multiple things at the same time. Concurrency is handling multiple things at the same time.

Async is designed for concurrency. Tokio's default runtime utilises threads so we also benefit from parallelization.

Benchmarking

Comparing an example written using async vs the same example written synchronously - for a large numbers of concurrent web requests, the async version is ~60% faster that synchronous requests and ~20% faster than spinning up a thread for each request1.

CommandMean [s]Min [s]Max [s]Relative
./sync1.070 ± 0.0131.0601.0851.65 ± 0.09
./threads0.787 ± 0.0070.7820.7951.22 ± 0.06
./async0.732 ± 0.0160.7210.7501.13 ± 0.06
./async_threads0.646 ± 0.0330.6120.6771.00

Getting started with asynchronous Rust

Rust does not have a runtime2 and so doesn't have a standard executor (at least for now). There are several popular executor runtimes. These are crates like any other library so you can use them by adding them to the Cargo.toml. For this demo we will pick Tokio as it the most popular executor. Other runtimes exist and prioritize different things. For example async-std is focused on an async version of Rust's standard library and smol which is focused on being lightweight. Overall Rust is designed to stay out the way, so it lets you pick which executor you run.

To start we will run cargo new. Then add tokio = { version = "1.19", features = ["full"] } to Cargo.toml (or if you have cargo-edit installed: cargo add tokio -F full)

1#[tokio::main]
2async fn main() {
3    println!("Hello from an async function");
4}
5

Async functions

Function which contain async things are marked as async, this is done by prefixing the function with async:

1async fn do_thing() {
2    let result = some_async_function().await;
3    println!("{}", result);
4}
5

In an async function you can use .await. You add it on to the end of a call of async function and it will now block and return the actual value.

Async functions (and async blocks) return Futures. A Future is a function which returns a Poll. Poll is a bit like a Result or Option, it has two variants one is a final value and the other variant is that the value is still pending. Futures are lazy, there are two ways to run a future: tokio::spawn to spawn eagerly and get a JoinHandle or .await. Rust warns against unawaited futures.

Writing async operations

All IO and filesystem functions in Rust's standard library are synchronous (and so block). Tokio provides async versions of the synchronous io in Rusts's standard library.

1let contents = tokio::fs::read("Cargo.toml").await;
2

Writing concurrency

As discussed earlier the problem with blocking calls is only one thing can run.

1let weather = client.get("https://api.darksky.net/forecast").await;
2let news = client.get("https://api.nytimes.com/svc/topstories").await;
3

With tokio::join! we can run the requests start both requests and await for their results concurrently.

1let weather = client.get("https://api.darksky.net/forecast");
2let news = client.get("https://api.nytimes.com/svc/topstories");
3let (weather, news) = tokio::join!(weather, news).await;
4

Rather than getting weather then getting the news. The above code starts both requests and then waits for the response from both, joining them in a resulting tuple.


To keep this post short and to the basics we will stop here. If you want to read more about writing async the there is the official Rust async book and Tokio has a brilliant tutorial.

Conclusion

Rust async is very much usable. The async side of the Rust language is still in heavy development and can only can get better from here. https://areweasyncyet.rs/ gives a good overview of the status of async language features and other things in the async ecosystem. This post is a introductory look into writing async Rust. Maybe await a future post digging deeper into async in Rust!

Shuttle: Stateful Serverless for Rust

Deploying and managing your Rust web apps can be an expensive, anxious and time consuming process.

If you want a batteries included and ops-free experience, try out Shuttle.


Footnotes

  1. We had a bit of difficult showing beneficial results for async and still unsure whether these results are a good reflection of the benefits of async. You can view the results here and the full benchmarking code here.

  2. Technically there are panic handlers and things which is runtime https://doc.rust-lang.org/reference/runtime.html

Share this article

Let's make Rust the next language of cloud-native

We love you Go, but Rust is just better.