How does async Rust work
Rust has a burgeoning async system. If your application is heavy on IO, you should simply “use
async” and everything will work efficiently. You can have async fn
, .await
whenever that could
be worked on in the background while the CPU does something useful. Then you learn to add
Tokio for it to do anything and things may seem like magic. Fortunately, computers do not
work by magic yet, so we can try to simplify things and get a better understanding. Today I want to
do just that.
The very quick version is that asynchronous functions in Rust are just syntactical sugar for regular
functions, but instead of directly returning the value it gives you a complex state machine that
implements the Future
trait.
1
2
3
4
5
6
7
8
9
// The following function
async fn foo() -> i32 {
// …
}
// Actually desugars to
fn foo() -> impl Future<Output = i32> {
// …
}
The compiler then generates an appropriate type and implementation. That is helpful, but it’s still
overly complicated. I will focus on what I think are the two most important components, the
Future
trait and the executor, and finally show my minimal
executor implementation that can be used to run your async code. Let’s start with the trait first.
The Future
trait
The Future
trait, while fundamental to how async Rust works, is a rather simple one.
It’s so simple, here it is in its entirety:
1
2
3
4
5
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
The Output
associated type denotes what type the future will eventually return, and the poll
method will check whether the asynchronous process is complete yet and will return either
Poll::Ready(Self::Output)
if it was complete or Poll::Pending
if it’s not. That Pin<&mut Self>
has complicated reasons for its existence that we will largely ignore in this article.
Crucially, a Future by itself does nothing. It should return quickly with either a Poll::Pending
or Poll::Ready
so that the program can either continue to check other futures or go back to sleep.
The actual work of the future should happen elsewhere.
The only other interesting thing about the poll
method is that it takes a Context
method. The
context one purpose as of the time of writing: to provide you with (a reference to) a Waker
that
can later wake the Executor. We will talk about that next.
An example Future
So async functions get turned into impl Future
types, but something has to be async in the end. To
illustrate this, let’s look at a simple future that returns nothing, but spawns a thread and
completes when the thread it spawned is done.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct ThreadedFuture {
started: bool,
completed: Arc<AtomicBool>
};
impl std::future::Future for ThreadedFuture {
type Output = ();
fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 1. Check if the completion was already done
if !self.completed.load(Ordering::Acquire) {
if !self.started {
// 2. If we have not completed and haven't started the thread, start a background thread.
self.started = true;
let completer = Arc::clone(&self.completed);
let waker = cx.waker().clone();
thread::spawn(move || {
// 3. First mark the future as completed
completer.store(true, Ordering::Release);
// 4. Then wake up the executor
waker.wake();
});
}
// 5. Now we've set up the async completion but haven't finished yet, so return Pending.
Poll::Pending
} else {
// 6. Already completed, so return.
Poll::Ready(())
}
}
}
There is a bunch of boilerplate here, but the basics are relatively simple:
- Check if we have completed the future. If not, check if we’ve started the async computation
- If not, spawn a thread in the background that will eventually complete the computation
- In that thread, mark ourselves as completed
- And wake up
- Then return a
Pending
state - Finally, return the
Ready
state if we turned out to have completed.
The Executor
The other piece of the async puzzle is the executor. Executors primarily need to poll futures, go to
sleep when there’s nothing to do, and provide a suitable Waker
to be woken up with. Many
implementations, such as tokio
, provide a ton of additional functionality, but deep down all it
needs to do is complete the following steps:
When the user provides a Future
to the executor, the executor will start polling it until it
finally returns a value. This polling is not a busy loop; instead, the executor will wait until it
receives a wake-up.1 This signal can come from any thread, or even a simple C-style signal
handler, and will generally be specific to the asynchronous process underpinning the operation.
Alternatively, in flow chart form:
For the implementation of the executor, it doesn’t matter where the signal comes from. As long as it causes a wake-up, the future should be polled again. With this knowledge, we can construct a basic executor. Except we don’t have to; in the documentation of the standard library you can find an intentionally somewhat buggy executor implementation2 that implements just this.
I could write a correct sample executor like I did an example future, but it so happens I already did and published it as a crate. Let’s have a look at that now.
Introducing Beul
The main reason for this article is the 1.0.0 release of Beul. Beul is a safe, minimalistic futures executor based on the ideas above. It is so minimalistic, it is only 84 lines of (commented) code and its entire public API looks like this:
1
pub fn execute<T>(f: impl Future<Output = T>) -> T
The code is relatively straightforward from there and if you’re still trying to grasp the idea of async execution I recommend you check it out. The obvious question is then, what would one use this for, instead of a fully featured async framework? The main reasons are writing tests of executor-agnostic async code, and working with async libraries in largely synchronous code.
It is possible to nest calls to beul::execute
, where you call some sync code from your async code
which in turn uses Beul to call async code. In many cases, it is still better for performance to not
do this, and make sure to .await
any asynchronous function calls whenever possible.
Prior art
Beul is hardly the first minimal futures executor. Many crates implement the same thing with similar ideas. It seems appropriate to acknowledge the ones I am aware of, and why I think Beul might be a better choice.
-
Pollster provides largely the same implementation as this crate, with one minor use of unsafe code that can be removed as of Rust 1.68. It uses an extension trait to provide its blocking functionality. It has recently also gained a set of (optional) procedural macros that allow you to annotate async
main()
or tests in a way where they can be executed normally. Beul is slightly more minimal and uses dynamic dispatch to reduce code size. -
futures-executor provides several executors as well as utilities to make working with futures simpler. Its
block_on
executor is similar to Beul’s API, though calls to it can intentionally not be nested. It’s also quite a large crate. -
extreme has the same API as Beul but predates the
Wake
trait and as such has to use unsafe Rust for its executor implementation.extreme
is also licensed under the GNU Public License which may make it unsuitable for many applications. The versioning scheme is somewhat peculiar though fine. -
Yet Another Async Runtime (or
yaar
) and safina-executor both provide more of a general executor framework rather than simply something that executes futures. They can make sense if you need more, but for simple future execution, Beul should suffice.
And if that doesn’t convince you, Beul extreme are the only ones among these who have moved to or past 1.0.0, which should make the fine and sarcastic people of ZeroVer happy.
The future
With the current state of standard-library asynchronous Rust, I consider Beul “finished.” It executes futures, it does so efficiently, and there are (as of now) no major improvements possible to how it works.
Of course, that can all still change. Future Rust will surely have a more extensive async standard library and I’d like Beul to support it. For now, this is all.
In conclusion
Asynchronous Rust consists of executors that repeatedly poll futures until they are complete. When a future is still pending, the executor will go do something else, usually sleep, before it polls the future again. Repeat until completion.
With that, I hope I’ve provided an intuition for what is happening under the hood. Ideally, you will never need to look into the details, but when the abstraction gets too leaky, now you know. Also, I would appreciate you checking out Beul. And that’s it.
-
It is valid for any implementation to be a busy loop, as a future should behave normally under spurious polling. It would not be the best use of resources of course. ↩︎
-
This example is slightly oversimplified and subject to both spurious wake-ups (as
thread::park
can do that) and race conditions (since a wake-up can come in after a poll but before the executor parks) so it is not immediately useful. ↩︎