Skip to content
academia.sh

Lesson 06 / 10

Futures

A future is a lazy value that advances only when polled, not the moment it is constructed: of two tasks built from the same source, the one handed to the executor writes a record to the ledger, while the one withheld from the executor leaves no trace.

Published Updated

In this lesson

  • Shows that constructing a future does not advance it — it advances only when polled
  • Distinguishes the when-polled and not-at-all classes by handing one of two tasks built from the same source to the executor and withholding the other
  • Explains how this course's question — what a unit needs in order to advance — differs from the question of how many times a body ran
Contents

The Threads topic measured threads that protected shared state with a lock or an atomic operation; in both cases, once a unit was started it either advanced on its own or waited for something else to finish. This topic carries the same question — what does a unit need in order to advance — to a different kind of unit: the asynchronous task. At first the answer looks familiar, on its own or when something else finishes. But Rust’s asynchronous model raises a third possibility, and this lesson builds exactly that: a task can sit written in the source and never advance at all.

A thread is an execution unit the operating system allocates: the moment thread::spawn is called, something is born with its own stack and its own scheduling claim, and it keeps advancing even if the calling code never touches it again. An asynchronous task represents the exact opposite — a plain data value that carries no execution context of its own. Constructing an instance of ImmediatelyReady allocates no stack and claims no scheduling slot; what exists is an ordinary struct instance holding a few fields. Seeing this difference matters for the rest of the course: the Threads topic measured advancement as a decision made by the operating system; this topic measures advancement as a decision made by a piece of code in the source.

In Rust, an asynchronous task is a value that implements the standard library’s Future trait. This trait declares a single required method: poll. Called once, the method returns one of two things — Ready, wrapping the value the task produced, if the task has finished, or Pending if it has not. The Future trait is not conceptually unique to Rust; what sets it apart is who makes this decision. The code producing the value does not advance on its own — advancing it requires an outside call to trigger poll. The component making this call is called the executor, and the third lesson of this topic is devoted to it directly; here the executor is used only as a tool needed to advance a task, not as the subject of the lesson itself.

This laziness is not an arbitrary implementation detail — it is a decision at the center of the Future trait’s design. The Programming Fundamentals course introduced the concept of lazy evaluation: an expression’s value is computed only when actually needed. Rust’s futures apply the same principle to asynchronous work. Constructing a future is only writing a recipe — a record of which steps happen in which order. Actually running that recipe begins with a separate, explicit decision: handing it to an executor. The distinction matters, because otherwise constructing many futures at once would already be expensive: if each one started running the moment it was constructed, all of them would already be halfway done before anyone decided which task should take priority. Laziness places a boundary between constructing and running; that same boundary explains, in the closing lesson of this course, why a task left dropped can go on to never be polled again — a future sitting in the source that never advanced is already in the same state as this lesson’s _never_polled.

Constructing a Value Does Not Advance It

Constructing an instance of a type that implements Future looks no different from constructing an ordinary struct instance: fields are filled in, the value is bound to a variable. The difference is that nothing runs at the moment of construction. The following excerpt defines the plainest possible future — one that completes the moment it is ready — and the plainest executor needed to advance it.

// advance.rs — a slice of the core: a hand-written executor, a future ready immediately.

use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, Wake, Waker};

pub struct Notifier {
    pub ready: Mutex<bool>,
    pub condvar: Condvar,
}

impl Wake for Notifier {
    fn wake(self: Arc<Self>) {
        *self.ready.lock().unwrap() = true;
        self.condvar.notify_one();
    }
}

pub fn run<F: Future>(task: F) -> (F::Output, u32) {
    let mut task = Box::pin(task);
    let n = Arc::new(Notifier {
        ready: Mutex::new(false),
        condvar: Condvar::new(),
    });
    let waker: Waker = n.clone().into();
    let mut context = Context::from_waker(&waker);
    let mut poll_count = 0;
    loop {
        poll_count += 1;
        match task.as_mut().poll(&mut context) {
            Poll::Ready(x) => return (x, poll_count),
            Poll::Pending => {
                let mut ready = n.ready.lock().unwrap();
                while !*ready {
                    ready = n.condvar.wait(ready).unwrap();
                }
                *ready = false;
            }
        }
    }
}

pub struct ImmediatelyReady {
    pub name: &'static str,
    pub channel: Sender<String>,
}

