R0KernelPath
module 044 / 262

CPU Scheduling

CPU scheduling is the OS's art of creating the illusion of simultaneous execution by deciding, nanosecond by nanosecond, which process deserves the CPU next — and every design choice is a negotiation between throughput, fairness, and responsiveness.

~12h focused study12 concepts

Overview

CPU scheduling is the art and science of deciding which process (or thread) gets to run on the CPU, and for how long. At first glance it sounds bureaucratic — like a maître d' seating people at tables. But go deeper and you discover it is one of the most consequential pieces of software ever written. A bad scheduler makes your laptop feel sluggish, your video call choppy, your database grind. A great one makes hundreds of competing tasks feel simultaneous, keeps interactive applications snappy, and drains your battery more slowly. The Linux Completely Fair Scheduler (CFS), introduced in 2.6.23, is a masterpiece of applied theory — and understanding why it was designed the way it was is only possible if you understand what came before it.

This module builds your mental model from the ground up. We start with the most basic question: why does scheduling exist at all? The answer is that CPUs are fast and I/O is slow. If a process is waiting for a disk read, its CPU time is wasted. Multiplexing the CPU among many processes simultaneously is the key to keeping hardware utilized and users happy. We then explore the spectrum from cooperative to fully preemptive multitasking, study the classic algorithms that every OS course teaches, and analyze the trade-offs that make scheduling an endlessly interesting design problem.

By the end of this module you will be able to read a scheduler trace, reason about starvation and fairness, calculate turnaround and response times analytically, and understand why the Linux kernel's scheduler looks nothing like the textbook FCFS or Round Robin algorithms — and yet is built on exactly the same first principles. This is the foundation you need before you can understand kernel/sched/fair.c.

why this matters for the kernel

Every kernel contribution that touches performance — and most do — requires fluency in scheduling. When you read a bug report that says "my latency spiked under load," or "this workload got 30% slower after this patch," you are reading a scheduling story. The Linux scheduler lives in kernel/sched/ and is one of the most heavily contested subsystems in the kernel, with patches debated fiercely on LKML every week. Understanding MLFQ and virtual runtime is the direct prerequisite for understanding CFS, the real-time scheduling classes (SCHED_FIFO, SCHED_RR), and the newer EEVDF scheduler landing in 6.6+.

Beyond the kernel itself, scheduling literacy unlocks adjacent topics: lock contention, priority inversion (the bug that almost lost the Mars Pathfinder mission), CPU affinity, NUMA-aware placement, and the perf sched toolchain. You will also be a far better application developer — knowing when to use sched_yield(), how to set thread priorities responsibly, and why pinning a latency-sensitive thread to a dedicated core with pthread_setaffinity_np can cut tail latency by 10x. Scheduling is not an academic curiosity; it is the beating heart of every system that runs more than one thing at a time.

Multi-Level Feedback Queue: Process State and Queue Transitions

rendering diagram…

MLFQ state machine: a new process enters the highest-priority queue (Q0, smallest quantum). CPU-bound behavior causes demotion toward lower queues. I/O-bound behavior (blocking before quantum expires) keeps the process at high priority. Periodic boosts prevent starvation and adapt to changed behavior.

The concepts

01Why Scheduling Exists: The CPU-I/O Gap

A modern NVMe SSD delivers a read in roughly 100 microseconds. A CPU executes roughly 3 billion instructions per second. In the time a process waits for one disk read, the CPU could have executed 300,000 instructions for something else. This gap — between CPU speed and the speed of everything outside it — is the entire motivation for scheduling.

Without scheduling, the naive approach is to let a process run until it either finishes or explicitly yields. This is called bare-metal or single-tasking execution (think MS-DOS). The CPU sits idle during every I/O wait. With scheduling, the OS detects that a process is blocked waiting for I/O, yanks it off the CPU, and hands the CPU to another runnable process. When the I/O completes, the blocked process becomes runnable again and will eventually get the CPU back.

This is the fundamental abstraction: the OS creates the illusion that each process has its own dedicated CPU, even though the hardware has only one (or a few). The mechanism that makes this work is the context switch — saving the full CPU state of the outgoing process (registers, program counter, stack pointer, page table base) and restoring the state of the incoming process. To the processes, nothing happened; to the CPU, it was running someone else for a while.

Analogy: Think of a single chef (CPU) in a kitchen. Instead of standing idle while a pot boils (I/O wait), a good chef context-switches: starts the pasta water, then chops vegetables while it heats, then stirs the sauce. The chef is nearly always doing something useful. The OS is the head chef who decides what task gets the chef's hands at each moment.

