InnocentZero's Treasure Chest

HomeFeedAbout MeList of interesting people

01 Jul 2025

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.
Tags: rust programming

Other posts
Creative Commons License
This website by Md Isfarul Haque is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.