A look back at asynchronous Rust

General introduction

I feel obliged to add an introduction paragraph, given the discussions and controversies that have happened around asynchronous Rust recently and over time.

Future cancelling problem

I’m going to start with what I think is the most problematic issue in asynchronous Rust at the moment: knowing whether a future can be deleted without introducing a bug.

async fn read_send(file: &mut File, channel: &mut Sender<...>) {
loop {
let data = read_next(file).await;
let items = parse(&data);
for item in items {
channel.send(item).await;
}
}
}
let mut file = ...;
let mut channel = ...;
loop {
futures::select! {
_ => read_send(&mut file, &mut channel) => {},
some_data => socket.read_packet() => {
// ...
}
}
}
  • Rewrite the select! to not destroy the future. Example. This is arguably the best solution in that specific situation, but it can sometimes introduce a lot of complexity, for example if you want to re-create the future with a different File when the socket receives a message.
  • Ensure that read_send reads and sends atomically. Example. In my opinion the best overall solution, but this isn’t always possible or would introduce an overhead in complex situations.
  • Change the API of read_send and avoid any local variable across a yield point. Example. Real world example. This is also a good solution, but it can be hard to write such code, as it starts to become dangerously close to manually-written futures.
  • Don’t use select! and spawn a background task to do the reading. Use a channel to communicate with the background task if necessary, as pulling items from channels is cancellation-resilient. Example. Often the best solution, but adds latency and makes it impossible to access file and channel ever again.

The Send trait isn’t what it means anymore

The Send trait, in Rust, means that the type it is implemented on can be moved from a thread to another. Most of the types that you manipulate in your day-to-day coding do implement this trait: String, Vec, integers, etc. It is actually easier to enumerate the types that do not implement Send, and one such example is Rc.

fn background_task() {
let rc = std::rc::Rc::new(5);
let rc2 = rc.clone();
bar();
}
async fn background_task() {
let rc = std::rc::Rc::new(5);
let rc2 = rc.clone();
bar().await;
}

Flow control is hard

Many asynchronous programs, including Substrate, are designed around what we generally call an actor model. In this design, tasks run in parallel in the background and exchange messages between each other. When there is nothing to do, these tasks go to sleep. I would argue that this is how the asynchronous Rust ecosystem encourages you to design your program.

“Just spawn a new task”

As explained in the “Future cancellation problem” section, the most straight-forward solution to cancellation problems is often to spawn an additional background task. In particular, when you have more than 2 or 3 futures to poll in parallel, spawning additional background tasks tends to solve all problems.

Program introspection

An unfortunate consequence of switching everything from synchronous to asynchronous, and that we didn’t anticipate enough when writing Substrate, is that every single debugging tool in the Unix world assumes threads.

Example graph showing how much CPU each task uses

tokio vs async-std vs no-std

I’ve left this topic for the end.

Conclusion

That’s already a lot. I didn’t go into a lot of small details, such as how much I would like to have an InfiniteStream trait, how rustfmt isn’t capable of formatting code within select!, or how there isn’t any no-std-friendly asynchronous Mutex anywhere in the ecosystem, as I imagine that this will all be covered by other people.

--

--

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