Skip to content
academia.sh

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 poll is hand-written.
  • An executor is the component that decides what to do on a Pending return — 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 poll call — 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 Pending return and the next poll; run and run_loop were 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close