Schedulingschedulerready queuequantumpreemptionpriority

The Scheduling Problem

A hundred runnable processes and eight cores forces a decision every few milliseconds — who runs, on which core, for how long, and who waits — and every scheduler is one particular answer to that question.

ConceptualLinux
Interview question
Progress

The problem

There are 100 processes that could run right now and only 8 cores. Something has to decide who runs, and it has to decide again a few milliseconds later when the situation has changed. What does that decision look like, and what is it optimising?

Progressive depth

The same mechanism at different altitudes — start where you are.

Who runs next

Runnable tasks wait in a queue; the scheduler pulls one onto each free core, and a timer takes it back after a few milliseconds so everyone gets a turn. Tasks that are waiting for I/O are not in the queue and cost nothing.

Eight cores, a hundred contenders

A core executes one instruction stream at a time. If 100 processes are all runnable, 92 of them are not running at any given instant — they are waiting in a data structure the kernel owns, the ready queue (or run queue), which holds every task that is runnable but not currently on a core. The code that pulls a task from that queue and puts it on a core is the scheduler. Nothing else in the system decides who runs; user code can only ask nicely (nice, sched_setscheduler, SetPriorityClass).

Not every process is in that queue. A process that is blocked in read() on a socket, sleeping in sleep(1), or waiting on a mutex is not runnable and costs the scheduler nothing; it sits in a wait queue attached to whatever it is waiting for, and is moved back to the ready queue when the event arrives (see Process States). On a typical machine with 500 processes, the ready queue is usually a handful long. The interesting case is when it is not.

The scheduler runs at well-defined moments, not continuously: when the running task blocks or exits, when a timer interrupt says its slice is over, when a higher-priority task wakes up, or when a task voluntarily yields. Each of those is a scheduling point; at each one the scheduler picks the next task and, if it differs from the current one, performs a Context Switching.

Runnable tasks wait in the ready queue; the scheduler feeds cores
pick nextrunrunblocks on I/OwakeuppreemptedReady queue (92 runnable)SchedulerCore 0Core 7Wait queues (blocked)
UserLLMAgentToolDataDecisionHumanGuardrail

Time slices and preemption

If a task keeps the core until it decides to give it up, one infinite loop freezes the machine — that was the world of cooperative multitasking (Windows 3.x, classic Mac OS). The fix is a hardware timer that interrupts the core periodically (historically every 1–10 ms; modern kernels program it on demand). At the interrupt the kernel is in control, checks whether the running task has used its time slice (its quantum), and if so puts it back on the ready queue and picks another. Taking the core away from a task that has not asked to stop is preemption.

The quantum is a knob with two bad ends. Too long and an interactive task that wakes up has to wait behind a compute-bound one for the whole slice — you type a key and the echo arrives 100 ms later. Too short and the machine spends its time context switching instead of computing: at a 1–5 µs switch cost, a 10 µs quantum would be half overhead. Kernels resolve this with a target latency rather than a fixed slice: "every runnable task should get a turn within roughly N milliseconds", with N scaling with the number of runnable tasks, bounded below by a minimum granularity so slices never become absurdly short.

This is the latency vs throughput trade-off in its purest form. Throughput wants long uninterrupted runs (warm caches, few switches). Latency wants every waiting task to get a core soon. A batch server can tune towards throughput; a desktop or a request-serving process tunes towards latency, and the default on general-purpose kernels leans towards latency because unresponsiveness is what users notice.

What different workloads want from a scheduler
WorkloadCares aboutWantsExample
Batch / computeThroughputLong slices, few switchesVideo encoder, CI build
InteractiveResponse latencyQuick access to a core after a wakeupEditor, shell, browser UI thread
Request serverTail latency + throughputFairness across connections, no starvationWeb server, database
Soft real-timeDeadlinesFixed priorities, preempt everything elseAudio callback, control loop

CPU-bound, I/O-bound, and why interactive tasks get boosted

Tasks reveal their nature by how they leave the core. A CPU-bound task runs until preempted — it always uses its whole slice. An I/O-bound task runs for a few microseconds, issues a read() or waits on a socket, and blocks — it rarely uses its slice. A text editor, a shell, a network server waiting for requests and a database waiting for disk are all I/O-bound most of the time.

Every general-purpose scheduler treats these differently, because the right policy falls out of the constraint: a task that blocked voluntarily has consumed less than its share, so when it wakes up it deserves to run soon, ahead of the task that has been grinding through its full slices. Older schedulers implemented this as an explicit interactive bonus to priority; fairness-based schedulers get it for free by tracking how much CPU each task has actually received and always running the one furthest behind. Either way the effect is the same: the keystroke handler preempts the compiler, and the compiler barely notices because the handler is done in 50 µs.

Priority is the other axis. A static priority (nice −20…19 on Unix, priority classes on Windows) says how large a share a task should get when there is contention, not that it runs first no matter what — except for real-time policies (SCHED_FIFO, SCHED_RR, Windows REALTIME_PRIORITY_CLASS), which do exactly that and can therefore starve everything else. Fairness is the property that no task waits unboundedly long; a scheduler that lacks it exhibits starvation, which the Scheduling Simulator: FCFS, Round Robin, Priority lets you provoke.

  • CPU-bound: uses its full slice, leaves only by preemption. I/O-bound: leaves by blocking, uses a fraction of its slice.
  • A task that blocked has under-used its share; running it first on wakeup is what makes the machine feel responsive.
  • Static priority sets the share under contention. Real-time priorities bypass sharing entirely and can starve the system.
  • On a multi-core machine each core usually has its own queue; a load balancer migrates tasks between queues, trading cache warmth for balance.

One concrete implementation: Linux CFS and EEVDF

Linux

