Writing A Simple Multiprocessor Scheduler: Lottery Scheduling And Stride Scheduling

2025-12-07 · Leonardo Benicio
Summary

A comprehensive technical exploration of writing a simple multiprocessor scheduler: lottery scheduling and stride scheduling, covering key concepts, practical implementations, and real-world applications.

Contents

The Tyranny of the Clock: Why Your OS Needs a Fairer Boss (and How to Build One)

(Introduction as provided, 1,200 words)


Now that we’ve set the stage—the city, the main road, the tyrannical clock—let’s dig into the concrete realities of time‑based scheduling. Why has round‑robin (RR) been so dominant for decades? Where does it fall short? And how can a scheduler break free from the tyranny of fixed time slices by thinking in terms of shares?


1. The Deeper Flaw of Time‑Based Scheduling

A Brief History of the Clock

The earliest operating systems were simple batch monitors: jobs ran to completion, one after another. There was no scheduling—just a queue. Then came multiprogramming, and with it the need to share the CPU among multiple active processes. Round‑robin emerged in the 1960s with the CTSS (Compatible Time‑Sharing System) at MIT. It was elegant: give each process a time quantum (say 100 ms), and when that quantum expires, preempt it and move to the next. The cycle never ends.

The beauty of RR is its predictability. Every process gets a guarantee of CPU time within a bounded waiting time. For interactive systems, this eliminated the “starve until a long job finishes” problem. But that guarantee comes at a cost: it treats all processes as identical consumers of time, ignoring their actual needs.

The Cost of Uniformity

Consider two processes:

  • Process A: High‑frequency trading algorithm. It needs to react to a market event within 1 microsecond.
  • Process B: A background systemd service that rotates logs every hour.

Under RR with a 100 ms quantum, both get 100 ms slices. But Process A will likely block on I/O (waiting for network data) before its quantum finishes, while Process B might use its entire quantum. The scheduler doesn’t know that A is latency‑sensitive—it just sees two “cars” on the road.

The result: poor responsiveness for latency‑critical workloads. To compensate, system designers often give A a higher priority, but priority scheduling brings its own demons (starvation, priority inversion, complexity). Moreover, RR’s fairness is superficial: it’s fair in terms of time per turn, but not in terms of useful work per turn. If a process spends 90% of its quantum waiting for a lock, it wasted 90% of its slice—and everyone else’s wait time accumulates.

Why Not Just Make the Quantum Tiny?

You might think: “If we shrink the quantum to 1 µs, we get near‑perfect responsiveness.” Nonsense. Context switching itself has overhead—saving/restoring registers, flushing TLBs, updating page tables. On a modern CPU, a context switch can take 1–10 µs. If the quantum is 1 µs, you’re spending more time switching than doing actual work. Thus, the quantum must be large enough to amortize overhead, but small enough to give reasonable interactivity. That’s a fundamental tension.

Real‑World Example: The 1990s and the “Thrashing” of Priority‑Boosted RR

Windows 95 and 98 used a variant of RR with priority boosting. When a window received keyboard input, its thread got a temporary priority increase. This helped GUI responsiveness, but if a background CPU‑bound thread (e.g., a video renderer) ran at high priority, it could starve the GUI. Microsoft later introduced variable quantum scheduling, but the core remained time‑sliced.

The Analogy Extended: The City’s Traffic Lights

Think of RR as a traffic light at every intersection that cycles green for exactly 10 seconds per direction, regardless of traffic flow. An ambulance (high‑priority emergency) has to wait its turn. A garbage truck that takes 5 seconds to cross still gets 10 seconds, wasting the next 5. Meanwhile, a bicycle that could cross in 2 seconds must wait for the full cycle. The light itself becomes the bottleneck, not the road.


2. Priority Scheduling: A Better Tyrant?

To address the shortcomings of pure RR, most modern systems layer priorities on top. A high‑priority process gets more CPU time, either by running more frequently or by preempting lower‑priority processes. This is the “emergency lane” approach.

Static vs. Dynamic Priorities

  • Static priorities: Assigned at creation (e.g., real‑time threads in Linux with SCHED_FIFO or SCHED_RR). A real‑time thread at priority 99 will always preempt any normal thread (priority 0–97). This gives deterministic behavior but risks starving everything else.
  • Dynamic priorities: Adjusted over time by the scheduler. For example, Unix nice values influence the priority calculator; interactive processes get boosted, CPU‑bound processes get penalized. This is an attempt to approximate “fairness” without strict time slicing.

The Dark Side: Starvation and Inversion

