Futures in Rust
How Rust futures work
Different types of concurrencies
- Threads provided by the OS: kernel threads are straightforward. However, syscalls are a problem, and control goes outside of the program to the OS for scheduling. Alongside comes a stack for all of the threads that's pretty huge.
- Threads managed by a runtime: Simple to use like OS threads. However, not as powerful and context switches can be expensive. Apart from that these are not zero-cost abstractions.
- Callback based approaches: Are we really gonna discuss this in 2025? This is simple and straightforward to implement from a runtime point of view and no context switching is involved, but the state sharing and reasoning about code can drive anyone crazy, especially with the borrow checker.
From Callbacks to Promises
Simplifies the callback based approach. Instead of this:
setTimer(200, () => {
setTimer(100, () => {
setTimer(50, () => {
console.log("I'm the last one");
});
});
});
we write this:
function timer(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } timer(200) .then(() => timer(100)) .then(() => timer(50)) .then(() => console.log("I'm the last one"));
A promise returns a state machine which can be in one of the three states: pending, fulfilled and rejected.
Now the same in await syntax looks like this:
async function run() { await timer(200); await timer(100); await timer(50); console.log("I'm the last one"); }
Each await is a yield point where it gives over control to the scheduler.
A future in rust - polling
A future in rust represents a pending operation. It has three phases:
- Poll phase: A future is polled which results in the task progressing up to a point where it can no longer progress. The executor polls the future.
- Wait phase: The reactor notes that the future is waiting for an event to happen and will wake the future up when the event is ready.
- Wake phase: Event happened, and the reactor woke the future up. Now executor can poll it again and make further progress until it completes or reaches a new point where it can't make any progress.
- Leaf futures: These are mostly created by the runtime. It's unlikely that the user of the runtime will create a leaf future/pass it to the runtime for completion.
- Non-leaf future: These are the kinds of futures we write. This is achieved by composing tasks using the async framework. These are pausable computations that represent a set of sub-tasks.
A mental model of an async runtime
This is not a necessity. So keep it in mind that there is no requirement for runtimes to adhere to this model. But this mode of thinking helps.
There are the three parts we mentioned above, the reactor, executor, and futures. They work using an object called the Waker. This waker is the one responsible for telling the executor that a certain future is ready to run.
- A waker is created by the executor.
- The future, when polled for the first time by the executor is handed a waker, which is shared by everyone.
- The future clones the waker and passes it to the reactor, which stores it for later use.
The await points act as yield points for the runtime, and the runtime determines how it can handle them.
- Create a new leaf future and send it to another thread, which can be awaited.
- Have a supervisor that monitors this and let it move the task to some other thread manually itself.
Generators - stackless coroutines
This is the basis of async await syntax in Rust. The largest benefit is the fact that it allows for borrowing across await points, which the promise and callback framework didn't. These are implemented in Rust as state machines.
Futures are equivalent to generators. This section gives a motivation behind pinning memory. We'll show manual invocations of polling (in this case it's called yielding) and what's returned for each poll. Suppose we have the following:
let A = move || { let s = String::from("foo"); let borrowed = &s; yield borrowed.len(); println!("{borrowed}"); };
This will be divided into executable chunks for the executor to poll. In this case, the chunks
would be start
, yield1
and fin
. They also represent the state of the generator.
When we poll on start
, we create the string s and then the borrow, but we need to store them
somewhere. So the generator state yield1
stores them as they're needed for the next chunk of
invocation. The lifetime would be tied to the object itself, so it's self-referential. Now
whenever we poll, we basically check the state, call the corresponding computation, and then
change the state of self
to reflect the upcoming computation. Since we're holding references
to ourselves, we can't really change the location in memory. That's where the problem lies, and
the Pin
mechanism solves exactly that.
Pinning
- Pinning can be done on the stack as well.
- Most types implement
Unpin
which means that they can be moved even when pinned. - We use
PhantomPinned
member function to mark it as!Unpin
- Pinning to the heap is safe as the heap memory will have a stable address.
- Getting a mutable reference to a pinned struct requires unsafe if the structure
is
!Unpin
.