impl Future for ImmediatelyReady {
    type Output = &'static str;
    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<&'static str> {
        self.channel.send(self.name.to_string()).unwrap();
        Poll::Ready(self.name)
    }
}

The run function hand-builds a piece that is not part of Rust’s standard library: a Waker, a Context, and a loop that calls poll repeatedly. The executor’s body is given in this lesson only as a tool; what is actually measured is the ImmediatelyReady type itself. Before poll is called, an instance of this type is indistinguishable from an ordinary value sitting in memory — no field runs, nothing is sent down any channel. Only when poll is called does channel.send fire and the value get produced.

The run loop repeats three steps: increment the poll counter, call poll, evaluate the result. If it returns Ready, the loop ends and the total poll count is returned along with the value. If it returns Pending, the loop waits on a condition variable for as long as the logical flag carried by Notifier reads false — this is a real sleep for the thread, not a busy loop, and it belongs to the same family of locking primitives introduced in the Mutex and Read-Write Lock lesson. The while loop is used instead of a single if because a condition variable can wake up even without ever receiving a wake call; rechecking the flag ignores this possible spurious wakeup. The Waker is carried as a clone of an Arc<Notifier> — because a task can return Pending more than once within a single run, the same Notifier has to be shareable across multiple poll calls.

The following program constructs two tasks from the same source — the same ImmediatelyReady definition. One is handed to run, the other is only bound to a variable and never touched again.

// lazy.rs — two tasks from the same source: one handed to the executor, the other untouched.
mod advance;
use advance::{run, ImmediatelyReady};
use std::sync::mpsc::channel;

fn main() {
    let (sender, receiver) = channel::<String>();

    // two separate tasks constructed from the same source (the ImmediatelyReady definition)
    let polled = ImmediatelyReady {
        name: "task-polled",
        channel: sender.clone(),
    };
    let _never_polled = ImmediatelyReady {
        name: "task-never-polled",
        channel: sender.clone(),
    };

    // only one of them is handed to the executor
    let (value, poll_count) = run(polled);

    drop(sender);
    let records: Vec<String> = receiver.try_iter().collect();

    println!("returned value: {}", value);
    println!("poll count: {}", poll_count);
    println!("record count: {}", records.len());
    for record in &records {
        println!("  {}", record);
    }
}
returned value: task-polled
poll count: 1
record count: 1
  task-polled

AP1. There is exactly one record in the ledger. The _never_polled variable is written in the source, its type is ImmediatelyReady, its fields are filled in — but no line was printed for it, because poll was never called. The only difference between the two tasks is whether each was handed to the executor; their source code, their types, and their field values come from the exact same identical construction. The record count stayed identical across five separate runs: this is not a race, it is a behavior.

The topic’s shared measurement core splits thirteen units into the same four classes: five advance on their own, three when polled, three when something else finishes, two not at all. The two units in this lesson — task-polled and task-never-polled — are one example each of that thirteen’s when-polled and not-at-all classes; the remaining classes appear in the Threads topic and in the next lesson.

This is the first answer to the question this course asks. task-polled advanced when polled: not on its own, but because run called it. task-never-polled advanced not at all — it sat in the source and never once received a poll. The Traits, Generics and Error Handling course counted how many times a body ran — every time, only when needed, or never. This course asks a different question from that same triple: not how many times it ran, but who advances it so that it can run. The subject that advances task-polled is run; the ImmediatelyReady type itself makes no decision on this front, it only says what to do once called.

A Task Also Produces a Value — It Is Not an Ordinary Call

An ordinary function call (f(x)) runs the moment it is called; the caller waits until the return value comes back. ImmediatelyReady’s poll method looks similar — it delivers its result the moment it is called — but this is not the general rule of the Future trait, it is specific to ImmediatelyReady. The general rule is this: a future, once poll is called, either finishes (Ready) or says it has not finished yet (Pending) and leaves itself for the next poll call. ImmediatelyReady always finishes on the first call because it has nothing to wait for; the future type built in the next lesson returns Pending at least once. These two extremes are two different implementations of the same trait — the contract is the same, the behavior differs.

The signature of poll carries this same difference: fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>. Context is the one channel the executor gives to the future — it carries a Waker, and once the future considers itself ready for the next poll, it can call this Waker to notify the executor. ImmediatelyReady never uses it because it never returns Pending; the next lesson builds a future that uses this channel directly.

