loading...
Cover image for Async Rust, but less intimidating

Async Rust, but less intimidating

dotxlem profile image Dan Updated on ・8 min read

For the last few years, Rust has been a fast-moving target. The language and supporting ecosystem are still evolving, meaning that even though the language is pretty stable overall, some details are still being figured out.

One of those details has been support for asynchronous functions, which recently went stable in version 1.39. However, a lot of what I've read while learning still uses older/non-standard implementations of async. It's also still pretty easy to come across code using pre-async/await futures, which were...unpleasant.

Separating the deprecated information from the current can be a little tricky at first -- I spent time learning a couple of APIs only to find out that they are already obsolete. It doesn't help either that Rust's Future-based approach to asynchronous code is, I suspect, a bit different from what many people are used to (coming from ECMAScript's Promise for example).

All of that, can make the task of writing asynchronous Rust code look a little bit intimidating.

So! In this post I'll give you a short guide to getting started with futures and async/await syntax in Rust 1.39+. We'll strip out all the cruft, and give you the up-to-date information that you need to get cooking. For you, it's only the latest and greatest from here on out. Lucky you!

So, What's a Future?

In the broadest sense, a future is an abstract object which represents a computation. Practically speaking, a future is a value which tracks the state of an asynchronous computation. Think of it as a placeholder for a value you will have sometime in the... f u t u r e 😎 (get it? do you get it?).

A future's "value" will either be pending, indicating that the computation is still running, or ready, indicating that the computation has returned a value. When ready, the future object represents the value returned by the asynchronous call. Typically this value is wrapped in a Result, allowing you to handle error values the same as you would elsewhere in your Rust code.

If you have written any JavaScript recently, this concept will feel very similar to a promise.

However! There are a couple of key differences.

For one, Rust's futures are lazy. This means that they are not necessarily executed right away -- that is, just because you have created a Future, does not mean you have started a new computation. A future is technically started and executed by an executor -- I'll explain executors further down, but in most cases you won't even need to know that you have one! 🙂

In comparison, JavaScript promises are eager. This means that they are always immediately queued for execution in the main event loop.

Another difference is that an executor may choose to run futures in parallel (via threads), whereas (to the best of my knowledge) JavaScript's event loop is single-threaded, meaning that promises are concurrent but not parallel.

Squidward, on his back, repeatedly crunching his body and screaming the word "future"

Basic Ingredients

As I hinted at earlier, async support in Rust is not entirely batteries-included. Rust leaves the async runtime out on purpose. There are many possible strategies for orchestrating asynchronous work, and in many cases the "best" strategy is application-specific. For example, AssemblyLift uses both a standard tokio runtime, as well as the lightweight direct-executor to run futures inside the WebAssembly environment!

You will need some kind of executor; the executor is responsible for driving the future towards completion. Executors themselves typically require a few components of their own, which together make up a runtime. Fortunately, you don't have to write a runtime yourself! Even though Rust doesn't ship with a "standard" async runtime, there are many good choices available. I will be focusing on tokio, only because I've been following its development for a while so it's the one I'm most familiar with. The async-std crate seems popular, but I've never tried it personally.

Practical Use

Let's look at a few concepts which I think are likely to be the most useful in your day-to-day. We'll start with the basics, and work our way to more advanced topics 🙂.

Getting Started

If you don't need to deviate from the defaults (the standard, multi-threaded runtime), tokio makes it reaaaaal easy to get set up.

For example:

use tokio::prelude::*;

#[tokio::main]
async fn main() {
    println!("Hello, async world");
}

That's not much code, but it's doing a lot for you under the hood. The line #[tokio::main] is something called an attribute macro; here it's applied to main. At compile-time, the macro transforms your async function into all of the code actually required to set up a tokio runtime and spawn main.

In reality, the entry-point of a Rust program cannot be async -- a regular function first has to create a runtime, and then spawn the first function on the runtime.

Async Blocks

In addition to the async fn foo() syntax used for declaring asynchronous functions, Rust also provides something called an async block.

// you can also write `async move` if you need to move ownership
//    into the closure
tokio::spawn(async {
    an_async_function().await;
})

An async block is a block expression which evaluates to a Future. If you aren't familiar with block expressions, don't worry! That level of detail isn't really necessary for using them, in my opinion. But there's also not that much to it! In Rust a block is essentially any code surrounded by curly braces -- it's called an expression because it always evaluates to a value (just look at a regular function definition -- that's a block expression!). In essence, they allow you to create an anonymous async function.

Awaiting the Future

For any function that returns a Future trait object, Rust provides a convenient .await syntax.

