I find it easier to reason about how futures work by creating a high-level mental model we can use. To do that, I have to introduce the concept of a runtime that will drive our futures to completion.
Note
The mental model I create here is not the only way to drive futures to completion, and Rust’s futures do not impose any restrictions on how you actually accomplish this task.
A fully working async system in Rust can be divided into three parts:
- Reactor (responsible for notifying about I/O events)
- Executor (scheduler)
- Future (a task that can stop and resume at specific points)
So, how do these three parts work together?
Let’s take a look at a diagram that shows a simplified overview of an async runtime:
Figure 6.1 – Reactor, executor, and waker
In step 1 of the figure, an executor holds a list of futures. It will try to run the future by polling it (the poll phase), and when it does, it hands it a Waker. The future either returns Poll:Ready (which means it’s finished) or Poll::Pending (which means it’s not done but can’t get further at the moment). When the executor receives one of these results, it knows it can start polling a different future. We call these points where control is shifted back to the executor yield points.
In step 2, the reactor stores a copy of the Waker that the executor passed to the future when it polled it. The reactor tracks events on that I/O source, usually through the same type of event queue that we learned about in Chapter 4.
In step 3, when the reactor gets a notification that an event has happened on one of the tracked sources, it locates the Waker associated with that source and calls Waker::wake on it. This will in turn inform the executor that the future is ready to make progress so it can poll it once more.
If we write a short async program using pseudocode, it will look like this:
async fn foo() {
println!(“Start!”);
let txt = io::read_to_string().await.unwrap();
println!(“{txt}”);
}
The line where we write await is the one that will return control back to the scheduler. This is often called a yield point since it will return either Poll::Pending or Poll::Ready (most likely it will return Poll::Pending the first time the future is polled).
Since the Waker is the same across all executors, reactors can, in theory, be completely oblivious to the type of executor, and vice-versa. Executors and reactors never need to communicate with one another directly.
This design is what gives the futures framework its power and flexibility and allows the Rust standard library to provide an ergonomic, zero-cost abstraction for us to use.
Note
I introduced the concept of reactors and executors here like it’s something everyone knows about. I know that’s not the case, and don’t worry, we’ll go through this in detail in the next chapter.