๐ŸŽ“ A1Learner
๐Ÿ”

Rust Build an Executor

An executor is the worker that polls futures. This executor is a tiny single-threaded one that runs tasks from a queue.

The executor's whole job

Take a task, poll it once, keep it only if it is still Pending. recv() blocks while the queue is empty, so an idle executor uses no CPU.

while let Ok(task) = self.ready_queue.recv() {
    let mut future_slot = task.future.lock().unwrap();
    if let Some(mut future) = future_slot.take() {
        let waker = waker_ref(&task);
        let context = &mut Context::from_waker(&waker);
        if future.as_mut().poll(context).is_pending() {
            *future_slot = Some(future);
        }
    }
}

A task is a future plus a way back

The future sits in a Mutex<Option<...>> so poll can take it out and (maybe) put it back. The task_sender is the task's own doorbell back into the queue โ€” its waker sends through it.

struct Task {
    future: Mutex<Option<BoxFuture<'static, ()>>>,
    task_sender: SyncSender<Arc<Task>>,
}

wake() re-queues the task

This impl is the waker the executor hands to every poll. When the Delay future's timer thread calls wake(), this code runs โ€” and the task lands on the queue again. That is the entire notification system.

impl ArcWake for Task {
    fn wake_by_ref(arc_self: &Arc<Self>) {
        let cloned = arc_self.clone();
        arc_self.task_sender.try_send(cloned).expect("too many tasks queued");
    }
}

spawn: box it and queue it

Spawning wraps the future in an Arc<Task> and sends it once. That first send is what gets the task its first poll โ€” after that, only wake() can bring it back.

fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) {
    let task = Arc::new(Task {
        future: Mutex::new(Some(future.boxed())),
        task_sender: self.task_sender.clone(),
    });
    self.task_sender.try_send(task).expect("too many tasks queued");
}

Example

use futures::future::BoxFuture;
use futures::task::{waker_ref, ArcWake};
use futures::FutureExt;
use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::Duration;

// ---------- a small future to run: wait, then finish ----------
struct SharedState {
    completed: bool,
    waker: Option<Waker>,
}

struct Delay {
    state: Arc<Mutex<SharedState>>,
}

impl Delay {
    fn new(wait: Duration) -> Delay {
        let state = Arc::new(Mutex::new(SharedState { completed: false, waker: None }));
        let thread_state = Arc::clone(&state);
        thread::spawn(move || {
            thread::sleep(wait);
            let mut state = thread_state.lock().unwrap();
            state.completed = true;
            if let Some(waker) = state.waker.take() {
                waker.wake();
            }
        });
        Delay { state }
    }
}

impl Future for Delay {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        let mut state = self.state.lock().unwrap();
        if state.completed {
            Poll::Ready(())
        } else {
            state.waker = Some(cx.waker().clone());
            Poll::Pending
        }
    }
}

// ---------- the executor ----------
struct Task {
    future: Mutex<Option<BoxFuture<'static, ()>>>,
    task_sender: SyncSender<Arc<Task>>,
}

impl ArcWake for Task {
    fn wake_by_ref(arc_self: &Arc<Self>) {
        let cloned = arc_self.clone();
        arc_self.task_sender.try_send(cloned).expect("too many tasks queued");
    }
}

struct Executor {
    ready_queue: Receiver<Arc<Task>>,
}

impl Executor {
    fn run(self) {
        while let Ok(task) = self.ready_queue.recv() {
            let mut future_slot = task.future.lock().unwrap();
            if let Some(mut future) = future_slot.take() {
                let waker = waker_ref(&task);
                let context = &mut Context::from_waker(&waker);
                if future.as_mut().poll(context).is_pending() {
                    *future_slot = Some(future);
                }
            }
        }
    }
}

#[derive(Clone)]
struct Spawner {
    task_sender: SyncSender<Arc<Task>>,
}

impl Spawner {
    fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) {
        let task = Arc::new(Task {
            future: Mutex::new(Some(future.boxed())),
            task_sender: self.task_sender.clone(),
        });
        self.task_sender.try_send(task).expect("too many tasks queued");
    }
}

fn new_executor_and_spawner() -> (Executor, Spawner) {
    const MAX_QUEUED_TASKS: usize = 10_000;
    let (task_sender, ready_queue) = sync_channel(MAX_QUEUED_TASKS);
    (Executor { ready_queue }, Spawner { task_sender })
}

// ---------- run two tasks on it ----------
fn main() {
    let (executor, spawner) = new_executor_and_spawner();

    let spawner2 = spawner.clone();
    spawner.spawn(async move {
        println!("task A start");
        Delay::new(Duration::from_millis(60)).await;
        println!("task A done");
    });
    spawner2.spawn(async move {
        println!("task B start");
        Delay::new(Duration::from_millis(20)).await;
        println!("task B done");
    });

    drop(spawner);
    drop(spawner2);
    executor.run();
}

Key points

  • An executor is one loop.
  • Take a task off a queue.
  • Poll its future once.
  • Put the future back if it was still Pending.
  • That is the whole idea โ€” Tokio's runtime is this plus a reactor and a thread pool.
  • The queue must be fed by wake-ups, not by busy looping.
  • A waker re-queues its own task.
  • That lets the executor block in recv().
  • It burns zero CPU while it waits.
  • futures::task::ArcWake builds a Waker out of an Arc<T> with a single impl.
  • It is the beginner-friendly alternative to building raw wakers by hand.
  • Box the future to give every task one shape.
  • BoxFuture<'static, ()> is Pin<Box<dyn Future<Output = ()> + Send>>.
  • The concrete type is erased.
  • The behaviour stays.
  • When you are done spawning, drop the spawner.
  • Closing the channel tells the run loop that no more tasks are coming.
  • The loop can then exit instead of waiting forever.
  • The futures crate also ships executor::block_on โ€” an executor for exactly one future.
  • It is the smallest possible version of the executor in this lesson.

Two tasks run on one thread. The executor polls A (prints, awaits Delay, Pending), then B (same). B's 20 ms timer fires first and wakes B's task. So B finishes first. A's 60 ms timer wakes A last. When both tasks are done and every Spawner clone is dropped, the channel closes and run() returns. This channel executor is the design from the Async Book's 'Build an Executor' chapter. It is retold with the Delay future from the previous lesson.

Try it Yourself

Edit the code, then press Run.

Running is simulated ยท Rust Playground

Exercise

In this executor, what does waker.wake() actually do?