02Process States and the Run Queue

Before we can schedule processes, we need to understand what states they can be in. A process is not simply 'running' or 'not running' — it has a lifecycle:

  • RUNNING: Currently executing on a CPU core.
  • RUNNABLE (READY): Has everything it needs to run; just waiting for the CPU.
  • BLOCKED (SLEEPING): Waiting for an event — disk I/O completion, a mutex, a timer, a network packet. Cannot use the CPU even if offered it.
  • ZOMBIE: Finished executing, waiting for its parent to collect its exit code.

The scheduler's job is to manage the set of RUNNABLE processes, called the run queue (or ready queue). It selects one of them, calls context_switch(), and hands over the CPU. When a running process calls read() on a file, the kernel moves it from RUNNING to BLOCKED and runs the scheduler to pick the next RUNNABLE process. When the I/O completes, an interrupt fires, the kernel moves the process back to RUNNABLE, and it re-enters the run queue.

In Linux, process state is stored in the task_struct at field __state. The relevant values are TASK_RUNNING (meaning runnable or actually running — Linux combines these two into one state), TASK_INTERRUPTIBLE (sleeping, can be woken by signals), TASK_UNINTERRUPTIBLE (sleeping, cannot be interrupted — this is the 'D' state you see in ps), and TASK_ZOMBIE.

The run queue in the CFS scheduler is not a simple list — it is a red-black tree ordered by a 'virtual runtime' value. But the conceptual model — a queue of 'who's ready to run' — is universal across all scheduler designs.

/* Simplified: what the kernel does when a syscall blocks */
void do_blocking_read(struct file *f) {
    /* Mark current task as sleeping */
    set_current_state(TASK_INTERRUPTIBLE);

    /* Add a wait entry so the I/O completion IRQ can wake us */
    add_wait_queue(&f->wait_queue, &wait);

    /* Voluntarily give up the CPU — triggers scheduler */
    schedule();

    /* We only get here after an interrupt woke us.
     * Our state is back to TASK_RUNNING. */
    remove_wait_queue(&f->wait_queue, &wait);
}

03Cooperative vs Preemptive Scheduling

There are two fundamental philosophies about when a process loses the CPU.

Cooperative (non-preemptive) scheduling: The running process keeps the CPU until it voluntarily yields — either by calling a blocking syscall, calling sched_yield(), or exiting. The OS never forcibly removes it. This was the model used by early Mac OS (System 1–9) and Windows 3.x. It is simple and requires no hardware timer interrupts. The fatal flaw: a buggy or malicious process can hog the CPU forever. One runaway loop and the whole system freezes.

Preemptive scheduling: The OS sets a hardware timer (the preemption timer or scheduling clock) to fire at a fixed interval — typically 1–10 milliseconds on modern Linux (the HZ kernel config parameter, e.g. CONFIG_HZ_1000 = 1000 interrupts/second = 1ms tick). When the timer fires, an interrupt transfers control to the kernel's timer interrupt handler. The handler checks: has the current process used up its time slice? If yes, it sets a need_resched flag. At the next safe point (interrupt return, or syscall return), the scheduler runs and may switch to another process.

The key insight is that preemption requires no cooperation from the process being preempted. Even an infinite loop while(1){} will be preempted. This is essential for interactive responsiveness — your video call's audio thread gets its turn even if your browser's JavaScript engine is busy.

Kernel preemption is a separate (more subtle) concept: whether the kernel itself can be preempted while running on behalf of one process to handle another. CONFIG_PREEMPT enables this in Linux. Without kernel preemption, a process running in kernel mode (e.g., inside a syscall) runs to completion before another task can run, which increases worst-case latency. CONFIG_PREEMPT_RT (the real-time patch) takes this further, converting almost all spinlocks to sleeping locks to achieve microsecond-range latencies.

/* Cooperative yield — voluntary CPU release */
#include <sched.h>

void cooperative_worker(void) {
    while (1) {
        do_some_work();
        /* Politely tell the scheduler:
         * "I'm done with my current burst, reschedule me" */
        sched_yield();
    }
}

/* On Linux, check if kernel preemption is enabled */
/* $ grep CONFIG_PREEMPT /boot/config-$(uname -r)
   CONFIG_PREEMPT=y   <- voluntary preemption
   CONFIG_PREEMPT_RT  <- full real-time preemption */

04Scheduling Goals and Metrics

There is no single 'best' scheduler because different workloads care about different things. Before comparing algorithms, you must know what you are optimizing for:

