Lesson 08 / 10
Executors
Rust's standard library has no executor; the course's own two hand-written executor implementations produce identical poll counts when they run the same task — an executor adds nothing to a task's body, it only decides when to poll it.
Published Updated
In this lesson
- Shows that an executor is a component that decides only when a task is polled, independent of what the task does
- Runs the same task type through two different executor implementations and confirms that the poll count does not change
- Explains the difference between this lesson's "who advances it" question and the axis the Traits, Generics and Error Handling course measured — how many times a body ran
Contents
The previous two lessons used run as a given tool: a future went in, and the result and the
poll count came back out. The mechanism in between was never questioned. This lesson opens up
exactly that mechanism. Rust’s standard library has no executor — Future, Pin, Context,
Poll, and Waker define a contract, but they make no decision at all about who calls that
contract, or when. The code filling this gap sits hand-written in the course’s measurement
core, and this lesson takes it as its direct subject.
This gap is not a shortcoming of the language, it is a deliberate boundary. An executor’s
right decision depends on which program is running: a program with a small number of
long-running tasks wants one kind of scheduling, a program with thousands of short tasks wants
another. Rather than impose a single policy, the language standardizes the contract (Future,
Poll, Waker) and leaves scheduling to a separate component. This lesson builds two
different executors and runs the same tasks through both, to show what this separation means
in the source: swapping the executor does not change a single line of the code defining the
task.
The Executor Is the Part Left Outside the Contract
The Future trait declares only the poll method; it says nothing anywhere about who calls
this method, in what order, or how often. This gap is deliberate: the language itself does not
impose a single scheduling policy, it leaves scheduling to a separate component — the
executor. The following excerpt defines two different executors. The first is run, used in
the previous lessons — an implementation that waits on a condition variable until woken. The
second is run_loop — an implementation that never waits, re-polling immediately as soon as
it gets Pending back.
// advance.rs — a slice of the core: two different executor implementations, the same task types.
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();
}
}
// executor 1: waits until woken
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 EmptyNotifier;
impl Wake for EmptyNotifier {
fn wake(self: Arc<Self>) {}
}
// executor 2: does not wait for the wake signal, immediately re-polls
pub fn run_loop<F: Future>(task: F) -> (F::Output, u32) {
let mut task = Box::pin(task);
let n = Arc::new(EmptyNotifier);
let waker: Waker = n.clone().into();
let mut context = Context::from_waker(&waker);
let mut poll_count = 0;
loop {
poll_count += 1;
if let Poll::Ready(x) = task.as_mut().poll(&mut context) {
return (x, poll_count);
}
}
}
pub struct PausesOnce {
pub paused: bool,
pub name: &'static str,
pub channel: Sender<String>,
}
impl Future for PausesOnce {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<&'static str> {
if self.paused {
self.channel.send(self.name.to_string()).unwrap();
Poll::Ready(self.name)
} else {
self.paused = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
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)
}
}
Both executors follow the same three steps: increment the poll counter, call poll, evaluate
the result. The only place they differ is what they do on a Pending return. run genuinely
waits on the condition variable that Notifier carries, and only resumes once wake is
called. run_loop uses a type named EmptyNotifier, whose wake call does nothing, and it
calls poll again without waiting every time it gets Pending. Both conform to the same
Future contract; both are valid executors. The difference between them is not in the task
itself, it is in when they choose to poll it again.
The difference between the two Wake implementations reflects this same choice. Notifier
carries a logical flag and a condition variable, genuinely delivering the wake call to the
waiting side — whoever calls it, run will see this call and wake up. EmptyNotifier, by
contrast, never uses wake at all; its body is empty, because run_loop already re-polls
without waiting for the wake call. The Waker itself is still handed to both future types —
PausesOnce‘s poll calls wake_by_ref unconditionally, without knowing which executor’s
Wake implementation its Context carries. Conforming to the contract demands exactly this
much; anything beyond that is the executor’s own decision.
Both executors hold their own notifier inside an Arc. The shared ownership the Ownership and
the Memory Model course measured finds exactly its use here: the Waker passes into the
future’s poll method as a reference from within Context::from_waker, but the notifier
itself also needs to go on living in the executor’s own loop. Arc lets the notifier exist
inside the Waker and in the executor’s own variable at the same time — whichever is dropped
first, the other can keep using its own copy. EmptyNotifier follows the same arrangement,
even though the data it holds is empty.
Same Task, Two Executors, Same Poll Count
The following program constructs both a task that pauses once and one that is ready immediately, from the same definitions, and runs each of them through two separate executors.
// two-executors.rs — the same task type, two different executors, the same poll count.
mod advance;
use advance::{run, run_loop, PausesOnce, ImmediatelyReady};
use std::sync::mpsc::channel;
fn main() {
let (sender, receiver) = channel::<String>();
let (value_a1, poll_count_a1) = run(PausesOnce {
paused: false,
name: "pauses-run",
channel: sender.clone(),
});
let (value_a2, poll_count_a2) = run_loop(PausesOnce {
paused: false,
name: "pauses-loop",
channel: sender.clone(),
});
let (value_b1, poll_count_b1) = run(ImmediatelyReady {
name: "ready-run",
channel: sender.clone(),
});
let (value_b2, poll_count_b2) = run_loop(ImmediatelyReady {
name: "ready-loop",
channel: sender.clone(),
});
drop(sender);
let mut records: Vec<String> = receiver.try_iter().collect();
records.sort();
println!(
"pauses once: run={} run_loop={} (value {} / {})",
poll_count_a1, poll_count_a2, value_a1, value_a2
);
println!(
"ready immediately: run={} run_loop={} (value {} / {})",
poll_count_b1, poll_count_b2, value_b1, value_b2
);
println!(
"same poll count: pauses={} ready={}",
poll_count_a1 == poll_count_a2,
poll_count_b1 == poll_count_b2
);
println!();
println!("records ({}):", records.len());
for record in &records {
println!(" {}", record);
}
}
pauses once: run=2 run_loop=2 (value pauses-run / pauses-loop) ready immediately: run=1 run_loop=1 (value ready-run / ready-loop) same poll count: pauses=true ready=true records (4): pauses-loop pauses-run ready-loop ready-run
AP4. Four separate calls run two different task types through two different executors, and
the result stays the same: the task that pauses once finishes in two polls on both executors,
the task that is ready immediately finishes in one poll on both. This stayed identical across
five separate runs. PausesOnce‘s body never changed — it does not even know which executor
is calling it, it does nothing but call the Waker inside its Context. What determines the
poll count is the task’s own poll logic; the executor only decides how often to trigger
that logic.
How Many Tasks Run at the Same Time
Every call to run and run_loop takes a single root future and does not release the calling
thread until that future finishes. This is a simplified version of what a real executor
generally does: an executor usually holds a queue of more than one task at once, deciding which
one to poll by selecting from that queue. The four calls in this lesson — the PausesOnce and
ImmediatelyReady instances run through each of the two executors — run independently, one
after another; none of them is ever polled at the same time as another. Whether an executor
watches more than one task at once is a separate design question, and it falls outside this
lesson’s measurement — what is fixed here is the plainest form of advancing a single task. The
two executor implementations producing the same number is already enough to show that, even in
this plain form, the executor’s choice adds nothing to the body. The new question that arises
once an executor starts watching more than one task at once — which one gets polled first, how
long one keeps another waiting — does not change the single-task number this lesson measures;
it is a separate question, one concerning only the order between tasks, and it falls
outside this lesson.
An Executor Decides When to Poll, Not What Happens
The difference between run and run_loop never shows up in the observable part of the
run: the same values, the same poll counts, the same set of records. The difference lives only
in the — unprinted, and this course forbids printing it — time that passes between a Pending
return and the next poll call. run genuinely puts the calling thread to sleep for that
time; run_loop keeps that same thread in a busy loop. Both produce the correct result, but
one releases the processor while waiting, the other does not. This is the same question the
Threads topic measured — what a thread does while it waits — reappearing here at the executor
level; but this lesson does not measure it through duration, it measures it through
whether the poll count changes, and shows that it does not.
The Traits, Generics and Error Handling course measured how many times a body ran — every
time, only when needed, or never. This lesson asks a different question: who advances a
body that runs the same number of times. The executor is exactly the answer to that question —
a subject that has no say in how many times a body runs, only in when those runs happen.
run and run_loop play this subject role in two different ways, yet both trigger the same
number of times.
This choice between the two implementations is not trivial in a real program.
run_loop keeps the processor constantly busy with its busy loop; the moment a task returns
Pending, even if it is not ready, the loop keeps querying it again and again. run, by
contrast, releases the processor while it waits on the condition variable and only comes back
once wake is called. Neither changes the poll count measured in this lesson, because
PausesOnce’s decision to return Pending is already made by looking at the paused field —
it gives the same answer regardless of how often it is asked from outside. In a future genuinely
waiting on a resource (a reply from the network, data from a file), this difference becomes
even more pronounced, but it still has no effect at all on the number this lesson
measures — the next lesson models exactly such a resource.
The Same Executor, Two Different Future Types
Both run and run_loop were written generically with the F: Future bound; this is why the
same function can be called for both PausesOnce and ImmediatelyReady, without changing a
single line of the body. The Traits, Generics and Error Handling course measured that a generic
body is generated separately by the compiler for each concrete type; the same thing happens
here — run::<PausesOnce> and run::<ImmediatelyReady> are two separate bodies the compiler
produces. This lesson does not return to that axis, but it benefits from its result: the
executor’s code is written in one place, and it does not need to be rewritten for every new
future type. All four calls in two-executors.rs call the same two functions (run,
run_loop); none of them duplicates the executor’s body.
The Limiting Measurement
Changing the executor does not change the task’s body. PausesOnce’s poll method does not
even look at which executor is calling it — it looks at the value of the paused field and
makes its own decision. The same task produces the same record in both implementations; the
difference lives only in the polling schedule, not in the produced value or the record. This
limit shows what an executor cannot change: what a task produces, in how many steps it
produces it, or which way it branches. An executor can only change the timing of the poll
call; beyond that it has no authority over a task at all.
This limit becomes more visible in situations where more than one task coexists on the same
executor: an executor can decide which task gets polled first, but it cannot interfere with
how many times Pending a task returns within its own poll. This distinction stays hidden
in this lesson’s single-task examples, because there is no other task to determine the
order; but the rule already holds here too — run and run_loop follow very different
scheduling strategies, yet neither could change the decision that PausesOnce finishes in two
polls.
Summary
- Rust’s standard library has no executor; the loop that calls
pollis hand-written. - An executor is the component that decides what to do on a
Pendingreturn — waiting, re-polling immediately, or following some other strategy is the executor’s choice. - The same task type run through two different executors produces the same poll count: the executor adds nothing to the task’s body.
- The executor is the subject that advances a body; unlike the Traits, Generics and Error Handling course’s “how many times did it run” question, this lesson asks “who advanced it.”
- All four tasks run in this lesson fall into the when-polled class: none of them advances
on its own, all four need an executor’s
pollcall — only which executor makes that call changes, not the class itself. - The real difference between the two executors lives not in the run count, but in the
behavior during the — unprinted — time between a
Pendingreturn and the next poll;runandrun_loopwere written generically (F: Future), so the same executor body works with every future type.
Next Step
This lesson showed that an executor does not interfere with what a task produces, deciding
only when it is polled; two utterly different scheduling strategies produced an identical
poll count on the same task. Up to this point, every task was either ready immediately or
paused once and resumed on its own — in both cases, the reason for pausing was the task’s own
logic, not dependent on an outside condition. The next lesson takes up the case where an
outside resource — input or output — is not yet ready: a resource that is not ready returns
Pending, and wakes the executor once it is ready. What is measured is still the poll count,
but this time that number changes depending on how long the resource takes to become ready,
and this lesson reuses one of the two executors built here — run.
To keep your progress and take notes, Log in
My notes
Log in to take notes.