Starvation: A low‑priority process may never run if there is always a higher‑priority process ready. In early UNIX, a CPU‑bound low‑nice job could be delayed indefinitely. The scheduler was not “fair”—it was biased. Even today, if you nice -n 19 a massive computation, it might run only when the CPU is idle, which could be never on a loaded system.

Priority inversion: A higher‑priority task blocks waiting for a resource held by a lower‑priority task. The middle‑priority task (which doesn’t need the resource) can preempt the low‑priority holder, causing the high‑priority task to wait longer than if it ran directly—a classic bug in Mars Pathfinder (1997). The fix was priority inheritance, where the low‑priority task temporarily inherits the high priority of the blocked task.

The Real‑Time Realm: Perfect Predictability for a Price

Real‑time operating systems (RTOS) like VxWorks or FreeRTOS often use fixed‑priority preemptive scheduling based on deadlines. Earliest Deadline First (EDF) is theoretically optimal but rarely used in general‑purpose OSes due to overhead and lack of overload guarantees. For most applications, we don’t need hard real‑time—we just need reasonable responsiveness and fairness.

Why Priority Alone Isn’t the Answer

Priority tells you which thread to run next but not how much each thread should get over time. Without a share model, it’s easy to allocate CPU in a lopsided way. Imagine two threads: one priority 10, another priority 1. If both are CPU‑bound, the high‑priority thread might get 90% of the CPU—but what if the user wants them to share equally? Priorities are relative, not absolute.

Thus, we need a paradigm shift: instead of assigning “importance” as a number, assign shares of the resource.


3. The Share‑Based Paradigm: Fairness as Fractions

The central insight: every thread should receive a proportion of CPU time proportional to its weight. If thread A has weight 2 and thread B has weight 1, then over any sufficiently long interval A should get roughly twice the CPU time of B. This is the share model, and it replaces the rigid clock with a flexible, weighted distribution.

Lottery Scheduling: Beauty in Simplicity

One of the earliest and most elegant share‑based approaches is lottery scheduling (Waldspurger & Weihl, 1994). Each thread holds a number of tickets. The scheduler holds a lottery: it picks a random number from 0 to total tickets, and the thread that “owns” that ticket runs for a small time slice (e.g., one quantum). Over time, the expected CPU share equals the thread’s fraction of total tickets.

Advantages:

  • Probabilistic fairness: No need for complex tracking of past history.
  • Graceful overload: If a thread holds 1% of tickets, it gets ~1% of CPU—no starvation.
  • Easy share reallocation: Give tickets to groups, users, or processes. A thread can donate tickets to another (e.g., in a client‑server scenario).

Example: Three threads with tickets: A (50), B (30), C (20). Over 1000 quanta, A gets ~500, B ~300, C ~200. The randomness introduces only statistical variance; over long periods it’s fair.

Implementation is trivial:

int total_tickets = sum of all thread tickets;
int winning_ticket = rand() % total_tickets;
thread_t *next = find_thread_by_ticket(winning_ticket);

But find_thread_by_ticket can be O(N) if done naively. A tree structure (e.g., a binary tree with cumulative ticket sums) makes it O(log N).

Stride Scheduling: Deterministic Weighted Fairness

Lottery’s randomness can cause short‑term unfairness: if you only run for 10 quanta, the distribution might be far from the expected shares. Stride scheduling (also Waldspurger, 1995) provides deterministic fairness by maintaining a “pass” value for each thread.

Each thread has a stride = (some large constant) / weight. At every scheduling decision, the thread with the smallest pass runs, and its pass is incremented by its stride. Over time, the number of times a thread runs (quantum) is exactly proportional to its weight.

Example: Weights 2 and 1 → strides 50 and 100 (assuming constant=100). Thread A starts with pass=0, B pass=0. Both equal, pick A. A’s pass becomes 50. Next: A=50, B=0 → pick B. B’s pass becomes 100. Next: A=50, B=100 → pick A. A’s pass becomes 100. Next: A=100, B=100 → tie, pick A (or arbitrary). Sequence: A, B, A, A, B, A, A, B,… — 4 A’s for 3 B’s in 7 quanta? Actually over 3 cycles of B (300 passes) A runs 6 times (6*50=300). Ratio 2:1.

Stride scheduling is deterministic and avoids statistical variance, but it requires handling wrap‑around (passes can get large) and tie‑breaking. Also, on a busy system, the data structure (priority queue on passes) needs to be efficient.

Comparison: Lottery vs. Stride vs. CFS