Throughput: Jobs completed per unit time. A batch compute cluster running genomics pipelines wants to maximize this. It does not care if any individual job starts a bit later.

Turnaround time: Time from job arrival to job completion = waiting time + execution time. Minimizing average turnaround is often the goal for batch systems.

Response time / latency: Time from a request being issued to the first response being produced. Interactive applications (terminals, GUI apps, games, audio) are extremely sensitive to this. A keystroke that takes 200ms to echo feels broken.

Fairness: Every process (or every user) gets an equitable share of the CPU over time. Fairness does not mean equal shares — it means proportional to each task's weight/priority.

CPU utilization: Fraction of time the CPU is doing useful work (not idle). For server workloads, you want this close to 100%. For laptops, high utilization = high power draw.

Starvation-freedom: A low-priority process must eventually get the CPU, even under heavy load from high-priority processes. Starvation (a process waiting forever) is a liveness violation — a correctness bug, not just a performance bug.

These goals are often in tension. Maximizing throughput often means running long jobs without interruption (poor response time). Maximizing responsiveness often means short time slices (more context switches = overhead = lower throughput). A real OS scheduler, like Linux CFS, is an engineered compromise that navigates these trade-offs based on a scheduling policy.

05FCFS and SJF: The Baseline Algorithms

First-Come, First-Served (FCFS): The simplest scheduler imaginable. Processes are run in the order they arrive. Non-preemptive. Easy to implement (a FIFO queue). The problem: the convoy effect. One long job at the head of the queue blocks all shorter jobs behind it. If a 30-second CPU-bound job arrives just before ten 100ms interactive jobs, those interactive jobs wait 30 seconds before getting any CPU.

Shortest Job First (SJF) / Shortest Remaining Time First (SRTF): Instead of FIFO order, always run the job with the shortest expected execution time. This is provably optimal for minimizing average turnaround time (among non-preemptive algorithms). The preemptive variant (SRTF) is optimal for average turnaround period.

The fatal flaw of SJF: you cannot know in advance how long a job will run. In the real world, you can only estimate. One common approach is exponential averaging: predict the next burst length as a weighted average of all past bursts. If past bursts were [10ms, 8ms, 12ms, ...], you extrapolate. But this is an estimate — a misbehaving job can blow past its predicted burst.

Another flaw: SJF can starve long jobs. If short jobs keep arriving, a long job may wait forever. In practice, SJF/SRTF is used mostly in batch systems where job lengths are known ahead of time (e.g., from a job submission manifest).

These two algorithms are the foundation from which every real scheduler departs. FCFS fails on latency; SJF fails on predictability and fairness. The algorithms that follow try to get the best of both worlds.

/* Simulate FCFS: jobs arrive at t=0, run in arrival order */
#include <stdio.h>

typedef struct { int id; int burst; } Job;

void fcfs(Job jobs[], int n) {
    int time = 0;
    for (int i = 0; i < n; i++) {
        int wait   = time;              /* waited since arrival */
        int finish = time + jobs[i].burst;
        int turnaround = finish;        /* arrival was t=0 */
        printf("Job %d: wait=%d turnaround=%d\n",
               jobs[i].id, wait, turnaround);
        time = finish;
    }
}

int main(void) {
    /* Three jobs: short, long, short — demonstrates convoy effect */
    Job jobs[] = {{1, 2}, {2, 20}, {3, 2}};
    fcfs(jobs, 3);
    /* Output:
       Job 1: wait=0  turnaround=2
       Job 2: wait=2  turnaround=22   <- long job
       Job 3: wait=22 turnaround=24   <- short job waited 22ms! */
}

06Round Robin and Time Slices

Round Robin (RR) is the quintessential preemptive scheduler. Each process gets a fixed time slice (also called a quantum or time quantum) — say, 10ms. When the slice expires, the process is moved to the back of the ready queue and the next process runs. No process can monopolize the CPU; everyone gets a turn.

RR is excellent for response time. If there are N processes and the quantum is q, the worst-case wait before getting the CPU is (N-1) * q. With 10 processes and a 10ms quantum, you wait at most 90ms — acceptable for interactive use.

The quantum size is a critical tuning knob:

  • Too small (e.g., 1ms): Context switches happen constantly. Each switch takes 1–10 microseconds of overhead (saving/restoring registers, flushing TLB entries, cache warming). With a 1ms quantum, you might spend 1% of CPU time on context switches — significant. Worse, cache thrashing: each new process pollutes the CPU cache with its working set, slowing everyone down.
  • Too large (e.g., 100ms): RR degenerates toward FCFS. Response time suffers.

