Operating Systems
The Multilevel Feedback Queue: How Your OS Schedules Without Knowing the Future
A scheduler has an impossible job: it must minimize turnaround time for batch jobs and keep your terminal responsive to keystrokes — yet it has no idea, when a process arrives, whether it will run for 2 milliseconds or 2 hours. In 1962, Corbató's CTSS on the IBM 7094 cracked this with the Multilevel Feedback Queue (MLFQ): instead of asking a process how long it will run, watch how it behaves and demote the greedy ones. The same idea shipped in Solaris, the classic BSD/Unix scheduler, and the Windows NT dispatcher.
The magic is that MLFQ learns a job's character from a single observable — whether it yields the CPU before its time slice expires. Interactive jobs float to the top and get scheduled instantly; CPU hogs sink to the bottom and run round-robin among themselves. All of this in O(1) per scheduling decision, with a couple of famous ways to get it catastrophically wrong.
- Decision costO(1) pick + enqueue
- SpaceO(k + n), k queues, n jobs
- InventedCorbató, CTSS, 1962
- Best forMixed interactive + batch loads
- GoalLow latency ∧ high throughput
- Failure modeStarvation & gaming
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
The core idea: approximate SJF without a crystal ball
The theoretically optimal policy for turnaround time is Shortest Job First (SJF) — or its preemptive cousin Shortest Remaining Time First (SRTF). Both are provably optimal, and both are useless in a general-purpose OS because you never know a job's runtime in advance. MLFQ's insight is to estimate job length from recent behavior and continuously revise that estimate.
The scheduler maintains k priority queues, Q₀ (highest) down to Qₖ₋₁ (lowest). Two rules govern which job runs:
- Rule 1: If priority(A) > priority(B), run A.
- Rule 2: If priority(A) = priority(B), run A and B in round-robin using that queue's time quantum.
The feedback — the part that makes it learn — lives in how jobs change queues. A process that repeatedly blocks for I/O before using its slice looks interactive and is kept high. A process that burns its entire quantum looks CPU-bound and gets demoted. The governing invariant: a job's current queue encodes the scheduler's running estimate of how CPU-hungry it is, updated every quantum.
The feedback rules, step by step
The naive version uses three additional rules to move jobs between queues:
- Rule 3: A new job enters at the top queue Q₀. Optimistically assume it is short/interactive — this is what lets short jobs finish fast.
- Rule 4a (naive): If a job uses up its entire time slice, demote it one level (priority drops).
- Rule 4b (naive): If a job gives up the CPU (blocks on I/O, a system call, or a lock) before the slice expires, keep it at the same level.
Watch how this plays out. A long-running matrix multiply arrives at Q₀, exhausts its quantum, drops to Q₁, exhausts that, drops to Q₂, and eventually sits at the bottom running round-robin against other hogs. An interactive shell that reads a keystroke, prints, and blocks for the next keystroke keeps yielding early — so it stays at Q₀ and is dispatched almost the instant its input is ready. Neither job ever declared its intent; the scheduler inferred it.
A crucial detail is the quantum-length gradient: high queues get short quanta (say 10 ms) for snappy response; low queues get long quanta (say 100–200 ms) so batch jobs get big, efficient CPU chunks and pay context-switch overhead less often. This is the single most important tuning knob.
Two ways it breaks — and the two rules that fix them
The naive rules have two textbook failure modes, and both have standard patches.
Failure 1 — Starvation. If the system has enough interactive load, jobs at the top queues can monopolize the CPU forever, and anything demoted to the bottom never runs. A long-running job that briefly needed the CPU can be permanently locked out.
Failure 2 — Gaming the scheduler. A malicious or clever process can exploit Rule 4b: right before its quantum expires, it issues a trivial I/O (a 1-byte write) to yield voluntarily, resetting its "used slice" clock and staying at high priority indefinitely. It steals a near-monopoly share while pretending to be interactive.
Both are cured by two more rules:
- Rule 5 — Priority Boost: Every period S, move all jobs to the topmost queue Q₀. This guarantees no starvation (a bottom job waits at most S before running) and re-classifies jobs whose behavior changed — a batch job entering an interactive phase gets a fresh chance. Choosing S is the classic voo-doo constant problem: too large and starvation bites; too small and everything piles at Q₀ and you degenerate to round-robin.
- Rule 4 (revised, anti-gaming): Track total CPU time a job accumulates at a given level across yields. Once it exceeds that level's budget, demote it regardless of how it gave up the CPU. Now the 1-byte-write trick doesn't help: the accounting is cumulative, so gaming and honest CPU use are indistinguishable to the ledger.
Complexity: why every decision is O(1)
MLFQ's data structures are deliberately cheap. Each priority level is a FIFO queue — typically a doubly linked list or a ring buffer of runnable tasks. The scheduler also keeps, for the ready set, a bitmask of non-empty queues (one bit per level).
- Pick next job: find the highest non-empty queue via
__builtin_ctz(bitmask)(count-trailing-zeros / "find first set"), then dequeue its head. Both are O(1) — this is exactly how the O(1) Linux 2.6 scheduler and the Windows dispatcher work, with k = 140 and k = 32 levels respectively. - Enqueue / demote / boost a job: unlink from one list, link into another. O(1) per job. A full priority boost touches n jobs, so it is O(n) once per period S — amortized negligible.
- Space: O(k + n) — the k queue headers plus one
task_struct-sized node per runnable job. k is a small constant (32–140), so this is effectively O(n).
Contrast this with SJF/SRTF, which needs a priority queue keyed on (estimated) remaining time and pays O(log n) per insert/extract, and even then requires an estimate MLFQ avoids entirely. MLFQ trades provable optimality for O(1) decisions and a scheduler that adapts online — the right trade for an interactive OS where scheduling runs thousands of times per second.
Where it actually runs
MLFQ is not a museum piece — variants power some of the most-deployed schedulers ever written:
- CTSS (1962) and Multics — Corbató's original exponential-quantum feedback queues, the ancestor of everything here.
- Classic BSD / Unix (4.3BSD) — the traditional Unix scheduler used a multilevel feedback design where priority was recomputed from a decaying estimate of recent CPU usage (
p_estcpu), plus aniceoffset. Decay played the role of the priority boost. - Solaris — its default Time-Sharing (TS) class is a textbook MLFQ, but the quanta and demotion rules live in an editable
dispatch table(60 priority levels), so admins tune the whole policy without touching kernel code. - Windows NT → 11 — 32 priority levels (0–31); the dispatcher boosts threads on I/O completion and event wakeups and decays the boost each quantum, which is MLFQ's rules 4/5 in disguise. It also anti-starvation-boosts long-waiting ready threads.
- Linux — the O(1) scheduler (2.6.0–2.6.22) was an explicit MLFQ with 140 levels and active/expired arrays. Modern Linux replaced it with CFS (fair red-black-tree) and now EEVDF, but real-time classes
SCHED_FIFO/SCHED_RRremain strict multilevel priority scheduling.
Tuning, variants, and edge cases
MLFQ has no clean closed-form optimum — it is a bundle of parameters that must be tuned to the workload, which is both its strength and its reputation for black magic. Key knobs and gotchas:
- Number of queues k and the quantum schedule. A common design uses geometrically increasing quanta: level i gets 2ⁱ × base. This is Corbató's original exponential rule and bounds the number of demotions any job suffers to O(log(total runtime)).
- Boost period S. The dominant anti-starvation lever. Solaris and Windows tie effective boosting to I/O-wait accounting rather than a single global timer, giving finer control.
- I/O-bound fairness. Even revised Rule 4 can slightly under-serve a genuinely interactive job that occasionally does heavy compute; Solaris's per-class tables let you carve out a fixed
ts_quantumand priority for such classes. - Priority inversion. A high-priority task blocked on a lock held by a demoted low-priority task can stall — MLFQ alone doesn't solve this; you need priority inheritance or ceiling protocols layered on top.
- Multicore. Classic MLFQ is single-run-queue; SMP systems use per-CPU MLFQs plus a load balancer, and cross-CPU migration interacts with cache affinity (a demoted-then-migrated job loses its warm cache).
The pragmatic lesson from decades of production use: give the scheduler hints and overrides (nice, real-time classes, tunable dispatch tables) rather than trusting the pure heuristic for every edge case.
| Algorithm | Pick cost | Needs job length? | Response time | Starvation risk |
|---|---|---|---|---|
| FIFO / FCFS | O(1) | No | Poor (convoy effect) | None |
| SJF / SRTF | O(log n) | Yes (unknown!) | Optimal turnaround | Long jobs starve |
| Round-Robin | O(1) | No | Good, uniform | None |
| Static priority | O(log n) or O(k) | No | Great for top tier | Low tiers starve |
| MLFQ | O(1) | No (learns it) | Near-SJF for short jobs | Fixed via boost |
Frequently asked questions
Why not just use Shortest Job First — isn't it optimal?
SJF/SRTF is provably optimal for average turnaround time, but it requires knowing each job's CPU burst length in advance, which a general-purpose OS never does. MLFQ approximates SJF by observing behavior: jobs that keep exhausting their quantum are treated as long and demoted, while short jobs finish from the top queue quickly. It gets near-SJF response for short jobs without any oracle.
What is the time complexity of a scheduling decision?
O(1). The scheduler keeps a bitmask of non-empty queues and finds the highest-priority ready job with a count-trailing-zeros instruction, then dequeues the FIFO head — both constant time. Enqueue, demote, and boost of a single job are O(1) linked-list operations; a full priority boost is O(n) but runs only once per boost period S.
How does MLFQ prevent starvation?
Through the periodic priority boost (Rule 5): every S time units, all jobs are moved back to the top queue. This bounds the maximum wait of any job — even one stuck at the bottom — to roughly S, and it re-classifies jobs whose behavior shifted from CPU-bound to interactive. Choosing S is the tricky part: too large reintroduces starvation, too small collapses MLFQ into plain round-robin.
How can a process 'game' the scheduler, and how is it stopped?
In the naive rules, a job that yields the CPU before its quantum ends keeps its priority. So a hog can issue a trivial I/O (a 1-byte write) just before its slice expires to reset the clock and stay at top priority forever. The fix is better accounting: track total CPU time consumed at each level across all yields, and demote once the budget is exceeded regardless of how the job gave up the CPU.
Is MLFQ still used in modern operating systems?
Yes, in spirit and often literally. Windows (32 levels), Solaris's Time-Sharing class (60-level tunable dispatch table), and the classic BSD scheduler are all MLFQ variants; Linux's 2.6 O(1) scheduler was a 140-level MLFQ. Modern Linux moved its default policy to CFS/EEVDF, but its SCHED_FIFO and SCHED_RR real-time classes are still strict multilevel priority scheduling.
What's the difference between MLFQ and a plain multilevel queue?
A multilevel queue assigns each process to a fixed queue permanently (e.g., system vs. interactive vs. batch) — no movement. MLFQ adds feedback: jobs move between queues based on observed CPU usage, so their priority is learned and continuously revised. The 'feedback' is precisely the demotion-on-quantum-exhaustion and periodic-boost machinery.