When I first opened the Linux kernel source and stumbled onto kernel/sched/fair.c, I expected a maze of arbitrary heuristics. What I found instead was something closer to an elegant economics problem: how do you divide one scarce resource — CPU time — among dozens or thousands of competing processes, without letting any of them starve or hog the machine? The answer the kernel settled on in 2007, replacing the old O(1) scheduler, is the Completely Fair Scheduler (CFS). This article walks through exactly how CFS achieves fairness, from the math underneath it to the data structures that make it fast enough to run on everything from a Raspberry Pi to a 256-core server.
The Problem CFS Was Built to Solve
Before CFS, Linux used a scheduler built around fixed time slices and priority arrays. It worked, but it had a fundamental flaw: it modeled fairness in terms of time slices, not proportions of CPU. A process with a longer time slice wasn’t necessarily getting a fair share relative to everyone else — it was just getting a bigger fixed chunk. Interactivity heuristics were bolted on top to guess whether a process was “interactive” and deserved a boost, and those heuristics were notoriously easy to get wrong, leading to audio stutters, laggy desktops, and inconsistent behavior under load.
Ingo Molnar, who wrote CFS, framed the problem differently. Instead of asking “how much time slice should this process get,” he asked: if we had an ideal, perfectly fair CPU that could run every runnable process simultaneously at 1/N speed, how much CPU time would each process have received by now? CFS doesn’t achieve that ideal — a real CPU can only run one thing at a time (per core) — but it constantly measures how far every process has drifted from that ideal and corrects course.
The Core Idea: Virtual Runtime (vruntime)
The heart of CFS is a per-task counter called vruntime (virtual runtime). Every task accumulates vruntime as it runs, but not at a 1:1 rate with wall-clock time — the rate is weighted by the task’s priority (its “nice” value).
vruntime += actual_time_running * (NICE_0_LOAD / task_weight)
- A task with default priority (nice 0) accumulates vruntime at roughly the same rate as real time.
- A high-priority task (lower nice value, higher weight) accumulates vruntime more slowly — so it looks “behind” and gets scheduled again sooner, effectively getting more CPU.
- A low-priority task (higher nice value, lower weight) accumulates vruntime faster — so it looks “ahead” and gets scheduled less often.
CFS’s entire scheduling decision boils down to one rule: always run the runnable task with the smallest vruntime. That’s it. No time-slice bookkeeping, no priority arrays, no interactivity heuristics. Whichever task has fallen furthest behind the “ideal fair CPU” gets to run next.
The Red-Black Tree
Picking the task with the smallest vruntime efficiently — especially when there might be thousands of runnable tasks — requires the right data structure. CFS keeps all runnable tasks on a red-black tree (a self-balancing binary search tree), keyed by vruntime.
[vruntime: 820]
/ \
[vruntime: 410] [vruntime: 1150]
/ \ \
[vruntime:200] [vruntime:600] [vruntime:1400]
- The leftmost node is always the task with the smallest vruntime — the next task to run. The kernel caches a pointer to this node (
rb_leftmost) so picking the next task is an O(1) operation. - Inserting a task after it runs (with its updated vruntime) is an O(log n) operation.
- This gives CFS excellent scalability — O(log n) per scheduling decision, which stays fast even with thousands of runnable threads.
Every time a task runs for a while, gets preempted, or blocks, its vruntime is updated and it’s re-inserted into the tree at the correct position. The task that was just running now has a higher vruntime, so it moves rightward in the tree, and whichever task now has the smallest vruntime becomes the new leftmost node and gets picked next.
Nice Values and Weights
Linux still exposes the traditional nice value, ranging from -20 (highest priority) to +19 (lowest priority), but CFS translates nice values into weights using a table defined in kernel/sched/core.c. The weights follow roughly a 1.25x multiplicative curve per nice level, so that each step of nice value changes CPU share by about 10%.
| Nice Value | Weight | Relative CPU Share (2 competing tasks) |
|---|---|---|
| -20 | 88761 | ~97% |
| -5 | 3121 | ~76% |
| 0 | 1024 | 50% |
| 5 | 335 | ~24% |
| 19 | 15 | ~1.5% |
This weight directly scales vruntime accumulation, which is why nice values still work exactly as system administrators expect, even though the underlying mechanism is completely different from the old scheduler.
Scheduling Latency and the “Targeted Latency”
CFS doesn’t just let one task run for an unbounded time before switching — that would hurt responsiveness. It defines a targeted scheduling latency (sched_latency_ns, historically 20ms on many systems), which is the time window in which every runnable task should ideally get to run at least once.
The actual time slice a task gets within that window is:
time_slice = sched_latency_ns * (task_weight / total_weight_of_all_runnable_tasks)
So if 4 tasks of equal priority are runnable and the target latency is 20ms, each gets roughly a 5ms slice before the scheduler re-evaluates. If there are 40 tasks, slices shrink — but there’s a floor called sched_min_granularity_ns (commonly 4ms) so that with very high task counts, the CPU doesn’t spend all its time context-switching instead of doing useful work. When the number of runnable tasks grows large enough that dividing the latency window would push slices below the minimum granularity, CFS effectively extends the period instead, trading fairness precision for lower overhead.
Preemption: How Fairness Gets Enforced Mid-Flight
CFS doesn’t wait passively for a task’s time slice to expire. On every scheduler tick (and at certain wakeup events), the kernel checks: has the currently running task’s vruntime exceeded the vruntime of the leftmost task in the tree by more than its allotted slice? If so, it sets the need_resched flag, and the task gets preempted at the next safe opportunity (e.g., returning from a syscall or interrupt).
This is also why CFS handles I/O-bound and CPU-bound tasks well without special-casing them. A task that sleeps waiting for I/O accumulates vruntime very slowly (it’s not running), so when it wakes up, its vruntime is far behind everyone else’s — it lands near the leftmost side of the tree and gets scheduled almost immediately. This naturally gives interactive tasks (which spend a lot of time blocked on input, network, or disk) low latency without any explicit “interactivity bonus” hack. There is a small safeguard called sched_wakeup_granularity that limits how aggressively a newly woken task can preempt the current one, to avoid excessive context switching from bursty wakeups.
Scheduling Classes and Where CFS Fits
CFS is the scheduler for the SCHED_NORMAL (also called SCHED_OTHER) class — the default for regular processes. Linux actually has multiple scheduling classes, checked in priority order:
- Stop scheduler class — highest priority, used internally for CPU hotplug and migration.
- Deadline scheduler class (
SCHED_DEADLINE) — Earliest Deadline First for hard real-time tasks with explicit runtime/deadline/period parameters. - Real-time class (
SCHED_FIFO,SCHED_RR) — fixed-priority real-time scheduling, always preempts CFS tasks. - CFS class (
SCHED_NORMAL,SCHED_BATCH,SCHED_IDLE) — the fairness-based scheduler discussed here.
So “fairness” in CFS only applies among normal processes; real-time and deadline tasks sit above CFS entirely and can starve it if misconfigured (which is why the kernel has sched_rt_runtime_us throttling to reserve some CPU for non-real-time tasks).
CFS on Multi-Core Systems: Load Balancing
Everything above describes fairness on a single CPU run queue. Real machines have many cores, each with its own CFS run queue (its own red-black tree). Perfect global fairness would require a single shared tree, but that would create massive lock contention. Instead, CFS runs a periodic load balancer that:
- Groups CPUs into scheduling domains (hyperthread siblings, cores on a socket, NUMA nodes).
- Periodically compares the total weighted load across run queues in each domain.
- Migrates tasks from overloaded CPUs to underloaded ones to keep load roughly even.
- Considers cache locality and NUMA distance so migrations don’t needlessly throw away cache-warm state.
This is a soft, statistical fairness — not a hard guarantee like the single-queue vruntime ordering — but it keeps utilization balanced across cores in practice.
Group Scheduling (cgroups) and Container Fairness
Modern Linux systems run containers, and CFS supports hierarchical fair scheduling through cgroups. Instead of comparing individual tasks’ vruntime directly, CFS can compare groups of tasks (e.g., “container A” vs “container B”) as if each group were a single scheduling entity, and then fairly divide CPU within that group among its own tasks. This is how Kubernetes CPU requests/limits, Docker --cpu-shares, and systemd slices get enforced — they map onto cpu.shares (or cpu.weight under cgroup v2) which feeds directly into the same weight mechanism used for nice values.
There’s also cpu.cfs_quota_us and cpu.cfs_period_us, a separate bandwidth control mechanism layered on top of CFS, which caps the absolute amount of CPU time a group can consume per period, regardless of fairness — useful for hard-capping noisy-neighbor containers in multi-tenant environments.
Real-World Example: Watching CFS Work
You can observe CFS’s fairness directly. Run two CPU-bound loops at different niceness:
nice -n 0 yes > /dev/null &
nice -n 10 yes > /dev/null &
Then check CPU usage with top or htop. You’ll see the nice-0 process consistently getting a noticeably larger share of CPU time than the nice-10 process — roughly proportional to the weight table above — without either one being starved entirely. Contrast this with a strict fixed-priority scheduler, where the higher-priority process could monopolize the CPU completely.
You can also inspect scheduling statistics directly:
cat /proc/<pid>/sched
This shows se.vruntime, nr_switches, sum_exec_runtime, and more — useful for debugging scheduling-related performance issues.
Comparisons: CFS vs Other Schedulers
| Scheduler | OS | Core Mechanism | Fairness Model |
|---|---|---|---|
| CFS | Linux | Red-black tree ordered by vruntime | Proportional-share, weighted |
| O(1) Scheduler (pre-2.6.23) | Linux (legacy) | Priority arrays, fixed time slices | Priority-based with interactivity heuristics |
| Windows Scheduler | Windows NT kernel | Multilevel feedback queue, priority boosts | Priority-based with dynamic boosting |
| XNU Scheduler | macOS/iOS | Multilevel feedback queue + QoS classes | Priority + Quality-of-Service tiers |
| Completely Fair Queuing analog for Android | Android (Linux-based) | CFS + EAS (Energy Aware Scheduling) | Proportional-share + power/thermal awareness |
| BFS/MuQSS | Linux (out-of-tree, used in some distros like Zen kernel) | Single global run queue, virtual deadline | Simpler fairness model, tuned for desktop latency |
Windows and macOS both lean more heavily on explicit priority levels and dynamic priority boosting (e.g., Windows boosts the priority of a thread right after it’s woken from a wait, then decays it back down), whereas CFS achieves similar responsiveness as an emergent property of the vruntime math rather than through explicit “boost” rules.
EAS: CFS Meets Power Efficiency (Android and Mobile)
On Android and other battery-powered devices, raw fairness isn’t the only goal — power efficiency matters just as much. Linux’s Energy Aware Scheduling (EAS), built on top of CFS, adds a layer that considers each CPU cluster’s energy cost (important on big.LITTLE ARM designs with performance and efficiency cores). EAS still respects CFS fairness within a cluster but chooses which cluster/core to place a task on based on energy models, aiming to hit performance targets with minimal power draw. This is a good example of how the “pluggable” nature of Linux’s scheduling framework lets fairness coexist with other system goals.
Troubleshooting Common CFS-Related Issues
Symptom: A process seems throttled even though the CPU isn’t fully loaded. Check for cgroup CPU bandwidth limits:
cat /sys/fs/cgroup/cpu.max
If a quota is set lower than expected, the process is being throttled by CFS bandwidth control, not starved by fairness.
Symptom: Desktop feels laggy despite low average CPU usage. Look at sched_latency_ns and sched_min_granularity_ns in /proc/sys/kernel/. Very bursty workloads (compilation jobs spawning hundreds of short-lived processes) can increase scheduling latency even when average utilization is low. Tools like cyclictest (from rt-tests) can measure actual scheduling latency.
Symptom: One niced-down background job still noticeably affects interactive performance. Consider moving it to the SCHED_IDLE or SCHED_BATCH policy instead of just adjusting nice value, since these are explicit CFS sub-policies designed for background work:
chrt -i 0 -p <pid> # SCHED_IDLE
Symptom: Real-time process is starving normal processes. Check /proc/sys/kernel/sched_rt_runtime_us — by default the kernel reserves 5% of each second for non-RT tasks (sched_rt_period_us at 1,000,000 and sched_rt_runtime_us at 950,000), but misconfiguration can remove this safeguard.
Best Practices
- Use
nice/renicefor soft prioritization of normal workloads; don’t reach for real-time scheduling classes unless you actually need hard latency guarantees. - For background batch jobs, prefer
SCHED_BATCHorSCHED_IDLEover just a high nice value, since these policies also disable wakeup preemption in ways tuned for throughput over latency. - In containerized environments, set
cpu.weight/cpu.sharesfor relative fairness between services, and reservecpu.max(bandwidth quota) only when you need a hard ceiling — combining both gives predictable multi-tenant behavior. - When debugging latency-sensitive applications (audio, real-time trading, robotics), measure actual scheduling latency with
cyclictestrather than assuming CFS’s default tuning is sufficient — for hard real-time needs,SCHED_FIFO/SCHED_DEADLINEcombined with CPU isolation (isolcpus,nohz_full) is the correct tool, not CFS tuning. - Avoid nice values as a substitute for proper cgroup resource control in multi-tenant systems — nice values only affect relative CPU share, not memory, I/O, or absolute caps.
Summary
CFS achieves fairness not by handing out fixed time slices or maintaining brittle interactivity heuristics, but by continuously tracking how much CPU time each task should have gotten on an idealized, infinitely-parallel CPU, and always running whichever task has fallen furthest behind that ideal. The red-black tree keyed by vruntime makes this efficient at scale, nice values map cleanly onto proportional weights, and the same machinery extends naturally into cgroup-based group scheduling for containers. It’s a rare case in systems design where a mathematically clean idea — minimize the maximum deviation from an ideal fair-share baseline — turned out to also be practically fast and behaviorally intuitive.
FAQs
Does CFS guarantee exactly equal CPU time for all processes? No — it guarantees proportional fairness based on weight (derived from nice value or cgroup shares), not equal time. Two processes with the same nice value on an otherwise idle system will get roughly equal shares, but that’s a special case of the general proportional model.
Is CFS still used in modern Linux kernels? Yes, though recent kernels (6.6+) introduced EEVDF (Earliest Eligible Virtual Deadline First) as a replacement scheduling algorithm for the same SCHED_NORMAL class, addressing some edge cases in CFS’s latency behavior. EEVDF keeps the same weighted-fairness philosophy but changes the ordering criteria to also factor in an eligible time and virtual deadline per task.
Why doesn’t CFS use fixed time slices like older schedulers? Fixed time slices don’t scale gracefully as the number of runnable tasks changes, and they don’t naturally express proportional priority. The vruntime approach adapts smoothly to any number of tasks and any weight distribution.
Can I see which scheduler my system is using? Check cat /sys/kernel/debug/sched/features (requires debugfs mounted) or inspect /proc/version and kernel changelogs — kernels 6.6 and later default to EEVDF.
References
- Linux kernel documentation:
Documentation/scheduler/sched-design-CFS.rst - Linux kernel source:
kernel/sched/fair.c - Weidendorfer, J., “Understanding the Linux Kernel Scheduler” — kernel.org scheduler documentation
man 7 sched— Linux manual page on scheduling policies- Linux Kernel Mailing List (LKML) discussions on CFS design by Ingo Molnar (2007)