Process Scheduling Algorithms in Depth

Process Scheduling Algorithms in Depth

Process scheduling determines which process runs on the CPU at any given moment. The scheduler’s decisions directly impact system throughput, responsiveness, and fairness. Different workloads demand different scheduling strategies — a web server, a real-time control system, and a batch compute cluster each need a scheduler tuned to their priorities.

First-Come, First-Served (FCFS)

FCFS is the simplest scheduling algorithm: processes are queued in arrival order, and each runs to completion before the next starts. It is implemented with a FIFO queue and is the default in many batch systems. The main advantage is low overhead — no context switching between processes until one finishes. The critical weakness is the convoy effect: a single long-running process blocks all subsequent short processes, dramatically increasing average waiting time.

def fcfs(processes):
    """
    processes: list of (pid, arrival_time, burst_time)
    """
    time = 0
    results = []
    for pid, at, bt in sorted(processes, key=lambda p: p[1]):
        if time < at:
            time = at
        start = time
        time += bt
        results.append({
            'pid': pid, 'start': start, 'finish': time,
            'turnaround': time - at, 'wait': start - at
        })
    return results

procs = [(1, 0, 24), (2, 0, 3), (3, 0, 3)]  # convoy example
res = fcfs(procs)
for r in res:
    print(f"P{r['pid']}: wait={r['wait']}, turnaround={r['turnaround']}")
# Output: P1: wait=0, turnaround=24
#         P2: wait=24, turnaround=27
#         P3: wait=27, turnaround=30
# Average wait: 17 — terrible for short processes behind a long one

Shortest Job First (SJF)

SJF schedules the process with the smallest burst time next. It is provably optimal for minimizing average waiting time (shortest average turnaround time). The catch is that burst times are unknown in advance — the scheduler must predict them using exponential averaging of past behavior. In practice, SJF can cause starvation: long-running processes may never execute if short jobs keep arriving. Preemptive SJF (Shortest Remaining Time First) switches to a newly arrived shorter job, further improving average wait but increasing overhead.

def sjf_nonpreemptive(processes):
    """
    Non-preemptive Shortest Job First.
    """
    processes = sorted(processes, key=lambda p: (p[1], p[2]))  # by arrival, then burst
    n = len(processes)
    time = 0
    completed = []
    remaining = processes[:]

    while remaining:
        # Pick shortest burst among arrived processes
        available = [p for p in remaining if p[1] <= time]
        if not available:
            time = min(p[1] for p in remaining)
            continue
        chosen = min(available, key=lambda p: p[2])
        remaining.remove(chosen)
        pid, at, bt = chosen
        start = time
        time += bt
        completed.append({
            'pid': pid, 'start': start, 'finish': time,
            'turnaround': time - at, 'wait': start - at
        })
    return completed

# SJF prediction using exponential averaging
def predict_burst(history, alpha=0.5):
    tau = history[0] if history else 5.0  # initial guess
    for actual in history:
        tau = alpha * actual + (1 - alpha) * tau
    return tau

# Example: process with varying CPU bursts
burst_history = [8, 6, 10, 4, 7]
predicted = [predict_burst(burst_history[:i]) for i in range(1, len(burst_history)+1)]
print("Predicted bursts:", [f"{p:.1f}" for p in predicted])
# Output: Predicted bursts: ['8.0', '7.0', '8.5', '6.2', '6.6']

Round-Robin (RR)

Round-Robin is FCFS with preemption: each process runs for a fixed time quantum (typically 10–100 ms), then is moved to the back of the ready queue. RR provides fair CPU sharing and fast response time for interactive processes. The choice of quantum is critical — too small and context-switch overhead dominates; too large and the scheduler degrades toward FCFS. O(1) context switch cost means quantum should be at least 10× the switch time (usually under 1 µs) to keep overhead below 10%.