Typical values: Linux historically used 100ms quanta (HZ=100 era). Modern Linux with CONFIG_HZ_1000 has a 1ms timer tick but the scheduler tick and actual quantum are more complex — CFS does not use fixed quanta at all.

RR has worse turnaround than SJF (because long jobs get interrupted repeatedly) but much better response time for interactive tasks. It is fair by construction: over any window of N*q ms, every process gets exactly q ms of CPU — true fairness.

The weighted variant assigns each process a weight (priority). Higher-weight processes get proportionally larger shares. This is the direct conceptual ancestor of Linux's nice value and CFS's weight table.

/* Simulate Round Robin with quantum=4 for three jobs */
#include <stdio.h>
#include <string.h>

void round_robin(int bursts[], int n, int quantum) {
    int remaining[n];
    memcpy(remaining, bursts, n * sizeof(int));
    int time = 0, done = 0;
    while (done < n) {
        for (int i = 0; i < n; i++) {
            if (remaining[i] <= 0) continue;
            int run = remaining[i] < quantum ? remaining[i] : quantum;
            printf("t=%2d  run P%d for %dms\n", time, i+1, run);
            time += run;
            remaining[i] -= run;
            if (remaining[i] == 0) {
                printf("      P%d finished at t=%d\n", i+1, time);
                done++;
            }
        }
    }
}

int main(void) {
    int bursts[] = {10, 4, 6};  /* P1=10ms P2=4ms P3=6ms */
    round_robin(bursts, 3, 4);
}

07Priority Scheduling and Starvation

Every real OS lets you assign a priority to a process. The scheduler always picks the highest-priority runnable process. Linux uses two priority scales:

  1. Nice values (-20 to +19): A user-space hint. Lower nice = higher priority. nice -n -10 ./myprogram runs a program with elevated priority. Root is needed to go below 0. The default nice is 0.
  2. Real-time priorities (1–99 in SCHED_FIFO/SCHED_RR): Completely separate from nice. Real-time tasks always preempt normal tasks.

Priority scheduling is powerful but introduces a dangerous pathology: starvation. If high-priority tasks keep arriving, a low-priority task may never run. In a busy server, a background job at nice +19 might wait minutes for CPU time.

Priority inversion is an even nastier bug. Imagine three tasks: H (high priority), M (medium), L (low). L holds a mutex. H needs that mutex and blocks. M is now the highest runnable task and preempts L, preventing L from releasing the mutex, which prevents H from running. H is effectively blocked by M, even though M has lower priority than H. This bug caused the Mars Pathfinder spacecraft to reset repeatedly in 1997 (it ran VxWorks with priority inversion in a bus mutex).

The fix is priority inheritance: when H blocks on a mutex held by L, temporarily elevate L's priority to H's level so it can finish and release the mutex. Linux's PTHREAD_PRIO_INHERIT mutex protocol implements this.

Aging is the standard starvation fix: gradually increase the priority of waiting tasks over time. If a task has been waiting 10 seconds, bump its effective priority up by 1. This guarantees eventual execution.

#include <pthread.h>
#include <stdio.h>

/* Using priority-inheriting mutex to prevent priority inversion */
int main(void) {
    pthread_mutex_t mutex;
    pthread_mutexattr_t attr;

    pthread_mutexattr_init(&attr);
    /* PTHREAD_PRIO_INHERIT: if a high-prio thread blocks on this
     * mutex, the low-prio holder gets its priority temporarily raised */
    pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT);
    pthread_mutex_init(&mutex, &attr);
    pthread_mutexattr_destroy(&attr);

    /* Now use mutex normally — inversion is prevented automatically */
    pthread_mutex_lock(&mutex);
    /* ... critical section ... */
    pthread_mutex_unlock(&mutex);
    pthread_mutex_destroy(&mutex);
    return 0;
}

08Multi-Level Feedback Queue (MLFQ)

MLFQ is the most important scheduler design to understand before studying CFS. It was first described by Corbató et al. in 1962 for the Compatible Time-Sharing System (CTSS) at MIT — one of the most influential papers in OS history. The insight: we want SJF's low turnaround time, but we cannot know job lengths in advance. What if we learn job behavior at runtime?

