Async Rust

A gentle introduction
A group of black crabs walking towards the words "async/.await" with a larger crab poised over it. The Rust logo is in the top left corner.

Hot off the heels of RustFest Barcelona and the stabilization of async/.await, I think it's safe to say that one of Rust's most anticipated language features has finally landed. And for that occasion (and because I've had some trouble understanding certain bits of it myself), I wanted to write a little introduction to asynchronous programming in Rust. We'll be creating a super simple application that fetches some data from the internet using the our newfound async abilities. The stabilization of async/.await also coincides nicely with another event I'm excited about: This week saw the release of the most recent entries in the mainline Pokémon games, Pokémon Sword and Shield, and because I'm a bit too busy to pick them up just yet, I'll make do by fetching data from the PokéAPI for now.

I'm assuming some base knowledge of Rust's syntax and ecosystem, but I hope that this is pretty accessible even to people very new to the community.

But before diving into the coding part, let's cover some basic concepts of asynchronous programming and how it might be a bit different in Rust than what you'd expect.

What does async mean?

In Rust, when we talk about async, we're talking about running code concurrently, or having multiple overlapping (in time) computations run on a single thread. Multithreading is a related, but distinct concept. Multithreading is ideal for when you've got computationally intensive tasks (so-called CPU-bound tasks) that can be spread across multiple, separated cores. Concurrent programming is better suited for when the task spends a lot of time waiting, such as for a response from a server. These tasks are called IO-bound.

So asynchronous programming lets us run multiple of these IO-bound computations at the same time on a single thread. They can run at the same time because when they're waiting for a response, they're just idle, so we can let the computer keep working on something that isn't waiting. When we reach a point where we need the result of an asynchronous computation, we must .await it. In Rust, values that are 'awaitable' are known as 'futures'.

Rusty weirdness

async in Rust may be a bit different from what you're used to in other languages. Having done asynchronous coding mostly in JavaScript and C#, it certainly was to me. Here's a few key things to understand:

An async function does not (necessarily) start executing immediately

To start an asynchronous function, you must either .await it or launch a task using an executor (we'll get to that in a moment). Until this happens, all you have is a Future that has not started. Let's look at an example to make it clearer:

use async_std::task;
// ^ we need this for task spawning

async fn negate_async(n: i32) -> i32 {
    println!("Negating {}", n);
    task::sleep(std::time::Duration::from_secs(5)).await;
    println!("Finished sleeping for {}!", n);
    n * -1
}

async fn f() -> i32 {
    let neg = negate_async(1);
    // ... nothing happens yet
    let neg_task = task::spawn(negate_async(2));
    // ^ this task /is/ started
    task::sleep(std::time::Duration::from_secs(1)).await;
    // we sleep for effect.

    neg.await + neg_task.await
    // ^ this starts the first task `neg`
    // and waits for both tasks to finish
}

So in the above little code snippet, here's what's going on.

  • The first line imports async_std::task. There's more on this below, but we need an external library to run futures as the standard library does not come with an executor.
  • The async function negate_async takes as input a signed integer, sleeps for 5 seconds, and returns the negated version of that integer.
  • The async function f is more interesting:
    • The first line (let neg ...) creates a Future of the negate_async function and assigns it to the neg variable. Importantly, it does /not/ start executing yet.
    • The next line of code (let neg_task ...) uses the task::spawn function to start executing the Future returned by negate_async. Like with neg, the Future returned by negate_async is assigned to the neg_task variable.
    • Next: we sleep for a second. This is so that it will be obvious from the output when a task starts running.
    • Finally, we await both futures, add them together, and return them. By awaiting neg, we start executing the Future and run it to completion. Since neg_task has already been started, we just wait for it to finish.

So what's the result of this, then?

Negating 2
# <- there's a 1 second pause here
Negating 1
Finished sleeping for 2!
Finished sleeping for 1!

As we can see, the second future, neg_task, started executing as soon as it was called---thanks to task::spawn---while neg did not start executing until it was awaited.

You need an external library to use async/.await

As was briefly alluded to above, you need to reach for an external library to do asynchronous programming in Rust. This took me a while to understand, as I'm used to it being part of the language experience. In Rust, however, *you need a dedicated executor/*[fn:1]. The executor is what takes care of /executing the futures, polling them and returning the results when they're done. The standard library does not come with an executor, so we need to reach out to an external crate for this. There are a few ones to choose from, but the two most prominent ones are ~async-std~ (which we're using here) and ~tokio~.

A minimal async example!

Alright, let's get practical. This is the reason that I'm writing this post. As mentioned at the start, we'll be creating a super simple application that fetches some Pokémon data and prints it to the console. For preparation, make sure you've got at least version 1.39 of Rust and cargo available.

Step 1: creating the application

Let's create a new application! Simply run this command in your preferred directory:

cargo new async-basics

Step 2: Dependencies

We're going to be using ~async-std~ for spawning tasks, and ~surf~ to fetch data from the API. Let's add them to the Cargo.toml file. Your whole file should look something like this:

[package]
name = "async-basics"
version = "0.1.0"
authors = ["Your Name <your.email@provider.tld>"]
edition = "2018"

[dependencies]
async-std = "1"
surf = "1"

Nice! This is going swimmingly!

Step 3: Fetch data

Okay, final step. Let's modify the main.rs file. We'll make it as simple as possible. Here's what we want to use:

use async_std::task;
use surf;

// fetch data from a url and return the results as a string.
// if an error occurs, return the error.
async fn fetch(url: &str) -> Result<String, surf::Exception> {
    surf::get(url).recv_string().await
}

// execute the fetch function and print the results
async fn execute() {
    match fetch("https://pokeapi.co/api/v2/move/surf").await {
        Ok(s) => println!("Fetched results: {:#?}", s),
        Err(e) => println!("Got an error: {:?}", e),
    };
}

fn main() {
    task::block_on(execute());
    // ^ start the future and wait for it to finish
}

That's all the code you need. In fact, it's more than what you need, as some parts have been broken up for legibility. Let's walk through it!

~use~ statements
Nothing exciting here. Just importing the crates we declared in the Cargo.toml file: surf and async_std.
~fetch~
This is simply a thin wrapper around the surf::get function which returns either the payload as a String or an Exception if something went wrong.
~execute~
This function calls fetch with the endpoint for the move Surf, waits for the result to return, and then matches on the result. If everything went well: print the output. Else: print the error.
~main~
main simply kicks off execute and waits for it to finish. task::block_on is a synchronous counterpart to task::spawn that starts an asynchronous operation, but blocks until it has finished. Because the main function can't itself be async (at least not at the time of writing), we can't use .await in it, but we can block on asynchronous operations.

Step 4: Extend it!

Hey, you made it this far; congrats! That's all I really have in store for you for this one, but if you want to play around a bit more, how about adding ~serde~ and try using surf's recv_json<T> instead? If you'd rather keep looking at async/.await, how about performing multiple requests simultaneously? Or how about making a PokéAPI CLI? (Ooh, that sounds like fun! Hit me up if you're doing this; I want in!)

Parting words and resources

So there you have it, dear reader. I hope you have found this useful. async/.await is finally stabilized and it feels like we've taken a major leap forward. I'm very much looking forward to seeing what happens in the coming months and what the community makes of this.

If you're looking for more resources on async Rust, be sure to check out the Async Book. I also recommend the async-std book for some extra insights.

Until next time: take care!

Footnotes

[1]


Thomas Heartman is a developer, writer, speaker, and one of those odd people who enjoy lifting heavy things and putting them back down again. Preferably with others. Doing his best to gain and share as much knowledge as possible.