def round_robin(processes, quantum=4):
    """
    processes: list of (pid, burst_time) — assumes all arrive at time 0.
    """
    queue = [(pid, burst) for pid, burst in processes]
    time = 0
    results = {pid: {'pid': pid, 'burst': bt, 'start': None, 'finish': 0}
               for pid, bt in processes}

    while queue:
        pid, remaining = queue.pop(0)
        if results[pid]['start'] is None:
            results[pid]['start'] = time

        if remaining > quantum:
            time += quantum
            queue.append((pid, remaining - quantum))
        else:
            time += remaining
            results[pid]['finish'] = time

    for r in sorted(results.values(), key=lambda x: x['pid']):
        print(f"P{r['pid']}: start={r['start']}, finish={r['finish']}, "
              f"turnaround={r['finish']}")
    avg_turnaround = sum(r['finish'] for r in results.values()) / len(results)
    print(f"Average turnaround: {avg_turnaround:.1f}")

round_robin([(1, 24), (2, 3), (3, 3)], quantum=4)
# Output: P1: start=0, finish=30, turnaround=30
#         P2: start=4, finish=7, turnaround=7
#         P3: start=7, finish=10, turnaround=10
# Average turnaround: 15.7 — much better than FCFS (27) for short processes

Multi-Level Feedback Queue (MLFQ)

MLFQ is the most practical general-purpose scheduler, used in BSD Unix and as the basis for the Linux scheduler before CFS. It maintains multiple queues with different priority levels. A process starts at the highest-priority queue and moves down after using its full quantum, while it moves up if it voluntarily yields the CPU (e.g., waiting for I/O). This gives interactive processes (short bursts, I/O-bound) high priority and batch processes (CPU-bound) low priority, achieving both responsiveness and throughput without prior knowledge of burst times.

class MLFQ:
    """
    Multi-Level Feedback Queue with 3 levels and priority boost.
    """
    def __init__(self, quanta=[4, 8, 16], boost_interval=50):
        self.queues = [[] for _ in range(len(quanta))]
        self.quanta = quanta
        self.boost_interval = boost_interval
        self.time = 0
        self.processes = {}  # pid -> {'burst': remaining, 'level': 0, 'yielded': True/False}

    def add_process(self, pid, burst_time):
        self.processes[pid] = {'burst': burst_time, 'level': 0}
        self.queues[0].append(pid)

    def tick(self):
        self.time += 1
        # Priority boost to prevent starvation
        if self.time % self.boost_interval == 0:
            for level in range(1, len(self.queues)):
                while self.queues[level]:
                    pid = self.queues[level].pop(0)
                    self.queues[0].append(pid)
                    self.processes[pid]['level'] = 0

        # Pick first non-empty queue (highest priority)
        for level, q in enumerate(self.queues):
            if q:
                pid = q.pop(0)
                proc = self.processes[pid]
                quantum = self.quanta[level]
                run_for = min(quantum, proc['burst'])
                proc['burst'] -= run_for
                self.time += run_for - 1  # tick accounts for 1

                if proc['burst'] <= 0:
                    print(f"  P{pid} finished at t={self.time}")
                else:
                    # Demote if used full quantum
                    new_level = min(level + 1, len(self.queues) - 1)
                    proc['level'] = new_level
                    self.queues[new_level].append(pid)
                return

# Demo: mix of CPU-bound and I/O-bound processes
mlfq = MLFQ()
mlfq.add_process(1, 30)  # CPU-bound
mlfq.add_process(2, 5)   # I/O-bound (short)
mlfq.add_process(3, 15)  # mixed

for _ in range(55):
    mlfq.tick()
# Output will show short jobs completing quickly at high priority,
# while CPU-bound job gets demoted to lower queues

Linux Completely Fair Scheduler (CFS)

Introduced in Linux 2.6.23, CFS replaced the O(1) scheduler with a radically different approach. Instead of fixed time slices, CFS models the CPU as a resource that should be perfectly shared among runnable processes. It maintains a red-black tree keyed by virtual runtime (vruntime) — the amount of time a process has run, normalized by its priority (nice value). The scheduler always picks the process with the smallest vruntime (leftmost node in the tree). CFS achieves fairness, O(log n) scheduling decisions, and excellent interactivity without multiple queues.