The rules:

  1. There are N queues, each with a different priority (Q0 = highest, QN-1 = lowest).
  2. A job starts in Q0 (highest priority, smallest quantum).
  3. If a job uses its entire quantum without blocking, it gets demoted to the next lower queue (suspicion: CPU-bound long job).
  4. If a job blocks before its quantum expires (e.g., it did I/O), it stays in its current queue (good behavior: interactive/short job).
  5. The scheduler always picks from the highest non-empty queue.
  6. Periodically (every S seconds), all jobs are boosted back to Q0 (prevents starvation and adapts to changing behavior).

The elegance: a short interactive job (like a keystroke handler) naturally stays in Q0, getting excellent response time. A long CPU-bound job sinks to low-priority queues, getting large quanta (efficient for throughput) but low priority. A process that starts CPU-bound but later becomes interactive will rise in priority after a boost.

MLFQ is essentially a heuristic approximation of SJF using recent history as a proxy for future behavior. It is not perfect — a CPU-bound process that carefully yields just before its quantum expires can game the system (the fix: track total CPU time used, not just per-slice usage).

Linux's O(1) scheduler (2.6.0–2.6.22) was an MLFQ variant with 140 priority levels and two arrays (active/expired). CFS replaced it, but the MLFQ insight — use execution history to infer job type — lives on in the scheduler's handling of wakeup bonuses and sleep-time tracking.

09Fairness and the Ideal Fair Scheduler

What does it mean for a scheduler to be fair? The formal definition is: given N processes with equal priority, each should receive exactly 1/N of the CPU over any sufficiently long interval.

The Ideal Fair Scheduler (a theoretical construct) runs all N processes simultaneously, each at 1/N speed. Every process advances at the same rate. Obviously impossible on a single CPU — you can only run one process at a time. But you can approximate this ideal by keeping careful track of how much CPU time each process has received and always scheduling the process that is furthest behind.

This is exactly what CFS does, via the concept of virtual runtime (vruntime). When a process runs, its vruntime increases. The amount it increases per real-time nanosecond is inversely proportional to its weight (priority). A high-priority (low nice) process's vruntime grows slowly; a low-priority process's vruntime grows quickly. The scheduler always picks the process with the smallest vruntime — the one that is furthest behind its fair share.

CFS stores all runnable processes in a red-black tree keyed by vruntime. The leftmost node (smallest vruntime) is the next process to run. Insertion, deletion, and min-lookup are all O(log N). This is why CFS is scalable to thousands of concurrent processes.

The target latency (default ~6ms, tunable via /proc/sys/kernel/sched_latency_ns) is the scheduling period within which every runnable process should get at least one turn. With N processes, each gets target_latency / N per period. But there is a floor: minimum granularity (default 0.75ms) prevents the slice from becoming so small that context-switch overhead dominates.

/* Inspect CFS parameters on your Linux system */
/* Run these in a terminal: */

// cat /proc/sys/kernel/sched_latency_ns
// -> e.g., 6000000  (6ms target scheduling latency)

// cat /proc/sys/kernel/sched_min_granularity_ns
// -> e.g., 750000   (0.75ms minimum time slice)

// cat /proc/sys/kernel/sched_wakeup_granularity_ns
// -> e.g., 1000000  (wakeup preemption threshold)

/* View vruntime and scheduling info for all tasks: */
// cat /proc/sched_debug  (requires root or CAP_SYS_ADMIN)

/* Check nice value of your shell: */
// ps -o pid,ni,comm -p $$

10Context Switch Cost and Cache Effects

A context switch is not free. Understanding its cost is critical for reasoning about scheduler overhead.

Direct cost: Saving and restoring the CPU state. This includes general-purpose registers (~16 on x86-64), floating-point/SIMD registers (the xsave/xrestore instruction, which can save 512–2048 bytes of state), the program counter, stack pointer, and segment registers. The OS also switches the page table (by writing to CR3 on x86-64 or TTBR0/TTBR1 on ARM64). Writing CR3 invalidates the TLB (Translation Lookaside Buffer) — the hardware cache that translates virtual addresses to physical addresses. Post-switch, every memory access that misses the TLB requires a slow page table walk (hundreds of nanoseconds each).

Indirect cost (cache pollution): The outgoing process had its working set loaded in L1/L2/L3 cache. The incoming process has a cold cache. For a process with a large working set, the first few milliseconds after a context switch are dominated by cache misses. Benchmarks show that context switch overhead can range from a few microseconds (for tiny working sets) to hundreds of microseconds (for cache-thrashing workloads).

ASID (Address Space Identifier): Modern CPUs can tag TLB entries with an ASID, so switching from process A to process B does not necessarily flush A's TLB entries — they remain tagged and usable when A runs again. Linux uses ASIDs on x86-64 (since 4.14) and ARM64, dramatically reducing context switch cost for frequently-switched processes.