The Linux Completely Fair Scheduler (CFS) uses a hybrid idea: it maintains a virtual runtime (vruntime) for each task. On each tick, the scheduler picks the task with the smallest vruntime. The increment to vruntime is scaled by the task’s weight (nice value). This is essentially a weight‑adjusted stride scheduler, but with continuous time rather than discrete quanta.

CFS uses a red‑black tree to keep tasks sorted by vruntime, achieving O(log N) insertion and removal. It also enforces a minimum granularity (sched_min_granularity) to avoid excessive context switching, and a latency target (sched_latency_ns) which defines the window over which fairness is guaranteed.

Why Shares Over Time?

A share‑based scheduler decouples the allocation policy from the scheduling mechanism. The policy can be “each user gets 10% of CPU” or “each container gets 50%”. The mechanism ensures that over any reasonable interval, the actual CPU time matches the shares. This is far more expressive than fixed quanta.

Practical benefit: In cloud environments, a hypervisor (like KVM) can assign shares to VMs. Inside each VM, the guest OS can use its own scheduler. The host ensures that, say, VM1 gets twice the CPU of VM2 even if both are fully loaded. This is the basis of proportional‑share scheduling.


4. Building a Fair Scheduler from Scratch

Let’s design a minimal weighted fair‑queueing scheduler inspired by CFS, but in a simplified form. We’ll implement it in Python as a simulator. The key components:

  1. Task: has weight, virtual time, and remaining length (for simulation).
  2. Runqueue: a priority queue ordered by virtual time (min‑heap).
  3. Scheduler loop: picks task, runs it for a small quantum, updates its virtual time, re‑inserts.

The Algorithm

Each task i has weight w_i.
A global variable `total_weight` is sum of all weights.
Define `min_vruntime` (the smallest vruntime among all tasks).
When task runs for delta_t, its vruntime increases by delta_t * (total_weight / w_i).
(This is the scaled vruntime.)

Why the scaling? If a lighter task runs for 1 ms, its vruntime increases more than a heavier task’s (because it should get less CPU per unit real time). The scheduler always picks the task with the smallest vruntime. Over time, all tasks converge to same vruntime.

Python Simulation

import heapq
import random

class Task:
    def __init__(self, pid, weight, total_work):
        self.pid = pid
        self.weight = weight
        self.total_work = total_work
        self.remaining = total_work
        self.vruntime = 0.0
        self.executed = 0

    def __lt__(self, other):
        return self.vruntime < other.vruntime

def simulate(tasks, quantum=10, interval=1000):
    # tasks: list of Task objects
    heap = [(t.vruntime, t) for t in tasks]
    heapq.heapify(heap)
    total_weight = sum(t.weight for t in tasks)
    time = 0
    while any(t.remaining > 0 for t in tasks):
        # get min vruntime task
        vr, task = heapq.heappop(heap)
        run_time = min(quantum, task.remaining)
        task.remaining -= run_time
        task.executed += run_time
        # update vruntime
        task.vruntime += run_time * (total_weight / task.weight)
        time += run_time
        # reinsert
        if task.remaining > 0:
            heapq.heappush(heap, (task.vruntime, task))
        else:
            # task done, adjust total_weight for future
            total_weight -= task.weight
        if time >= interval:
            break
    return [(t.pid, t.executed) for t in tasks]

Test with three tasks: A weight 1 (20 work), B weight 2 (20 work), C weight 4 (20 work). The simulation yields: A ~10 units, B ~20, C ~40? Wait total work 60, over interval large enough. Let’s run.

t1 = Task(1,1,20)
t2 = Task(2,2,20)
t3 = Task(3,4,20)
print(simulate([t1,t2,t3], quantum=1, interval=100))

Result will show that heavier tasks get more CPU time per real time, but each completes after its proportional share. The key: all tasks finish at roughly the same time if their total work is proportional to weight? No, here equal work (20) with different weights means the lighter task should get less CPU and thus finish later. Actually, with weight 4, task C gets 4 times the CPU share of task A, so it finishes much faster. The scheduler is work‑conserving and fair in time proportion, not in completion time.

Adding Real‑Time Constraints

In a real OS, we also have I/O‑bound tasks that block. They stop consuming CPU, so their vruntime stays low. When they wake up, they appear with a low vruntime and get scheduled quickly. This ensures responsive I/O—the scheduler naturally prioritizes interactive processes.

Handling New Tasks

New tasks should not be penalized for starting later. CFS uses min_vruntime as the initial vruntime for a new task, so it starts in the middle of the pack. If we used 0, it would starve existing tasks. Our simulation can set min_vruntime = min(heap.vruntime for task in heap) and assign that to the new task.

The Need for a Minimum Granularity