# Simplified CFS in Python
import heapq  # using heap instead of red-black for clarity

class CFS:
    def __init__(self, nice_values=None):
        self.runqueue = []  # min-heap of (vruntime, pid)
        self.vruntimes = {}
        self.nice = nice_values or {}
        self.weight = {}
        self._init_weights()

    def _init_weights(self):
        # Approximate CFS weight table (nice 0 = 1024)
        for nice in range(-20, 20):
            self.weight[nice] = int(1024 / (1.25 ** nice))

    def add_process(self, pid, nice=0):
        self.vruntimes[pid] = 0
        self.nice[pid] = nice
        heapq.heappush(self.runqueue, (0, pid))

    def schedule(self, delta=1):
        """Run the process with smallest vruntime for 'delta' time."""
        if not self.runqueue:
            return None

        vruntime, pid = heapq.heappop(self.runqueue)
        weight = self.weight.get(self.nice[pid], 1024)
        # vruntime advancement: actual_time * (1024 / weight)
        vruntime_advance = delta * (1024 / weight)
        self.vruntimes[pid] += vruntime_advance

        heapq.heappush(self.runqueue, (self.vruntimes[pid], pid))
        return pid

# Demo
cfs = CFS()
cfs.add_process(1, nice=0)   # default priority
cfs.add_process(2, nice=5)   # lower priority (gets less CPU)
cfs.add_process(3, nice=-5)  # higher priority (gets more CPU)

for _ in range(20):
    ran = cfs.schedule()

for pid in [1, 2, 3]:
    print(f"P{pid} (nice={cfs.nice[pid]}): vruntime={cfs.vruntimes[pid]:.2f}")
# Output demonstrates that despite equal run() calls,
# higher-priority (lower nice) processes accumulate vruntime more slowly,
# giving them more CPU time

Real-Time Scheduling

For hard real-time systems, schedulers must guarantee deadlines. Rate-Monotonic Scheduling (RMS) assigns static priorities inversely proportional to period — tasks with shorter periods get higher priority. Earliest Deadline First (EDF) dynamically prioritizes the task with the nearest deadline. RMS is optimal among fixed-priority schedulers; EDF can achieve 100% utilization theoretically but suffers from overload unpredictability. Linux supports both SCHED_FIFO and SCHED_RR (POSIX real-time policies) via the PREEMPT_RT patch set.

# Earliest Deadline First simulation
def edf(tasks, runtime=100):
    """
    tasks: [(pid, period, execution_time, deadline)]
    Returns schedule with deadline misses tracked.
    """
    schedule = []
    misses = 0
    time = 0
    ready = []

    while time < runtime:
        # Add newly released jobs
        for pid, period, exec_time, deadline in tasks:
            if time % period == 0:
                ready.append({
                    'pid': pid, 'remaining': exec_time,
                    'deadline': time + deadline
                })

        # Sort by earliest deadline
        ready.sort(key=lambda j: j['deadline'])

        if not ready:
            schedule.append(('idle', time, time + 1))
            time += 1
            continue

        job = ready.pop(0)
        schedule.append((job['pid'], time, time + 1))
        job['remaining'] -= 1

        if job['remaining'] > 0:
            ready.append(job)
        elif time + 1 > job['deadline']:
            misses += 1

        time += 1

    return schedule, misses

tasks = [(1, 10, 3, 10), (2, 15, 5, 15), (3, 20, 4, 20)]
sched, misses = edf(tasks, runtime=60)
print(f"Deadline misses: {misses}")
# Output: Deadline misses: 0 (feasible schedule)

Choosing the right scheduler depends on your workload: FCFS for batch throughput, SJF when burst times are predictable, RR for interactive fairness, MLFQ for general-purpose multitasking, CFS for modern Linux systems, and EDF for hard real-time constraints. Most modern operating systems combine multiple approaches — Linux's CFS handles normal processes while SCHED_FIFO/SCHED_RR supports real-time tasks, all running under the same kernel.

Leave a Reply

Your email address will not be published. Required fields are marked *