Measuring context switch overhead:

$ sudo perf stat -e context-switches,cache-misses,instructions -- stress -c 8 -t 5

This shows you context switches and cache misses caused by 8 CPU-saturating threads competing on your machine.

The practical lesson: fewer, longer time slices reduce context switch overhead but hurt interactivity. More, shorter slices improve responsiveness but incur overhead. CFS's adaptive quantum (proportional to N active tasks) is the principled middle ground.

/* Measure context switch time with two ping-pong threads */
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>

#define ITERS 100000

pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;

void *thread_b(void *arg) {
    for (int i = 0; i < ITERS; i++) {
        pthread_mutex_lock(&m1);    /* wait for A's signal */
        pthread_mutex_unlock(&m2); /* signal A */
    }
    return NULL;
}

int main(void) {
    pthread_t t;
    pthread_mutex_lock(&m2); /* pre-lock so B can't proceed yet */
    pthread_create(&t, NULL, thread_b, NULL);

    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (int i = 0; i < ITERS; i++) {
        pthread_mutex_unlock(&m1); /* wake B */
        pthread_mutex_lock(&m2);  /* wait for B -> context switch */
    }
    clock_gettime(CLOCK_MONOTONIC, &t1);

    long ns = (t1.tv_sec - t0.tv_sec) * 1000000000L
            + (t1.tv_nsec - t0.tv_nsec);
    printf("Avg context switch: %ld ns\n", ns / (2 * ITERS));
    pthread_join(t, NULL);
}

11Real-Time Scheduling: SCHED_FIFO and SCHED_RR

Linux supports multiple scheduling classes with strict priority ordering. From highest to lowest: stop (migration), deadline (SCHED_DEADLINE), real-time (SCHED_FIFO, SCHED_RR), and normal (SCHED_OTHER/CFS, SCHED_IDLE).

SCHED_FIFO (First-In, First-Out real-time): A SCHED_FIFO task with real-time priority P runs until it either blocks or explicitly yields. It is never preempted by any task with equal or lower RT priority. If you have two SCHED_FIFO tasks at the same priority, they share the CPU cooperatively (the one that was runnable first goes first). This is used for hard real-time tasks like audio/video mixing daemons (PulseAudio, JACK).

SCHED_RR (Round Robin real-time): Like SCHED_FIFO but with a fixed quantum. Processes at the same RT priority share the CPU in round-robin fashion. Combines predictability with fairness among equal-priority RT tasks.

SCHED_DEADLINE (Earliest Deadline First): The most sophisticated Linux RT policy. You specify three parameters: runtime, period, and deadline. The scheduler guarantees the task gets runtime nanoseconds of CPU within each period, meeting its deadline. This is the CBS (Constant Bandwidth Server) algorithm, suitable for tasks with hard real-time requirements.

Warning: A SCHED_FIFO task at RT priority 99 can starve the entire system, including the kernel's watchdog. On a uniprocessor system, a SCHED_FIFO task in an infinite loop will hang the machine. The kernel sets aside 5% CPU time for non-RT tasks by default (/proc/sys/kernel/sched_rt_runtime_us).

To set RT scheduling: chrt -f 50 ./my_rt_task (requires CAP_SYS_NICE or appropriate rlimits).

#include <sched.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    struct sched_param sp;
    memset(&sp, 0, sizeof(sp));
    sp.sched_priority = 50;  /* RT priority 1-99 */

    /* Switch THIS process to SCHED_FIFO RT scheduling.
     * Requires CAP_SYS_NICE (run as root or set rlimit). */
    if (sched_setscheduler(0, SCHED_FIFO, &sp) == -1) {
        perror("sched_setscheduler");
        return 1;
    }
    printf("Now running as SCHED_FIFO priority 50\n");
    /* From here: never preempted by any task with priority < 50.
     * Make sure to block on I/O or yield, or you'll starve others. */
    return 0;
}

12Measuring and Observing the Scheduler

Theoretical understanding is only half the story. Real kernel engineers use tools to observe the scheduler in action. This is how you move from textbook to production.

perf sched: The premier Linux scheduler analysis tool. perf sched record captures a trace of every context switch and wakeup; perf sched latency shows per-task scheduling latency statistics; perf sched timehist draws a timeline of which task ran on which CPU when. These tools reveal priority inversions, scheduling jitter, and starvation in real workloads.