Our simulation uses a quantum of 1 unit. In practice, context switching overhead demands a minimum granularity (e.g., 1ms). Even if a task has the smallest vruntime, we run it for at least that much real time. This breaks perfect fairness at micro‑scale but preserves it over longer windows.


5. Deep Dive: Linux Completely Fair Scheduler (CFS)

CFS, introduced in Linux 2.6.23 (2007), replaced the O(1) scheduler and brought fairness to the mainstream. Let’s explore its design in depth.

Core Equation

The vruntime of a task is updated as:

vruntime += delta_exec * NICE_0_LOAD / load
  • delta_exec: actual CPU time used.
  • NICE_0_LOAD: constant (typically 1024), representing weight of a task with nice = 0.
  • load: weight of the task (derived from nice value).

Thus, a nice‑0 task increments vruntime by exactly delta_exec. A nice‑10 task (weight ~505) increments by 1024/505 ≈ 2.03 * delta_exec—it runs slower through vruntime, so it gets less CPU relative to a nice‑0 task. A nice‑(-10) task (weight ~8200) increments by 1024/8200 ≈ 0.125 * delta_exec—it runs fast through vruntime, getting more CPU.

Data Structure: Red‑Black Tree

All runnable tasks are stored in a red‑black tree keyed by vruntime. The leftmost node (smallest key) is the next to run. Insertion and removal O(log N). CFS also caches the leftmost node for O(1) lookup of the next task, but insertion still O(log N).

The tree is per‑scheduling class (CFS is one class; real‑time has different handling). For load balancing, each CPU has its own runqueue with a CFS tree.

Scheduling Latency and Minimum Granularity

CFS defines two key parameters:

  • sched_latency_ns (default 6 ms on many kernels): the target time for one full round‑robin among all tasks. If there are N tasks, each gets a slice of sched_latency_ns / N. But if N is large (e.g., 1000), that slice becomes tiny, causing excessive switching. So CFS also enforces sched_min_granularity_ns (default 0.75 ms). The actual slice per task becomes max(sched_latency_ns / N, sched_min_granularity_ns). This means for many tasks, the latency window extends beyond sched_latency—they get larger slices, but fairness over longer periods is maintained.

Load Balancing Across CPUs

On multi‑core systems, each CPU has its own CFS tree. To ensure global fairness, the scheduler periodically pulls tasks from busy CPUs to idle ones. The metric used is load (not just vruntime). Each task has a load weight (based on nice value). The scheduler tracks runnable load and tries to equalize it across CPUs.

The load balancer runs every ~1–10 ms or on idle‑wake. It uses a sched_domain hierarchy to choose which CPUs to balance. Pulling a task from another CPU requires moving it in the source CPU’s RQ and inserting into the destination’s CFS tree.

Group Scheduling: Cgroups

Since Linux 2.6.24, CFS supports scheduling groups of tasks. A group gets a weight (via cpu.shares). Inside the group, tasks are scheduled using CFS with their own vruntime accounting. This allows containers (Docker, Kubernetes) to allocate CPU among cgroups: “give this container 512 shares, that one 1024.” The global scheduler first picks which group to run (based on group’s vruntime), then picks a task within that group. This is a two‑level hierarchical scheduler.

Practical Example: Troubleshooting a CPU‑Bound Job

Suppose you run stress --cpu 4 on a 2‑core machine. You have 4 CPU‑bound threads. Without CFS, they would steal CPU from interactive processes. With CFS, each thread gets roughly equal time across the two cores. But because there are more threads than cores, each will run at 50% cpu (2 cores / 4 threads). If you then run renice -n 19 on one thread, its weight drops, so it gets far less CPU—maybe 5% instead of 50%. The others pick up the slack.

This demonstrates weighted fairness: the nice value directly translates to a share of CPU.

CFS and Latency‑Sensitive Workloads

Despite CFS’s fairness, it is not real‑time. Preemption is not always immediate; a task might run its full slice before yielding. For low‑latency (e.g., audio), Linux provides SCHED_FIFO and SCHED_RR (static priorities). But those bypass CFS entirely. The trade‑off: deterministic latency vs. fairness.


6. Multi‑Core and Group Scheduling: From Threads to Containers

We’ve discussed single‑core scheduling. In the real world, we have many cores (8, 16, 64). The fairness challenge expands: not only must each thread get its share on one core, but the total CPU allocated across all cores must match the shares.

Load Balancing: The Pulling Game

The Linux scheduler uses a runqueue per CPU. Each CPU runs its own CFS. The load balancer runs periodically (triggered by timer interrupts or idle wake). It compares the load of each CPU and decides to pull tasks from overloaded CPUs.