The Linux Completely Fair Scheduler (CFS, 2007–2023) implemented fairness by giving every task a virtual runtime: the CPU time it has consumed, scaled by its weight (nice 0 has weight 1024; each nice step changes the weight by roughly 1.25×). Runnable tasks were kept in a red-black tree ordered by vruntime, and the scheduler always ran the leftmost node — the task that had received the least weighted CPU time. A task that slept accumulated no vruntime, so on wakeup it was far to the left and ran promptly: the interactive boost as a side effect of the accounting, not a heuristic.

Slice length was not a constant. CFS aimed for a target latency (about 6 ms scaled by the log of core count) divided among runnable tasks, with a minimum granularity (about 0.75 ms) so that 100 runnable tasks did not produce 60 µs slices. With 100 runnable CPU-bound tasks on one core, each got about 0.75 ms roughly every 75 ms.

Linux 6.6 (2023) replaced the CFS pick logic with EEVDF (Earliest Eligible Virtual Deadline First). It keeps the weighted-fairness accounting but tracks each task’s lag (how far behind or ahead of its fair share it is) and assigns each a virtual deadline computed from its requested slice; the scheduler runs the eligible task with the earliest deadline. This makes latency-sensitive tasks with short slice requests get the core sooner without breaking fairness, and gives the kernel a principled answer to "who should run" instead of CFS’s accumulated heuristics for wakeups and preemption.

This is one implementation. Windows uses a 32-level priority-based preemptive scheduler with temporary priority boosts on wakeup and a fairness pass that raises starved threads; macOS/XNU uses priority bands with a timeshare decay policy; FreeBSD’s ULE and the real-time kernels used in embedded systems make yet other choices. The constraints are universal; the policies are not.

Linux: the knobs behind the words (values vary by kernel and core count)
$ cat /sys/kernel/debug/sched/base_slice_ns     # EEVDF default slice request (6.6+)
3000000
$ cat /proc/sys/kernel/sched_rt_runtime_us         # real-time tasks may use 950 ms of every 1 s
950000
$ nice -n 10 ./encode video.mp4                    # lower weight: ~40% of the share of a nice-0 task
$ chrt -f 50 ./control-loop                        # SCHED_FIFO priority 50: preempts every normal task

From DSA to the kernel

The ready queue is literally the Queue you implemented in the DSA domain when the policy is first-come-first-served or round-robin: enqueue on wakeup or preemption, dequeue to run. The moment the policy involves priority, the data structure becomes a Priority Queue — a Binary Heap keyed on priority gives O(log n) pick and insert, and O(1) peek at the best candidate. CFS chose a red-black tree over a heap because it also needed to remove arbitrary nodes (a task blocking mid-slice) and find neighbours; the same O(log n) bound, more operations supported.

The transfer runs in both directions. Task-scheduling interview problems ("schedule jobs with cooldown", "minimum time to finish tasks with priorities") are the same problem the kernel solves, with the same tools; and understanding that a scheduler tick is a heap pop makes its cost model — O(log n) per scheduling point, independent of the number of *blocked* processes — obvious rather than memorised.

Key points

  • The scheduler is the only code that decides which task runs on a core; it runs at scheduling points (block, tick, wakeup, yield, exit), not continuously.
  • Only runnable tasks are in the ready queue. Blocked tasks live in wait queues and cost nothing until their event fires.
  • A timer interrupt enforces the quantum; preemption is what stops one infinite loop from freezing the machine.
  • Quantum length trades latency (short) against throughput (long); kernels use a target latency divided among runnable tasks, bounded by a minimum granularity.
  • I/O-bound tasks consume less than their share and are run first on wakeup — the interactive boost, explicit in old schedulers, implicit in fairness-based ones.
  • Priority sets the share under contention; real-time priorities bypass sharing and can starve the system.
  • Linux CFS/EEVDF is one implementation among many; Windows, macOS and RTOSes make different choices under the same constraints.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why does a scheduler exist at all?

Because cores are fewer than runnable tasks, and without an arbiter the first task to grab a core would keep it. The scheduler is the arbiter, and the timer interrupt is what gives it the power to take a core back.

Why not just give every task an equal fixed slice?

Because an equal slice punishes tasks that block: they give up most of their slice and would wait a full round to get another. Fair-share accounting on actual CPU consumed lets them run promptly when they wake, which is exactly what interactive and I/O-bound work needs.

Why does a busy machine feel slow even when nothing is at 100%?

Ready-queue length, not utilisation, is what a task experiences. If 20 tasks are runnable on 8 cores, a freshly woken task waits for a slice to end before it runs; load average (runnable + uninterruptible tasks) is the number to read, not CPU percent.

Why do Linux, Windows and macOS schedule differently?

Because they weigh the same constraints differently: Linux optimises weighted fairness on servers and desktops alike, Windows keeps fixed priority classes with boosts for the foreground window, macOS has a QoS-class model driven by the UI. The trade-offs are universal; the policy is a product decision.

How it fails

What the failure looks like from inside real software.

  • A CPU-bound thread at the same priority as the request-serving threads: p99 latency climbs because every request waits for a slice to end — fix by lowering its nice value or moving it to another core set.
  • A SCHED_FIFO real-time thread with a bug spins forever and the whole machine, including your SSH session, stops responding; Linux’s RT throttling (95%) is the only thing that lets you log in.
  • Thousands of runnable threads: the ready queue is long, slices hit the minimum granularity, and the machine spends its time switching — throughput collapses while every core shows 100%.
  • Reading CPU utilisation instead of load average: 60% CPU with a load of 40 on 8 cores means tasks are waiting for the core, not for I/O.
  • A container with a CPU quota (cpu.max) hits its limit mid-slice and is throttled until the next period: latency spikes of tens of milliseconds appear with no obvious cause.