Skip to content

← Async From First Principles step 20 of 25

Medium Framework

Fairness and starvation in your scheduler

Change ReadyQueue::push so the supplied executor supports both FIFO and LIFO scheduling. Keep pop removing from the front, and leave the supplied futures, waker, executor and schedule function in place.

pub fn schedule(counts: Vec<u32>, policy: String) -> Vec<usize>

Task i appends its index to a shared log counts[i] times. After each entry it awaits the supplied yield_now() future, which queues that task again and returns Pending once. Return the resulting log.

Queue rules

Policy Where push adds an ID Where pop removes an ID
"fifo" Back Front
"lifo" Front Front

The same push operation is used when spawning tasks and when waking them. Tasks are spawned in increasing index order. Therefore FIFO starts with task 0, while LIFO starts with the highest index, even before any task wakes itself.

"lifo" is the only string that enables LIFO in the supplied schedule. Every other string uses FIFO; do not add case folding or an error response.

Expected order

schedule([3, 2, 1], "fifo") = [0, 1, 2, 0, 1, 0]
schedule([3, 2, 1], "lifo") = [2, 1, 1, 0, 0, 0]
schedule([0, 2, 0], "fifo") = [1, 1]
schedule([], "lifo") = []

For FIFO, visit the unfinished tasks in increasing index order on each round, recording one entry per task that still has work. For LIFO, the highest-index nonempty task supplies all its entries first, then the next lower index, and so on. This happens because a task that yields is pushed straight back to the front. Both policies return exactly sum(counts) log entries.

A zero-count task still gets polled and completes, but adds no entry. Every other task also needs a final poll after its last logged yield to complete. Thus log entries and executor polls are different counts.

Input size and the executor guard

For this exercise, sum(counts) + counts.len() is at most 99,999, calculated without integer overflow. Each supplied task takes counts[i] + 1 polls to complete. The executor's 100,000-iteration guard also needs one iteration to notice that the queue is empty.

Do not change that guard. Inputs exceeding this bound can hit its panic even though each task has finite work. In particular, using all 100,000 iterations to poll tasks leaves no iteration for the empty-queue check.

What this demonstrates about fairness

FIFO distributes turns among the runnable tasks in this example. LIFO favors the task that was queued most recently. Neither policy changes the work each task must eventually perform within the input bound, but the order is visible in the log.

Now imagine a task that repeatedly wakes itself and returns Pending forever. Under this pure LIFO policy, once it reaches the front, it keeps putting itself back there. Other queued tasks receive no further polls. That is starvation. The supplied executor would eventually stop at its guard; an executor without that guard could keep favoring the same task indefinitely.

FIFO prevents that particular queue-order problem, because a self-waking task returns to the back. It does not guarantee equal CPU time. A task can spend much longer inside one poll than another task does, or fail to return at all. This executor cannot poll another task until the current poll returns. Cooperative scheduling therefore needs both queue policy and tasks that return control.

Why a scheduler might favor recent work

Running a recently woken task can improve locality when it consumes data another task just produced. The benefit depends on the workload, processor and scheduler; it is not a guaranteed speedup, and task-local register values are not generally shared across tasks.

Some production schedulers combine a ready queue with a preferred slot for recently woken work, plus limits on how long that slot can dominate. Tokio's multi-thread scheduler documents one such design. This exercise models only the two simple queue policies; it does not reproduce a production runtime's fairness guarantees, priorities, work stealing or wake deduplication.

Your solution must compile, match the output order and pass Clippy with warnings treated as errors.