ftrace / trace-cmd: The kernel's built-in tracer. echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable records every context switch with nanosecond timestamps, old and new process names, and states. This is what the kernel developers themselves use.

/proc/[pid]/sched: Per-process scheduling statistics — wait time, number of migrations, involuntary context switches. cat /proc/self/sched to see your current shell's stats.

schedtool: Inspect and set scheduling policy/priority for any process.

stress-ng --sched: Generates synthetic scheduler load for benchmarking.

When you read scheduler patches on LKML, you will often see performance data from these tools. Learning to use them is a direct path to being able to contribute scheduler patches — because any change must be backed by measurement data showing improvement without regression.

#!/bin/bash
# Observe scheduler activity on your system

# 1. Record 5 seconds of scheduler events
sudo perf sched record -- sleep 5

# 2. Show per-task scheduling latency
sudo perf sched latency

# 3. Enable sched_switch ftrace events (requires root)
sudo sh -c '
  echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
  sleep 1
  echo 0 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
  cat /sys/kernel/debug/tracing/trace | head -50
'

# 4. See your shell process's scheduling stats
cat /proc/$$/sched

# 5. Check the current scheduler policy for a process
chrt -p $$

Key insights

  • ✦Scheduling exists because of the CPU-I/O speed gap — without it, a single blocking operation wastes thousands of CPU cycles that could serve other work.
  • ✦Preemption is not just a performance optimization — it is a correctness requirement for any system that must remain responsive under adversarial or buggy workloads.
  • ✦No single algorithm is optimal: FCFS wins on simplicity, SJF on average turnaround, RR on response time. Real schedulers are engineered trade-offs, not pure implementations of any one algorithm.
  • ✦MLFQ is the conceptual heart of modern schedulers: use execution history to infer job type and assign proportional resources, adapting dynamically as behavior changes.
  • ✦A context switch is not just a register swap — TLB invalidation and cache warming are the hidden costs that dominate in cache-sensitive workloads.
  • ✦Priority inversion is a real bug with real spacecraft-crashing consequences. Priority inheritance is not a theoretical curiosity; it is a mandatory tool when mixing priority levels and shared resources.
  • ✦CFS's virtual runtime is a computational realization of the Ideal Fair Scheduler — it approximates running all processes simultaneously by always scheduling the most time-deprived one.
  • ✦Measuring the scheduler with perf/ftrace is how real kernel contributions get made — any scheduler patch without benchmark data will be rejected on LKML.

Common pitfalls

  • ⚠Confusing 'TASK_RUNNING' in Linux with 'actually running on CPU' — in Linux, TASK_RUNNING means *runnable or running*. A process in TASK_RUNNING may be sitting in the run queue waiting for a free CPU core.
  • ⚠Assuming context switches are cheap. On cache-sensitive workloads, a context switch can cause hundreds of microseconds of slowdown due to TLB flushes and cache cold-start, not just the microseconds of register save/restore.
  • ⚠Thinking 'lower nice value = lower priority'. It is the opposite: nice -20 is the *highest* priority, nice +19 is the *lowest*. The 'nice' value is how nice you are to *others* — a high nice means you give others more CPU.
  • ⚠Forgetting that SCHED_FIFO tasks never yield unless they block or explicitly call sched_yield(). A single SCHED_FIFO task in an infinite loop at high RT priority will starve all normal and lower RT-priority tasks.
  • ⚠Conflating scheduling policy with scheduling priority. In Linux, SCHED_FIFO and SCHED_RR are policies; 1-99 are priorities within those policies. A SCHED_OTHER (CFS) task has its own priority space via nice values.
  • ⚠Assuming SJF is always better than FCFS. SJF can cause unbounded starvation of long jobs under continuous short-job arrival — for production systems, starvation freedom often matters more than optimal average turnaround.
  • ⚠Treating the HZ timer tick as the scheduling quantum. In modern Linux (especially with CONFIG_HZ_1000 and NO_HZ), the relationship between tick rate and actual scheduler quantum is complex. CFS's quantum is computed dynamically from sched_latency_ns and the number of runnable tasks, not from HZ directly.
  • ⚠Implementing a 'gaming fix' for MLFQ by only tracking per-quantum CPU usage instead of total accumulated CPU time. The canonical fix is to count total CPU time consumed by a task across all quanta, not just whether it yielded during the current quantum.

