Explain how the Linux CFS ensures fairness in CPU allocation

Explain how the Linux CFS ensures fairness in CPU allocation

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 ValueWeightRelative CPU Share (2 competing tasks)
-2088761~97%
-53121~76%
0102450%
5335~24%
1915~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:

  1. Stop scheduler class — highest priority, used internally for CPU hotplug and migration.
  2. Deadline scheduler class (SCHED_DEADLINE) — Earliest Deadline First for hard real-time tasks with explicit runtime/deadline/period parameters.
  3. Real-time class (SCHED_FIFO, SCHED_RR) — fixed-priority real-time scheduling, always preempts CFS tasks.
  4. 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

SchedulerOSCore MechanismFairness Model
CFSLinuxRed-black tree ordered by vruntimeProportional-share, weighted
O(1) Scheduler (pre-2.6.23)Linux (legacy)Priority arrays, fixed time slicesPriority-based with interactivity heuristics
Windows SchedulerWindows NT kernelMultilevel feedback queue, priority boostsPriority-based with dynamic boosting
XNU SchedulermacOS/iOSMultilevel feedback queue + QoS classesPriority + Quality-of-Service tiers
Completely Fair Queuing analog for AndroidAndroid (Linux-based)CFS + EAS (Energy Aware Scheduling)Proportional-share + power/thermal awareness
BFS/MuQSSLinux (out-of-tree, used in some distros like Zen kernel)Single global run queue, virtual deadlineSimpler 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/renice for 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_BATCH or SCHED_IDLE over 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.shares for relative fairness between services, and reserve cpu.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 cyclictest rather than assuming CFS’s default tuning is sufficient — for hard real-time needs, SCHED_FIFO/SCHED_DEADLINE combined 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)
Total
0
Shares

Leave a Reply

Previous Post
What is the Completely Fair Scheduler (CFS) in the Linux operating system

What is the Completely Fair Scheduler (CFS) in the Linux operating system

Next Post
Discuss the significance of the virtual runtime in the CFS algorithm

Discuss the significance of the virtual runtime in the CFS algorithm

Related Posts