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:

  1. Check if we have completed the future. If not, check if we’ve started the async computation
  2. If not, spawn a thread in the background that will eventually complete the computation
  3. In that thread, mark ourselves as completed
  4. And wake up
  5. Then return a Pending state
  6. 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:

Sequence diagram for an executor and some future

Sequence diagram for an executor executing a future.

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:

Flow chart for executing futures

How an executor completes a future

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.


  1. 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. ↩︎

  2. 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. ↩︎