Skip to content

← Async From First Principles step 19 of 25

Hard End-to-End

A logical-time reactor: timers without a clock

Add deterministic timers to the supplied single-threaded executor. The executor polls ready tasks; the reactor stores timers and makes their tasks ready when a deadline is reached. Time is an integer here, so the simulation never needs to wait for a real clock.

Your task

Fix the timer ordering in the starter. Keep the given executor and the public functions, including simulate(delays: Vec<u64>) -> Vec<(usize, u64)>. The starter already handles registration, clock advancement and reset, but its heap removes the latest deadline instead of the earliest.

simulate starts at time zero and spawns tasks in input order. Task i sleeps for delays[i], records (i, now), then repeats that sleep and record once. Return the log in execution order. Each task contributes two entries; an empty input returns an empty log.

Process timers in ascending (deadline, task_id) order. When deadlines match, the lower task ID goes first. A zero-delay sleep completes immediately without registering a timer. The fixtures use deadlines that fit in u64.

For [3, 1, 2], the result is:

[(1,1), (1,2), (2,2), (0,3), (2,4), (0,6)]

Task 1 records at times 1 and 2, task 2 at times 2 and 4, and task 0 at times 3 and 6. At time 2, task 1 goes before task 2 because its ID is lower.

Order the timer heap

BinaryHeap removes its largest entry first. Wrapping each (deadline, task_id) in std::cmp::Reverse makes the smallest pair come out first. Update the heap's element type, the entry inserted by Sleep::poll, and the pattern used when advance removes an entry.

The task ID gives equal deadlines a defined order. This matters to the returned log, even though either task would observe the same logical time.

Drain ready work before advancing time

The supplied driver repeatedly drains the ready queue, then removes one timer and moves the clock to its deadline:

while ex.step() {}
// Once no task is ready, advance to the next timer.

Run the newly awakened task before advancing again. It may register another timer, which must join the heap before the next deadline is chosen. With the starter's max-heap, a later timer can fire first and leave an earlier deadline behind, making the simulated clock move backward.

This ordering is a rule of this simulation, not a rule for every async runtime. A production runtime must also service I/O and timers while other tasks remain runnable.

Register each sleep once

sleep(d) captures now() + d when it is constructed. On each poll, it first checks whether that deadline has arrived. If so, it returns the current time. Otherwise, it registers a timer only if armed is false, then returns Pending. Repeated polls must not add duplicate entries for the same sleep.

Here, Sleep gets the task ID from CURRENT, which Executor::step sets before polling. It relies on this particular executor instead of saving the waker from Context. It is not a timer future you can move unchanged into an arbitrary runtime. General-purpose futures must arrange a wakeup through the current polling context when they can make progress.

Reset and borrowing

Reset the thread-local reactor at the start of every simulate call. The harness runs the simulation twice in the same process and grades the second result. Without a reset, the next run can inherit the previous clock value.

Keep RefCell borrows inside short synchronous operations. The supplied with_borrow and with_borrow_mut closures release their borrows before the surrounding future can yield. Holding a borrow across an await can cause a runtime borrow failure if another task needs the same cell.

This exercise demonstrates timer ordering and the connection between a future, a ready queue and a reactor. It does not implement timer cancellation, operating-system I/O, scheduler fairness or a multithreaded runtime.