For example:

use tokio::prelude::*;

async fn my_async_function() -> Result<u8, Error> { ... }

#[tokio::main]
async fn main() {
    let x: Result<u8, Error> = my_async_function().await;
}

Awaiting a future means that the current execution task will be suspended, until the Future being awaited returns a value (becomes ready). It's entirely possible (and likely) that the runtime will continue to run other futures in the background.

The phrase "current execution task" is a little tricky. Originally, I thought this implied that the thread from which you called await would be blocked, however this isn't exactly true (credits for the correction at the bottom)! Under the hood, futures are executed in tasks -- think of them like a container for your async code that provides context (context which you generally don't have see or interact with). The runtime can swap tasks within the same thread, so the thread isn't necessarily blocked while your future is awaiting.

The await keyword can only be used inside a function declared async. It is also worth noting that even though my_async_function doesn't have an explicit return type, any async function implicitly returns a Future -- async fn is a shorthand provided by Rust to avoid having to include the full return type in the function declaration.

Storing and Passing Async Functions

You may find yourself needing to store references to an asynchronous function, or passing one somewhere as an argument or attribute.

Storing an async function in a struct for example, requires you to use a trait object.

For example:

use std::future::Future;
struct StructContainingAsyncFn {
    async_function: fn() -> dyn Future<Output=()>,
}

In the example above, we indicate that our async_function returns a Future trait object; this is the dyn Future<Output=()> type in the above example. This is the kind of syntax that the async keyword hides, in the places we can use it.

The "real" type of the Future is denoted by the Output=() type, which means that when the future resolves (ie, becomes ready) it will return a value of type (). If our asynchronous function was returning a String, we would write dyn Future<Output=String>.

Trait objects are also used when passing a future as an argument:

fn spawn_wrapper(future: impl Future<Output=()>) {
    // do something with the future here
    // you could combine it with other futures! 
    //    ^- we'll talk about this below 👇
    tokio::spawn(future)
}

The impl Future syntax is referred to as an impl trait (I pronounce it IMP-ull). Essentially this instructs the compiler that whatever is passed as the future argument must implement the Future trait.

Combining Futures

Personally, this is one of my favourite (I spelled it correctly don't @ me) patterns with async Rust!

Up until now, everything I've shown can be done with the standard library. However, for combinators I'll be importing the futures-util crate which provides us the TryFutureExt trait. This trait provides additional methods for our futures, which we can use to combine asynchronous functions into reusable function chains.

use tokio::prelude::*;
use tokio::net::TcpListener;
use futures_util::future::TryFutureExt;

#[tokio::main]
async fn main() {
    let mut buf = [0; 1024];
    let mut listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
    listener
        .accept()
        .and_then(|(mut socket, _)| {
            async move {
               Ok(socket.read(&mut buf).await)
            }
        })
        .await;
}

This example is adapted from tokio's documentation. Here, instead of calling listener.accept().await and assigning an intermediate value (potentially doing some matching & error handling), we "attach" another function to it using and_then, which only runs the provided closure if the previous Future resolves successfully.

I found that at first, I really wanted to write something like:

and_then(async |...| {
    Ok(socket.read(...).await)
})

It feels (and looks) a little more natural, however async closures aren't stable yet (but I believe this kind of syntax can be enabled as an experimental feature).

Instead, and_then expects a closure which returns a Future, for which I'm using an async block.

I called this pattern reusable, because we don't necessarily have to await the call chain where it's defined, as I have in the above example.

let my_complicated_call_chain: Future<...> = some_call()
    .and_then(|_| ...)
    .and_then(...);

Remember how earlier, I pointed out that Rust's futures are lazy? In the above code, nothing is actually being invoked -- and it won't be, until the resulting Future is spawned either by awaiting it, or spawning it directly.

I find a lot of operations I write require multiple async calls in succession, depending on whether/what the previous calls return to feed the next. This pattern is quite useful for combining these call chains and "freezing" them so that they can be reused. There are many more combinators besides and_then; I recommend reading the trait documentation for better detail than I can provide here.

The Closer

That's all! However you landed here, I hope this was helpful, and that you got something out of it!

If you have any questions, want something explained more clearly, or want to point out a mistake I made -- write in the comments, or feel free to write me at xlem@akkoro.io!

Credits

Cover photo by Ashim D’Silva on Unsplash

Update 2020/07/17: thanks to Reddit user coderstephen for a couple of corrections!

Posted on by:

dotxlem profile

Dan

@dotxlem

Writing about software engineering and its philosophies.

Discussion

markdown guide