A Rust Async Primer-Pt 2

This is part 2 in a 3 part series on understanding the basics of asynchronous programming in Rust. Part 1 focused on the concepts around asynchronous programming, and specifically, how Rust implements it. In this one, we’re going to look at some example code.

Photo by Markus Spiske on Unsplash

First, let’s create a new project using Cargo with cargo new rustasync --bin

Now, open up the generated Cargo.toml file and add the futures-rs crate to the dependencies section. You can follow the link above to learn more and find the complete documentation but in summary, it’s a bare-bones implementation of an async runtime. Your toml file should look similar to the one below:

[package]
name = "rust-async"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html[dependencies]
futures = "0.3"

The futures-rs crate implements some of Rust's core abstractions for asynchronous programming. We’ll go over those in more depth later but for now, the Future trait is of the most relevance to us.

Let’s imagine that we’re going to fix breakfast at home. We might choose to make some coffee, eggs, and because I’m trying to be healthy, use the oven to make some bacon. My process would be something like starting the coffee maker and getting the coffee brewing. While that’s happening I could pre-heat the oven. While the oven is pre-heating I can get the bacon out and ready it for the oven. Once the bacon is ready I can put it in the oven, pour myself some coffee, and cook the eggs while waiting for the bacon to finish. I’m sure you get the idea at this point. If we were to prepare breakfast synchronously, one task after the other, it would take significantly longer to complete preparing breakfast and most of it would be cold by the time we got to eat it.

Let’s write a simple application to try and mimic this approach by using asynchronous code. Open up the main.rs file under the src directory and add the following code.

// The block_on function simply blocks the current thread until the 
// Future has completed and returned.
use futures::executor::block_on;fn main() {
// The make_coffee, make_eggs, and make_bacon functions
// have all been annoted with the async keyword.
// This means that they will all return a Future.
// In fact, the following 2 functions are the same // async fn foo() { // do stuff }
// fn foo() -> impl Future<Outpout = ()> {
// async { // do stuff }
// }
let coffee_future = make_coffee();
// The block_on executor accepts a Future as input and blocks
// the current thread until that future has completed
block_on(coffee_future); let bacon_future = make_bacon();
block_on(bacon_future);
let eggs_future = make_eggs();
block_on(eggs_future);
}
async fn make_coffee() {
println!("Coffee started.");
}
async fn make_eggs() {
println!("Eggs started.");
}
async fn make_bacon() {
println!("Bacon started.");
}

First, we import a very simple executor from the futures crate. In Rust futures are lazy. We’ll look at this in a bit more detail later, but creating a future does nothing on its own. It needs to be driven to completion by an executor. That’s the job of the on_block() executor. It accepts a Future as a parameter and executes that code while blocking the main thread.

The code above is not idiomatic because we create the Future that represents the async function, immediately call the block_on() executor, wait for the async code to complete, and repeat this process with the rest of our async functions. This effectively makes the execution synchronous. Let’s tweak the code a bit and make it look a bit more idiomatic.

So we know that we can start the coffee maker and let it work while we cook our bacon in the oven, so let's start those two tasks before starting our eggs.

First, we’ll add a new async function called make_coffee_and_bacon() shown below:

async fn make_coffee_and_bacon() {
make_coffee().await;
make_bacon().await;
}

Next, we’ll add another new async function called async_main():

async fn async_main() {
let long_running_stuff = make_coffee_and_bacon();
let other_stuff = make_eggs();
futures::join!(long_running_stuff, other_stuff);
}

Finally, we’ll change the existing main() function to look like this:

fn main() {
block_on(async_main());
}

Let’s go through these changes one at a time. First, we create a new async function for the coffee and bacon tasks. You’ll notice that we use await inside the functions. Using await inside an async function is similar to using on_block() to wait until the Future has resolved, but instead of blocking the thread, it allows other tasks to run if the future is blocked, e.g., waiting on input from a socket or stream.

Second, we’ve added the async_main() function. This is a pretty straightforward change. Since await can only be used inside async functions or async code blocks, and main() function cannot be annotated with the async keyword. While the thread that runs main() will block until the block_on() call resolves, everything from async_main(), all the way down the execution chain will run asynchronously. The third line is new. The futures::join!() macro is similar to the await keyword but it operates on multiple futures, returning only when all Future’s complete, regardless of the order of completion.

Earlier I mentioned that Futures are “lazy”, in that they do nothing on their own. They will never return a value unless driven to completion. This may seem strange, especially if you’re familiar with how other languages implement asynchronous code, but this is a deliberate choice by the language designers. By allowing Future's to be lazy, creating an async function becomes a zero-cost operation. By “zero-cost”, I mean that there is no runtime impact for creating a Future unless you actually use them. If you want to see an observable example of this laziness in action, just remove the .awaitfrom the call to make_coffee() and make_bacon() in the make_coffee_and_bacon() function, and run the code. You’ll notice that when you run it after removing await, it will never print any output.

I wanted to note, in this specific example, that the order of execution of our async functions does not change. You could stick a random sleep in any, or all, of the three functions to change how long the execution time takes, but it will not impact the order the print statements appear.

This may seem counter-intuitive, after all, this is supposed to be asynchronous execution. Let's take a look at why. If you recall from the first article in the series, we mentioned that the await keyword is used by the generated state machine as a marker designating atomic units of execution. For example, in the async function make_coffee_and_bacon(), we first call make_coffee().await, followed by make_bacon().await. In the generated state machine, everything from the start of the function until the first await keyword is considered an atomic unit. Similarly, everything after the call to make_coffee().await until the next await keyword would be another atomic unit. This means that the function make_coffee() will complete before running make_bacon(). The await keyword by itself doesn’t actually drive a Future to completion. It’s the block_on() call that drives the async code to completion. I used the term executor earlier but didn’t really define it. An executor is responsible for driving all of the async code it’s responsible for to completion. In our case, we pass async_main() to the block_on() executor and that executor is responsible for driving all of the async code that async_main() contains to completion, but block_on() doesn’t implement any logic which would allow it to multiplex execution. In order to allow code using async/await to execute in an asynchronous fashion, we need to use a more sophisticated executor. I think of it like this; a future is a computation we want to perform, the task schedules that futures execution, and the executor ensures that all of the tasks are completed, and coordinates interdependencies.

Incidentally, the reason the join!() macro exists is to allow a more flexible composition of tasks. For example, if we were to swap out block_on() for a more complex executor, we wouldn't need to make any other changes to our async code.

This was part 2 in a 3 part series. I’ve tried to present this information in a simple way while still giving enough details to provide a basic understanding. It turned out to be a bit more difficult than I anticipated. In the next section, we’ll take a look at a more realistic implementation of Rust async, and talk about some of the different runtimes available, as well as some of the gotchas. If you made it this far, give me a clap or a follow so I know if you found this useful, and follow me to find out when the next article in the series gets published!

Part 3 of this series can be found here.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Clay Ratliff

Clay Ratliff

27 Followers

I am a jack of all trades and a master of none. An all-around neophile, currently disguised as a Solution Architect.