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
Futuretrait; its one required method ispoll, returningReady(finished) orPending(not finished yet). - Constructing a future runs nothing — only a
pollcall 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.
ContextandWakerare 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.