Practice

  1. 01
    easy

    Write a C program that simulates FCFS, SJF (non-preemptive), and Round Robin (quantum=4) for the same set of 5 jobs with given arrival times and burst times. Print average turnaround time and average waiting time for each algorithm. Observe how the results change when you reorder the jobs or change the quantum.

  2. 02
    medium

    Implement a simplified MLFQ scheduler in C or Python with 3 queues (quanta: 4ms, 8ms, 16ms). Accept a list of jobs as input. Output a Gantt chart showing which job ran at each time slice. Add the priority-boost rule (every 20ms, all jobs return to Q0). Test it with a mix of short interactive jobs and one long CPU-bound job.

  3. 03
    medium

    Write a benchmark that measures the actual context switch latency on your Linux machine using two threads that ping-pong through a mutex (as in the code example). Run it with and without CPU pinning (pthread_setaffinity_np). Report the average, median, and 99th-percentile latency. Explain in comments why CPU pinning changes the result.

  4. 04
    medium

    Use 'perf sched record' and 'perf sched latency' on your Linux system. Introduce a workload with 'stress-ng -c 4 -t 10' (4 CPU-bound workers for 10 seconds) and measure the scheduling latency of a background interactive process (e.g., a loop that calls nanosleep(1ms)). Compare latency before and after adding the stress workload. Document your findings.

  5. 05
    hard

    Implement a toy virtual-runtime-based fair scheduler (a miniature CFS) in Python. Represent the run queue as a sorted list (or use the heapq module) keyed by virtual_runtime. Each process has a weight (1.0 for normal, 2.0 for high-priority). When a process runs for dt milliseconds, increment its vruntime by dt / weight. Always schedule the process with the minimum vruntime. Simulate 5 processes (3 weight=1.0, 2 weight=2.0) for 1000ms and verify that weight-2.0 processes received approximately double the CPU time.

Resources

📕Operating Systems: Three Easy Pieces — Chapter 7 (Scheduling) through Chapter 10 (MLFQ)
bookfree

The single best free textbook treatment of scheduling; Chapter 8 on MLFQ is a masterpiece of clear explanation with worked examples.

✎Inside the Linux Completely Fair Scheduler — IBM Developer
articlefree

Walks through CFS vruntime, the red-black tree, and load balancing with concrete code pointers into kernel/sched/fair.c.

✎LWN: The Linux scheduler: a decade of wasted cores
articlefree

A landmark 2016 paper (EuroSys) that exposed a critical CFS bug where idle cores went unused; essential reading for understanding real-world scheduler failures.

📄Linux kernel source: kernel/sched/
docsfree

Browse the actual CFS implementation (fair.c), real-time scheduler (rt.c), and deadline scheduler (deadline.c) with cross-reference navigation.

📄perf sched man page and tutorial
docsfree

Official reference for the perf sched subcommand; pair with Brendan Gregg's perf examples for practical usage patterns.

📄Scheduling documentation in the Linux kernel
docsfree

Official kernel docs covering CFS design, deadline scheduling, sched-domains, and real-time throttling. Written by kernel developers for kernel developers.

📕Modern Operating Systems (4th ed.) — Tanenbaum
bookpaidPearson; widely available in university libraries

Chapter 2.4 gives a rigorous, precise treatment of scheduling algorithms with excellent diagrams; good complement to OSTEP's narrative style.

✎LWN: EEVDF scheduler — an explainer
articlefree

Covers the Earliest Eligible Virtual Deadline First scheduler that landed in Linux 6.6, which addresses CFS latency-unfairness problems; directly relevant to current kernel development.

Checkpoints — you can now…

  • ✓You can explain the CPU-I/O gap and why it makes time-multiplexed scheduling necessary, using a concrete numerical example.
  • ✓You can draw the process state diagram (RUNNING / RUNNABLE / BLOCKED / ZOMBIE) and explain what event causes each transition.
  • ✓You can calculate average turnaround time and waiting time for a given set of jobs under FCFS, SJF, and Round Robin by hand.
  • ✓You can explain the three key MLFQ rules (admission, demotion, boost) and demonstrate with a worked example how an interactive job stays in Q0 while a CPU-bound job sinks to Q2.
  • ✓You can describe what happens during a context switch at the hardware level — which registers are saved, why CR3/TTBR0 matters, and why TLB flush is the dominant cost.
  • ✓You can explain priority inversion with the H/M/L mutex example and state what priority inheritance does to fix it.
  • ✓You can explain CFS virtual runtime: why vruntime grows inversely with weight, why the run queue is a red-black tree, and how target latency and minimum granularity bound the effective time slice.
  • ✓You can use 'perf sched latency' or 'cat /proc/[pid]/sched' to observe real scheduling behavior on a running Linux system.