Scheduler: Selection

July 16, 2026 ยท View on GitHub

The scheduler chooses the next process to run by walking five priority bands in strict order and rotating round-robin within a band. Higher-priority work always runs before lower, and within a band no process starves its neighbours. This page documents the run queue the candidates come from, the priority-band walk, the round-robin rule inside a band, and the fallback that keeps the current process running when nothing else is ready. The code is under src/process/scheduler/.

The run queue

Runnable processes are held in a first-in-first-out queue of pids (src/process/scheduler/dispatch/run_queue.rs:25):

  static PID_RUN_QUEUE: Mutex<VecDeque<u32>>

  add_to_run_queue(pid)         push to the back, refusing duplicates
  add_to_run_queue_front(pid)   push to the front, refusing duplicates
  remove_from_run_queue(pid)    remove by value
  get_runnable_pids()           a snapshot for the selector to scan

The queue is a VecDeque ordered by arrival, and the source states why: FIFO by arrival means a long-running pid does not starve newcomers the way a pid-sorted set would. Both insert paths refuse duplicates, so the same pid cannot be enqueued twice and appear runnable in two places. A process becomes runnable when it is created, woken from sleep, or made ready after preemption, and it leaves the queue when it blocks or exits.

The priority bands

select_next_process (src/process/scheduler/selection/select.rs:26) takes a snapshot of the runnable pids and walks the five bands in order:

  select_next_process():
      current = CURRENT_PID (this CPU)
      runnable = get_runnable_pids(), else None if empty
      for band in [RealTime, High, Normal, Low, Idle]:
          if select_by_priority(runnable, band_last, current, band) is Some(pid):
              record it as this band's last and the overall last, return pid
      select_fallback(runnable, current)

The order is strict: RealTime before High before Normal before Low before Idle. The walk takes the first band that has a runnable process and never looks at a lower band while a higher one has work, so a realtime process always preempts a normal one, and idle-priority work runs only when nothing else can. Each band remembers the last pid it scheduled in LAST_PER_BAND[idx], and the overall last is LAST_SCHEDULED_PID.

Round-robin within a band

select_by_priority (select.rs:48) is the round-robin. Within a single band it picks the next pid after the one the band scheduled last, wrapping to the lowest when it runs off the end:

  select_by_priority(pids, last, current, band):
      lowest = None; after = None
      for pid in pids, skipping current:
          skip unless the process is Ready and in this band
          lowest = min(lowest, pid)
          if pid > last: after = min(after, pid)
      return after or lowest

after is the smallest runnable pid greater than the band's last-scheduled pid, which is the next one in rotation; when there is no such pid, the band has wrapped and it falls back to lowest, the smallest pid in the band. Rotating by pid this way means every ready process in a band takes a turn before any repeats, so no process in a band is starved by its neighbours. The selector skips current deliberately, so it rotates to another process rather than immediately reselecting the one that just ran.

The fallback

If no band yields a candidate, select_fallback (select.rs:70) keeps the current process running, but only if it is genuinely still runnable:

  select_fallback(pids, current):
      if current is not in the runnable set -> None
      if current's state is Ready           -> current
      else                                   -> None

This covers the case where the only runnable process is the one already on the CPU: it continues rather than the scheduler idling. If even the current process is no longer Ready, selection returns None, and the caller idles the CPU until an interrupt makes something runnable.

What is and is not the live policy

The five-band priority walk above is the scheduling policy in effect. The tree also contains a deadline module (src/process/scheduler/deadline/) and a realtime module (src/process/scheduler/realtime/), but the live selection path is the priority walk in select.rs, not a deadline scheduler, so this page documents the policy that actually runs. The preemption page covers the timer that forces a reselection, and the sleep and wake page covers how a process leaves and re-enters the run queue.

Security analysis

Selection decides who runs next, so its properties are about liveness and fairness rather than confidentiality: it must not let a process starve indefinitely, and it must not select something that is not genuinely runnable. Three hold.

Only a genuinely-ready process is selected. select_by_priority (select.rs:48) skips any pid whose state is not Ready and whose band does not match, and select_fallback (select.rs:70) keeps the current process only if it is still in the runnable set and still Ready, returning None otherwise so the caller idles. A pid that has exited, gone to sleep, or been preempted out of Ready cannot be dispatched, which is what keeps the scheduler from resuming a dead or blocked context. The run queue itself refuses duplicates on both insert paths (run_queue.rs), so a pid cannot appear runnable in two places and be double-counted.

No process in a band starves its neighbours. The round-robin picks after, the smallest runnable pid greater than the band's last-scheduled pid, and wraps to lowest when it runs off the end (select.rs:48), and it skips current so it rotates to another process rather than reselecting the one that just ran. Every ready process in a band therefore takes a turn before any repeats. The run queue is FIFO by arrival (run_queue.rs), which the source notes is deliberate: a long-running pid does not starve newcomers the way a pid-sorted structure would.

Higher priority strictly precedes lower, deterministically. The band walk is strict order, RealTime then High then Normal then Low then Idle (select.rs:26), and it takes the first band with a runnable process and never looks lower while a higher band has work. So a realtime process always preempts a normal one and idle work runs only when nothing else can. The honest boundary: this strictness is exactly what makes cross-band starvation possible. A saturated RealTime or High band will indefinitely starve Normal and below, by design, so fairness holds within a band, not across bands. There is no aging that promotes a starved low-priority process. The tree also contains deadline and realtime modules, but the live policy is this priority walk, so a claim about deadline guarantees would be about code the selection path does not run.

Debugging selection

A process that is runnable but never gets the CPU is the central symptom, and the priority band is the first thing to check. If a higher band is saturated, starvation of a lower band is expected behaviour, not a bug, given the strict walk. If the starved process shares a band with what is running, the round-robin should be rotating to it, so check that its state is actually Ready (a process left in Running, Sleeping, or a stale state is skipped by select_by_priority) and that it is in the run queue at all (is_in_run_queue), since a pid off the queue is invisible to selection regardless of state. A pid that runs twice in a row when others are ready is a current-skip that did not apply or a band last pointer not advancing. The CPU idling while a process is runnable is select_fallback returning None because the only candidate is current and it is no longer Ready, which is correct if that process just blocked; if it should still be ready, the bug is whatever moved it out of Ready. A process that appears in the runnable snapshot but never in a band is a band-membership mismatch: its priority field does not match the band being walked, so it is skipped in every pass.

Source map

  src/process/scheduler/selection/select.rs   select_next_process and the band walk
  src/process/scheduler/dispatch/run_queue.rs  the FIFO runnable queue

Every reference above is verified against those trees. The timer that forces a reselection is on the preemption page; the transitions that add and remove a pid from the run queue are on the sleep and wake page; the switch into the selected pid is on the context switch page; and the Priority and state fields the walk reads live on the PCB.