
Node to Rust, Day 18: Async in Rust
December 18, 2021
Introduction
Rust’s async story has its good parts and bad parts. Futures (Rust’s promises) are a core part of Rust. Actually being able to use them is not. That’s weird, let’s break it down.
Rust’s standard library defines what an asynchronous task needs to look like with the Future
trait. But implementing Future
isn’t enough to be “async” on its own. You need something that will manage them. You need a futures bucket that checks which futures are done and notifies what’s waiting on them. You need an executor and a reactor, kind of like node.js’s event loop. You don’t get that with Rust. The Rust team left it to the community to decide how best to flesh out the async ecosystem. It may seem nuts, but did you know there was a time where JavaScript didn’t have promises? JavaScript had this problem in reverse. It had the executor and reactor but no way to represent a task. The community had to define what a Promise was. We were left polyfilling them until they landed in ES6.
In Rust, you have to polyfill async. Maybe it’ll exist in Rust core someday, but that’s not relevant. The important part is figuring out which polyfills exist now and how to get started ASAP.
Quick links
- Day 1: From nvm to rustup
- Day 2: From npm to cargo
- Day 3: Setting up VS Code
- Day 4: Hello World (and your first two WTFs)
- Day 5: Borrowing & Ownership
- Day 6: Strings, part 1
- Day 7: Syntax and Language, part 1
- Day 8: Language Part 2: From objects and classes to HashMaps and structs
- Day 9: Language Part 3: Class Methods for Rust Structs (+ enums!)
- Day 10: From Mixins to Traits
- Day 11: The Module System
- Day 12: Strings, Part 2
- Day 13: Results & Options
- Day 14: Managing Errors
- Day 15: Closures
- Day 16: Lifetimes, references, and
'static
- Day 17: Arrays, Loops, and Iterators
- → Day 18: Async
- Day 19: Starting a large project
- Day 20: CLI Arguments & Logging
- Day 21: Building and Running WebAssembly
- Day 22: Using JSON
- Day 23: Cheating The Borrow Checker
- Day 24: Crates & Tools
The code in this series can be found at wasmflow/node-to-rust
This guide is not a comprehensive Rust tutorial. This guide tries to balance technical accuracy with readability and errs on the side of “gets the point across” vs being 100% correct. If you have a correction or think something needs deeper clarification, send a note on Twitter at @jsoverson, @candle_corp, or join our Discord channel.
Rust async libraries
- Tokio (repo) (crates.io) (docs.rs)
- Async-std (repo) (crates.io) (docs.rs)
- Smol (repo) (crates.io) (docs.rs)
There are more, but this is already enough. Every library has their audience and there’s little point debating which one is “best.” What’s important right now is which will be the easiest to deal with. That’s Tokio. There are libraries that depend on Tokio’s behavior which means you can’t (easily) use them without also using Tokio’s executor. It’s a bit of a hostage situation. Tokio’s not bad though. It has documentation, loads of community contributions, and there’s a lot of code to learn from. It’s just a weird situation to be in when you’re used to async JavaScript and node.js.
Did you know that there used to be many other server-side JavaScript implementations before node.js? Some of them were even single-threaded and required you to deal with blocking logic by forking. Tokio is kind of like node.js. It’s an async implementation with a core set of async methods you can rely on. Smol is kind of like deno. It’s newer, has an answer for interoperability, but promises to be faster and better.
Quickstart
Add Tokio as a dependency in your Cargo.toml
with the full
feature flag.
[dependencies]
tokio = { version = "1", features = ["full"] }
Feature flags expose conditional compilation to users of a library. All tags are arbitrary, “full” doesn’t mean anything special. In some libraries feature flags are used to turn on or off platform-specific code. In others like Tokio, it’s used to conditionally require what amounts to sub-crates. Tokio used to be split up into many small modules. Community feedback changed that course and now we use feature flags.
When you get more comfortable, you can read up on the feature flags and trim it down to what you need.
You must starting an executor before running your futures. Tokio gives you a handy macro that sets everything up behind the scenes. You add it to main()
and voila, you get a fully async Rust.
#[tokio::main]
async fn main() { // Notice we write async main() now
}
async/.await
Rust has an async/await style syntax like JavaScript. Adding async
turns a function’s return value from T
into impl Future<Output = T>
, e.g.
fn regular_fn() -> String {
"I'm a regular function".to_owned()
}
async fn async_fn() -> String { // actually impl Future<Output = String>
"I'm an async function".to_owned()
}
Unlike JavaScript, the await
syntax must be appended to an actual future. It isn’t prepended nor can it be used on arbitrary values. It looks like this.
#[tokio::main]
async fn main() {
let msg = async_fn().await;
}
Also unlike JavaScript, your futures don’t run until you await them.
#[tokio::main]
async fn main() {
println!("One");
let future = prints_two();
println!("Three");
// Uncomment and move the following line around to see how the behavior changes.
// future.await;
}
async fn prints_two() {
println!("Two")
}
One
Three
Uncommenting the line above produces
One
Three
Two
async blocks
Asynchronous behavior and closures are part of every developer’s toolbox. You will inevitable get to a point where you try to return a closure that’s also async
and you run into this error:
error[E0658]: async closures are unstable
--> src/send-sync.rs:6:15
|
6 | let fut = async || {};
| ^^^^^
|
= note: see issue #62290 <https://github.com/rust-lang/rust/issues/62290> for more information
= help: to use an async block, remove the `||`: `async {`
Async closures are unstable but the helper text advises you to use async blocks. Async blocks?
That’s right, any old block of code in Rust can be async all on its own. They implement Future
and can be returned from functions just like any other value:
#[tokio::main]
async fn main() {
let msg = "Hello world".to_owned();
let async_block = || async {
println!("{}", msg);
};
async_block().await;
}
You can get all the parts of an async closure you need by making a closure that returns an async block!
#[tokio::main]
async fn main() {
let msg = "Hello world".to_owned();
let closure = || async {
println!("{}", msg);
};
closure().await;
}
Send + Sync
Using threads and futures combined with traits will rapidly get you into a situation where you start seeing Rust complain about Send
and Sync
, frequently combined with the error future cannot be sent between threads safely
.
The code below won’t compile. It demonstrates a scenario that produces the error.
use std::fmt::Display;
use tokio::task::JoinHandle;
#[tokio::main]
async fn main() {
let mark_twain = "Samuel Clemens".to_owned();
async_print(mark_twain).await;
}
fn async_print<T: Display>(msg: T) -> JoinHandle<()> {
tokio::task::spawn(async move {
println!("{}", msg);
})
}
error: future cannot be sent between threads safely
--> src/send-sync.rs:12:5
|
12 | tokio::task::spawn(async move {
| ^^^^^^^^^^^^^^^^^^ future created by async block is not `Send`
|
note: captured value is not `Send`
--> src/send-sync.rs:13:24
|
13 | println!("{}", msg);
| ^^^ has type `T` which is not `Send`
note: required by a bound in `tokio::spawn`
--> /Users/jsoverson/.cargo/registry/src/github.com-1ecc6299db9ec823/tokio-1.15.0/src/task/spawn.rs:127:21
|
127 | T: Future + Send + 'static,
| ^^^^ required by this bound in `tokio::spawn`
help: consider further restricting this bound
|
11 | fn async_print<T: Display + std::marker::Send>(msg: T) -> JoinHandle<()> {
| +++++++++++++++++++
Send
and Sync
are core to how Rust can promise “fearless concurrency”. They are automatic traits. That is, Rust automatically adds Send
or Sync
to a type if all of its constituent types are also Send
or Sync
. These traits indicate whether a type can be sent safely across threads or safely accessed by multiple threads. Without these constructs, you could fall into situations where separate threads clobber each other’s data or any number of other problems stemming from multi-threaded programming.
Lucky for you though, many Rust types are already Sync
and Send
. You just need to know how to get rid of the error. It’s as simple as adding + Send
, + Sync
, or + Sync + Send
to your trait:
fn async_print<T: Display + Send>(msg: T) -> JoinHandle<()> {
tokio::task::spawn(async move {
println!("{}", msg);
})
}
But now we’re presented with another error…
error[E0310]: the parameter type `T` may not live long enough
--> src/send-sync.rs:12:5
|
11 | fn async_print<T: Display + Send>(msg: T) -> JoinHandle<()> {
| -- help: consider adding an explicit lifetime bound...: `T: 'static +`
12 | tokio::task::spawn(async move {
| ^^^^^^^^^^^^^^^^^^ ...so that the type `impl Future` will meet its required lifetime bounds...
|
We went over 'static
in Day 16: Lifetimes, references, and 'static
which is perfect. We now know that we shouldn’t fear it. Rust knows that it doesn’t know when our async code will run. It’s telling us that our type (parameter type `T`
) may not live long enough. That wording is important. We just need to let Rust know that the type can last forever. Using 'static
here is not saying that it will last forever.
fn async_print<T: Display + Send + 'static>(msg: T) -> JoinHandle<()> {
tokio::task::spawn(async move {
println!("{}", msg);
})
}
There’s a lot more to Send & Sync but we’ll deal with that another time.
Additional reading
Wrap-up
Async Rust is beautiful. There’s enough to write a whole twenty-four posts on it alone. Rust’s memory guarantees mean you can write multi-threaded, asynchronous code and be confident it won’t explode in your face. This is where you start to turn up the heat and leave JavaScript behind. Yes, you can use threads in node.js and there are web workers but it’s a weird middle ground. It’s a compromise. With Rust, we don’t have to compromise.
How are you handling the transition to Rust? It’s not easy but it’s doable. If you have questions, you can reach me personally on twitter at @jsoverson, the Candle team at @candle_corp, or join our Discord channel.
Written By
