Skip to content

← Async From First Principles step 17 of 25

Hard End-to-End

The minimal single-threaded executor

interleave(vec![3, 2, 1])  ==  [0, 1, 2, 0, 1, 0]

Three tasks. One thread. Each task appends its own index to a shared log and then yields. The output is round-robin — and that single line of data is proof that you are running concurrent tasks on one thread with no runtime crate anywhere in sight.

pub struct Executor { /* tasks + ready queue */ }
impl Executor {
    pub fn new() -> Self;
    pub fn spawn(&mut self, fut: impl Future<Output = ()> + 'static);
    pub fn run(&mut self);
}
pub fn interleave(counts: Vec<u32>) -> Vec<usize>

interleave spawns one task per entry of counts; task i loops counts[i] times, pushing i to the log and awaiting the supplied yield_now().

The three pieces, and that is genuinely all of them

A task list. Vec<Option<Pin<Box<dyn Future<Output = ()>>>>>, indexed by task id. Boxed because tasks have different concrete types and must live at a stable address; Option because a running task is temporarily taken out and put back, and a finished task is left as None. Give it a type alias — clippy::type_complexity is a default lint and that type is over the threshold.

A ready queue. Arc<Mutex<VecDeque<usize>>> holding the ids of tasks that can make progress right now. spawn pushes; run pops.

A per-task waker. TaskWaker { id, queue } with impl Wake, from item 16.7. This is the piece that makes it a scheduler rather than a loop: because the waker carries the id, waking a task re-queues exactly that task rather than “something changed, re-poll everything”.

run is then a loop: pop an id, take the task out of its slot, build that task’s waker and Context, poll it once, and put it back if it returned Pending. When the queue is empty, every remaining task is waiting on something that will never happen, so stop.

The bug of this problem

This looks right and self-deadlocks on the first wakeup:

while let Some(id) = self.queue.lock().unwrap().pop_front() {
    // poll the task here — and the task calls wake_by_ref(),
    // which locks the queue this thread is already holding
}

The MutexGuard produced in a while let scrutinee lives for the whole loop body. std::sync::Mutex is not reentrant. Make the pop its own statement so the temporary is dropped at the semicolon:

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

The general rule — hold a lock for the shortest region you can — is the same one that will bite you again as clippy::await_holding_lock in item 16.22.

The error you are meant to meet

The starter reaches for the task with

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

E0507: cannot move out of index of Vec<Option<Pin<Box<dyn Future ..>>>>. Indexing gives you a place behind a &mut, and Option::unwrap consumes by value. Option::take is the answer: it swaps None in and hands you the contents, leaving the vector valid. Exactly the mem::replace move from item 16.12, with a nicer name.

Taking the task out is not merely a workaround, either. It is what lets you poll the task while still holding &mut self for the executor — the task is no longer inside self while it runs.

Details that will cost you an hour otherwise

  • Put a poll budget on run. A task that wakes itself and never completes is a legal, easy-to-write bug. Bounding the loop turns an infinite hang into a fast, legible failure.
  • #[derive(Default)] works here, since every field type has a Default — and having a Default is also what stops clippy::new_without_default from complaining about your new().
  • A finished task must not be re-queued. Leaving the slot as None and skipping ids that are already None handles the case where a task woke itself immediately before completing.

When this passes, read tokio’s Runtime with the knowledge that it is this, plus work stealing, plus an epoll loop, plus twelve years of tuning.