Skip to content

← Async From First Principles step 17 of 25

Hard End-to-End

The minimal single-threaded executor

Repair a small executor that polls tasks on one thread. The supplied interleave function creates one task for each count:

pub fn interleave(counts: Vec<u32>) -> Vec<usize>

Task i appends its index to a shared log counts[i] times, awaiting the supplied yield_now() after each append. With this executor's FIFO queue, the tasks take turns in input order:

Counts Returned log
[3, 2, 1] [0, 1, 2, 0, 1, 0]
[2, 2] [0, 1, 0, 1]
[0, 3] [1, 1, 1]
[] or [0, 0] []

A zero count contributes no log entries but still creates a task. Indices refer to the original positions in counts.

What to fix

Keep Executor::new, spawn, run, yield_now and interleave available. The starter already supplies the queue, wakers, task creation and logging. Its invalid move is in run:

let mut task = self.tasks[id].unwrap();

unwrap() consumes the Option, but this expression tries to move a non-Copy value out of a vector element. Use Option::take to leave None in the slot and obtain the task. If the slot was already empty, skip the id. Restore the task only when its poll returns Pending; leave completed slots empty so later wakeups cannot poll them again.

How the pieces fit together

Piece Purpose
Vec<Option<Task>> Stores tasks at stable indices and marks empty or completed slots with None.
Pin<Box<dyn Future<Output = ()>>> Stores different future types behind one type and keeps each future's allocation pinned while its handle moves.
Arc<Mutex<VecDeque<usize>>> Shares the FIFO queue with wakers and protects queue operations.
TaskWaker { id, queue } Adds its task's id to the back of the queue when woken.

The futures need not be Send: this executor polls them on its calling thread, allowing the supplied tasks to share an Rc<RefCell<_>> log. The waker's shared queue uses thread-safe types, as required for constructing a Waker from Arc<TaskWaker>.

Each queue iteration removes an id, takes its task, builds a context with that task's waker, and polls once. Taking the task out is the supplied design's ownership-transfer strategy; it is not a requirement of every executor.

Why these tasks take turns

On its first poll, YieldNow marks itself done, wakes its task and returns Pending. On its next poll, it returns Ready(()). Waking appends the task id behind other queued tasks, producing the example's interleaving.

After a task's final append, it still awaits one last yield. A later poll completes that yield and finishes the task without appending again. A task with count c therefore needs c + 1 polls here, including when c is zero. This behavior comes from this future and queue, not a universal fairness guarantee of async Rust.

Release the queue lock before polling

Do not combine queue removal and polling into this form:

while let Some(id) = self.queue.lock().unwrap().pop_front() {
    // Polling here can call a waker that locks the same queue.
}

The temporary mutex guard in the while let condition remains alive through the body. Relocking a standard mutex on the same thread is unsupported and may deadlock or panic. The starter already avoids that problem:

let next = self.queue.lock().unwrap().pop_front();
let Some(id) = next else { return };

The guard is released at the first statement's semicolon. Polling can then call the waker without still holding the queue lock. Keep this separation.

Scope and limits

run() returns as soon as it sees an empty queue. That does not establish that all futures completed or that no later wakeup is possible. An externally woken future could become ready after run() returns; the caller would have to run the executor again. There is no event loop that waits for I/O or timers.

Waking can enqueue an id more than once. Empty-slot checks handle stale ids after completion, but this implementation does not deduplicate notifications. A wakeup requests another poll; it does not guarantee that poll will complete.

The supplied guard allows 100,000 queue-loop iterations before panicking. Stale ids count too, and this is not a wall-clock timeout: a single blocking or non-returning poll can still stall it. For the supplied interleave tasks, inputs must satisfy sum(counts) + counts.len() < 100_000, leaving an iteration to observe the empty queue. Even completion on the final allowed iteration reaches the current guard's panic.

This is a bounded teaching executor, not a general-purpose async runtime. The exercise exposes task ownership, waking and polling without requiring a runtime crate.