The Waker itself carries a lazy contract of its own: calling wake runs no code instantly, it only leaves a signal for the executor saying “poll this task again.” The Notifier type in run holds this signal as a logical flag and a condition variable: once the wake call arrives, it sets the flag to true and wakes the waiting loop. If a future returns Pending and never makes this call, the executor waits forever — this is the future’s own responsibility, not the executor’s. ImmediatelyReady carries no such risk because it never returns Pending; the future type built in the next lesson fulfills this contract correctly by making the wake call on every Pending return.

run places the task into a Box::pin. There is no reason for this in ImmediatelyReady itself — this simple type causes no trouble if it moves. The reason shows up in the async fn bodies built in the next lesson: the compiler turns an async fn into a state machine that waits on another future inside itself, and this machine can carry references pointing into its own fields. If such a value moves in memory, the references it carries go stale; Pin is a guarantee that tells the compiler a value’s address stays fixed between poll calls. ImmediatelyReady does not need this guarantee, but run follows the same path for every Future, because a generic function cannot know which future carries self-references and which does not. The async syntax introduced in the next lesson also follows the laziness rule: calling an asynchronous function does not run its body, it only produces a future value — the body actually running, exactly as with ImmediatelyReady here, waits for that value to be polled.

One more detail is worth noting: run itself is not asynchronous, it is an ordinary function call. When the run(polled) line inside main executes, the calling thread stays in this call until the future finishes — just like the main thread waiting for a join in the Threads topic. Asynchrony here does not reduce the number of threads; it opens a way to advance more than one future on the same thread, sequentially or nested. This distinction lays the groundwork for understanding, in later lessons, how more than one task can coexist on the same executor.

poll Can Also Be Called Directly

run calls poll inside a loop, but this is not the only way to call it — poll is an ordinary method, and any code holding a valid Context can call it. If polled.poll(&mut context) had been written directly in place of run, the return value would still have been Poll::Ready("task-polled") — because ImmediatelyReady finishes on the first call, and how many times the loop runs does not matter to it. The executor’s actual job is invisible in this example: for a future that a single call satisfies, the executor is merely a go-between. The difference shows up in the next lesson, in a future type that returns Pending more than once — there, knowing when to call poll again shifts from hand-written code to the executor’s responsibility. This is exactly where the course’s question comes back in: what advances a task is not its own logic, but the code that calls it.

The Limiting Measurement

An unpolled task is not an error. _never_polled compiles, sits in memory as a variable, and gets dropped when its scope ends — and through all of this it produces no error, no warning. The compiler does not track whether a Future was ever actually polled; tracking that is the programmer’s or the executor’s job. This is a different kind of silence from the course’s ban on environment-dependent data: what is missing here is not a record, it is advancement that was never even requested. The code’s own behavior shows why the “not at all” class is not a separate error form — something written in the source can exist without leaving a trace, for as long as it is never run.

This lesson’s “not at all” example belongs to the same family as one of the two “not at all” units in the topic’s shared measurement core: one is a closure that is never invoked, the other is a task that is never polled — both are written in the source, both compiled, neither left a trace. The Threads topic showed the same silence when a unit was left unspawned; the difference is that there, the responsible call was thread::spawn never being called, here it is poll never being called. Both tie back to the same rule: being written in the source does not mean having advanced.

Summary

  • A future is a value that implements the Future trait; its one required method is poll, returning Ready (finished) or Pending (not finished yet).
  • Constructing a future runs nothing — only a poll call advances it. This is the basis for a task falling into the when-polled class.
  • Of two tasks constructed from the same source, the one withheld from the executor advanced not at all: it was written in the source, it compiled, but it produced no record.
  • The subject that advances a task is the executor, not the task itself — a different axis from the Traits, Generics and Error Handling course’s count of how many times a body ran.
  • Context and Waker are the channel that lets a future tell the executor “notify me when I am ready”; this lesson introduced it, the next lesson puts it to use.
  • A thread is an execution unit the operating system allocates; a future is a plain data value — it carries no execution context of its own, it advances only for as long as the executor polling it keeps calling it.

Next Step

This lesson showed the difference between constructing a task and advancing it, through ImmediatelyReady — a future that never pauses, finishing on the first call. This was the plainest possible kind of future: a single poll call is enough, Pending never comes back. The next lesson extends the same contract to a future that pauses once: how a suspension point becomes visible in the source, how the async/await syntax builds these points, and which class a task waiting on another task falls into.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close