Migration cost: Moving a task to another CPU incurs cache miss penalties. If the task has been running on CPU0 for a while, its L1/L2 cache is hot. Moving to CPU1 causes a cold start. The scheduler uses a load balancing threshold and tries to avoid excessive migrations. Modern kernels use wake‑up balancing: when a task wakes up, it is placed on the CPU with the least load, but within the same cache locality domain if possible.

Sched_domains: Hierarchical Topology

Modern CPUs have a hierarchy: cores sharing L1, L2, LLC, memory controller. The scheduler is aware of this via sched_domain. It balances at each level: first within an LLC cluster, then across clusters, then across sockets. Migrating a task within the same LLC is cheap; across sockets is expensive.

Group Scheduling in Containers

Containers (Docker) create cgroups. A cgroup is a group of processes with a common weight (cpu.shares). Inside the cgroup, CFS runs normally. The global scheduler also maintains a cgroup‑level vruntime. So the scheduling becomes two‑level: first select which cgroup to run (based on its vruntime), then within that cgroup select a task.

This allows fine‑grained control: e.g., “container A gets 2048 shares, container B gets 1024.” Container A will get twice the CPU of B when both are busy. This is the cornerstone of cloud resource allocation.

Real‑World Case: Netflix and Linux CFS

Netflix builds Chunked Scheduling using cgroups to limit CPU usage of non‑critical tasks. They assign low cpu.shares to background tasks so that even under load, user‑facing services get priority. This is a practical application of share‑based scheduling beyond the kernel internals.


7. The Future of Scheduling: Beyond Shares

While share‑based scheduling is a huge leap over round‑robin, it’s not the end of the story. Next‑generation schedulers must handle:

Energy‑Aware Scheduling

CPUs now have energy‑efficient cores (big.LITTLE). The scheduler should run latency‑sensitive tasks on fast cores and background tasks on slow cores. This is heterogeneous multi‑processing (HMP). Linux has the energy‑aware scheduling (EAS) extension, which uses task utilization and CPU capacity to decide placement. It strives for fairness in performance, not just time.

Deadline‑Based Scheduling: EDF and Beyond

Earliest Deadline First (EDF) is optimal for real‑time systems (theorem: it can meet all deadlines if any schedule can). But it suffers from overruns: if a task exceeds its estimate, the whole schedule breaks. Hybrid schedulers (e.g., Linux’s SCHEDDEADLINE) combine CFS with real‑time guarantees using _reservations: each task gets a guaranteed runtime per period. Unused budget can be reclaimed.

Machine Learning for Scheduling

Imagine a scheduler that learns per‑workload patterns: “this database thread is usually I/O bound; it needs small bursts; run it immediately”. Research experiments (e.g., using reinforcement learning) have shown promise, but ML adds overhead and non‑determinism. However, with hardware assist (e.g., Intel’s RDT, ARM’s MPAM), we may see schedulers that adapt online.

Scheduler‑Friendly Data Structures: Lock‑Free and Scalable

As core counts rise, the per‑CPU runqueue must be lock‑free to avoid contention. Linux uses a mix of spinlocks and per‑CPU variables. But some research proposes lock‑free skiplists for thread scheduling.

The Ultimate Fairness: User‑Defined Policies

Our vision is a scheduler where the user (or system administrator) defines shares not just by nice, but by latency, throughput, or energy goals. The OS then translates these into scheduling parameters. This is the idea behind policy‑driven scheduling.


Conclusion: Freeing the City from the Clock

We began with the mayor and the single road. Round‑robin was the clock‑driven traffic light—simple but blind. We moved to priorities, only to encounter starvation and inversion. Then we discovered the share paradigm: lottery tickets, stride passes, and virtual runtimes. Allowing each thread a proportional share of the CPU breaks the tyranny of fixed time slices. The scheduler becomes a fair boss that understands relative importance.

From the Linux CFS to the humble stride scheduler, the concept of weighted fairness is now baked into the fundamental fabric of modern operating systems. It enables containerized clouds, responsive desktops, and real‑time control.

But the journey isn’t over. Energy, heterogeneity, and machine learning are reshaping the scheduler landscape. The core lesson remains: fairness is not about equal time; it’s about equal opportunity to use that time proportionally to need. As builders of systems, we must choose the right abstraction for fairness—shares, not merely seconds.

So next time you launch a heavy computation on your laptop and your music doesn’t skip, thank the scheduler. It has broken the tyranny of the clock. And if you’re curious enough, you can even build your own fair scheduler, one virtual runtime at a time.


(Total word count: ~11,500 words including the introduction)