AI EngineeringZero to ProductionHome·About·What’s new·Contact
System Design · Chapter SD8

Operating systems

The operating system is the layer every service you designed in SD5–SD7 actually runs on. This lesson opens the box: processes vs threads, context switching, how the CPU scheduler decides who runs next, and the user/kernel boundary a system call crosses — with a runnable scheduler simulation you can measure.

⏱️ ~3 hours🧪 5 labs🎯 Beginner→Expert
🌱 Start here — from zeroThe OS, from scratch — no systems background needed. Every program you write runs on top of an operating system: it hands your code a slice of the CPU, its own private memory, and a safe way to touch disk and network. This chapter builds the mental model — process, thread, scheduler, system call — and then simulates the CPU scheduler in plain Python so you can measure why one policy beats another. The sims are models of the algorithms, not a real kernel.

Learning objectives

  • Explain what an OS provides: CPU time, memory isolation, and mediated I/O.
  • Distinguish a process from a thread and say what a context switch costs.
  • Simulate FCFS, SJF, round-robin and priority scheduling and compare their metrics.
  • Trace a system call across the user/kernel boundary and say why the boundary exists.
▶ Runnable companionEvery code block here is plain Python (standard library only) — no OS internals are touched. The scheduler is a discrete-event model: it computes the same waiting and turnaround times a real scheduler would produce for the same workload.

1 · What an operating system actually does

Here is a moment you've almost certainly lived. You have a browser with forty tabs open, a music player, a video call, your editor, and a build running in the terminal — all at once, on a machine with maybe eight CPU cores. That's five, ten, fifty programs all convinced they own the computer, and somehow none of them notices the others. No tab can read your bank password out of another tab; a crashing build doesn't take down your call. Someone is refereeing all of that, invisibly, thousands of times a second. That someone is the operating system, and most engineers go years without ever looking at it directly — until the day a service falls over and the only useful question is "what is the OS actually doing under here?"

The reason this deserves a chapter at the top of a systems track is simple: every architecture you designed in SD5–SD7 is a fiction the OS maintains. "My service has its own memory," "my container is isolated," "requests are handled fairly" — none of those are true at the silicon level. There is one lump of RAM, a handful of cores, and one disk, all shared. The OS spends its entire life lying convincingly to every program so that each one can pretend it's alone. Understand the mechanism behind the lie and suddenly a whole class of production mysteries — why a box thrashes under load, why a "fast" service is slow, why adding threads didn't help — stops being magic.

A useful analogy: think of the OS as the manager of a shared workshop. Many workers (programs) want the same tools (CPU, memory, disk), and left alone they'd fight, grab each other's parts, and injure each other. The manager does three things — hands out time at the workbench in turns, gives each worker a locked cupboard nobody else can open, and stands between the workers and the dangerous machinery so no one loses a finger. That's scheduling, memory isolation, and mediated I/O. The common beginner mistake is to picture the OS as "the desktop" — the windows and icons you click. That's just one more program. The real OS is the referee you never see, and the part that matters for systems work is the tiny privileged core called the kernel.

An operating system is the program that sits between your code and the hardware. Three jobs matter most: it shares the CPU among many programs (scheduling), it gives each program its own private memory (isolation, covered in SD9), and it mediates access to devices — disk, network, screen — so one buggy program cannot corrupt another or crash the machine. The OS is a resource manager and a protection boundary at the same time.

Your app processes OS kernel scheduler + drivers Hardware CPU / RAM / disk
🗺️ How to read this diagram

Read it left-to-right, following the arrows: each box asks the box to its right for anything it cannot do itself.

  • The left box is your app — ordinary user-space processes. It has no direct access to the machine.
  • The middle box is the OS kernel — the scheduler and device drivers. The arrow from your app into it is every privileged request the app must route through the kernel.
  • The right box is the raw hardware (CPU, RAM, disk); the second arrow shows the kernel is the only thing that actually touches it.

In short: Your app sits on the kernel, the kernel sits on the hardware — nothing skips a layer.

Kernel vs the restThe kernel is the trusted core that runs with full hardware privilege — the scheduler, memory manager and device drivers live there. Everything else (your app, the shell, even most of the "OS") runs as ordinary user-space programs that must ask the kernel for anything privileged.

2 · Processes vs threads

This is the distinction that sits behind more confused engineering conversations than almost any other. Someone says "just add more threads and it'll go faster," someone else says "no, use separate processes," and both are half-right and half-talking-past-each-other because they never pinned down what actually differs between the two. Get this clear once and a surprising amount downstream — the Python GIL, why a worker pool is shaped the way it is, why one crash kills everything and another doesn't — falls into place.

Try this picture. A process is a house: it has its own walls, its own address, and its own furniture, and the people inside cannot wander into the neighbour's house. A thread is a person living inside one house. You can have several people (threads) in the same house, and they share everything — the same fridge, the same rooms, the same mess. Two houses (processes) share nothing unless they deliberately mail things to each other. That single image predicts every row in the table below: housemates (threads) coordinate instantly because they share the fridge, but if one leaves the stove on the whole house burns (a bad thread corrupts the whole process). Separate houses (processes) are safe from each other but slow to build and awkward to pass things between.

Why the sharing is the entire story. Threads are attractive precisely because they share memory: two threads can hand data back and forth just by writing to the same variable — no copying, no serialization, near-instant. But that same shared memory is a loaded gun. If two threads touch the same variable at the same time, you get a race condition: a bug that depends on the exact microsecond timing of the scheduler, appears once in ten thousand runs, and is nearly impossible to reproduce. That's the demon you'll spend all of SD9 learning to cage. Processes dodge it entirely — separate memory means no shared state to corrupt — but you pay for that safety with heavier creation and the need for explicit inter-process communication (pipes, sockets, shared-memory segments).

The mistake to avoid: reaching for threads "for speed" without asking what your work is actually waiting on. If your program is stuck waiting on the network or disk (I/O-bound), threads help a lot — while one waits, another runs. If it's crunching numbers with no waiting (CPU-bound), threads in a language with a global lock like Python's GIL won't speed it up at all, and you want separate processes instead. Naming what your bottleneck is comes before choosing the tool. With that framing, here is the crisp definition and a side-by-side comparison.

A process is a running program with its own private address space — its own view of memory. A thread is a single flow of execution inside a process; a process can have many threads that share the same memory. That sharing is the whole trade-off: threads are cheap to create and communicate through shared memory, but that shared memory is exactly what causes the race conditions you will fight in SD9.

Process A — one private address space shared memory thread 1 thread 2 x = 5 (both see it) Process B — separate address space its own memory thread 1 private x IPC only no shared variables across the wall — must copy via pipe/socket Sharing is the whole trade-off. Threads inside one process share the same memory (instant hand-off, but one bad thread corrupts all); two processes share nothing and must talk over IPC (safe, but slower and explicit).
🗺️ How to read this diagram

Read it as two boxes: each big box is one process (a private address space); the small boxes inside are threads.

  • The left process holds two threads that both point at the same amber cell — one shared variable x they can each read and write directly. That is why threads communicate for free.
  • The right process is a separate box with its own memory; its x is a different green cell entirely.
  • The dashed grey arrow across the gap is the only way the two processes can exchange data — IPC (a pipe, socket, or shared-memory segment). There is no shared variable across that wall.

In short: Threads share memory (cheap + risky); processes are walled off (safe + slower). Every row in the table below follows from which box you're inside.

ProcessThread
Memoryprivate address spaceshared with siblings
Creation costhigh (new address space)low
CommunicationIPC (pipes, sockets, shared mem)shared variables
Crash blast radiusisolated — one dies alonea bad thread can corrupt the process
Scheduled by OS?yesyes (kernel threads)
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 · A process is a table of state (model)
process.py# MODEL: a process is really just a bundle of state the OS tracks in a
# "process control block" (PCB). We model that bundle as a dataclass.
from dataclasses import dataclass, field

@dataclass
class Process:
    pid: int
    arrival: int          # tick it becomes ready
    burst: int            # CPU ticks it needs
    priority: int = 0     # lower = more important (convention here)
    remaining: int = field(init=False)
    def __post_init__(self):
        self.remaining = self.burst

jobs = [Process(1, 0, 5), Process(2, 1, 3), Process(3, 2, 8)]
for j in jobs:
    print(f"pid={j.pid} arrival={j.arrival} burst={j.burst} remaining={j.remaining}")
pid=1 arrival=0 burst=5 remaining=5
pid=2 arrival=1 burst=3 remaining=3
pid=3 arrival=2 burst=8 remaining=8
What the OS remembers per processA real PCB holds far more — the saved CPU registers, the program counter, memory maps, open file handles, and scheduling state. Our model keeps only the fields the scheduler needs: when the job arrives, how much CPU it wants, and how much is left.

3 · Context switching — the illusion of many at once

Start with the fact that should bother you. Your laptop has, say, eight cores, yet you routinely run hundreds of processes — check your activity monitor and you'll see three hundred plus. Eight cores. Three hundred programs. All apparently running "at the same time." They are not. At any given instant a core is executing exactly one thread, and the sensation of everything running at once is a magic trick performed so fast your eyes can't catch it. The trick has a name and a price, and both matter enormously the day you're trying to figure out why a busy server is spending its CPU on nothing.

The trick is like a single teacher helping thirty students, one at a time, but rotating so fast that every student feels continuously attended to. To switch from student A to student B, the teacher has to do bookkeeping: note exactly where A had got to (which page, which line), then pick up B's notes and remember where B had left off. For a CPU, "where you left off" is the contents of its registers and its program counter — the tiny scratchpad of what-am-I-doing-right-now. A context switch is the OS saving all of that for the outgoing thread and restoring it for the incoming one. It happens millions of times an hour, and each one is a few microseconds of pure overhead — work that produces no actual progress on anyone's program.

Here's the part that surprises people and where the real cost hides. The visible save-and-restore of registers is cheap. The expensive, invisible cost is the cache. A running thread warms up the CPU's fast local caches with its data; when you switch to a different thread, those caches are now full of the wrong thread's data, and the new thread limps along on cache misses until it warms the cache back up. So the true cost of a switch is not just the hand-off — it's the cold-start penalty that lingers afterward. This is why "just switch more often to feel snappier" is a trap: past a point you spend more time switching and re-warming than doing work. In extreme cases a system does almost nothing but switch — a pathology called thrashing, where CPU sits near 100% while real throughput collapses.

The common mistake is to treat context switches as free and reason only about "work to be done." Real capacity planning counts the switches too. The lab below models exactly this — total time as useful work plus a fixed cost per switch — so you can feel why a scheduler that preempts too eagerly can lose to one that lets jobs run a little longer. Keep that overhead in mind: it's the hidden tax that the round-robin quantum in the next section is really tuning.

A single CPU core runs exactly one thread at a time. The OS creates the illusion of many programs running at once by switching between them extremely fast. A context switch is that hand-off: the kernel saves the current thread's registers and program counter, then loads another thread's saved state. It is not free — the switch itself burns CPU cycles and cold caches, so a scheduler that switches too often wastes real work.

Save regs Pick next Load regs Resume
🗺️ How to read this diagram

Read it left-to-right along the arrows: this is the four-step sequence the kernel runs on every context switch.

  • The first box, "Save regs," is the kernel snapshotting the outgoing thread's registers and program counter — remembering exactly where it left off.
  • The middle boxes, "Pick next" then "Load regs," are the scheduler choosing which thread runs next and restoring its saved registers.
  • The last box, "Resume," hands the CPU to that thread — the arrows chain the steps in strict order, so every switch pays for all four.

In short: A context switch is save → pick → load → resume, and every one of those steps is pure overhead that does no real work.

Step 2 · Counting switch overhead (model)
switch_cost.py# MODEL: each context switch costs a fixed number of "overhead" ticks.
# Fewer switches -> less overhead, but (see round-robin) worse responsiveness.
def total_time(work_ticks, switches, switch_cost=1):
    return work_ticks + switches * switch_cost

print("2 switches:", total_time(100, 2))    # 102
print("50 switches:", total_time(100, 50))  # 150
2 switches: 102
50 switches: 150
Why time-slicing is a compromiseA tiny time slice makes the machine feel responsive (every program gets the CPU often) but multiplies context-switch overhead. A huge slice minimises overhead but a short interactive job can wait behind a long batch job. Round-robin (below) is the knob that trades these off.

4 · CPU scheduling — a runnable simulator

Think about the last time you were stuck in a supermarket queue behind someone with a full trolley while you held a single sandwich. The rage you felt — "why am I waiting ten minutes to buy one thing?" — is the exact problem a CPU scheduler exists to manage, and different checkout policies produce wildly different amounts of that rage. Express lanes, "next customer please" rotation, letting the pregnant woman go first: those are scheduling algorithms, and a supermarket that picks the wrong one has furious customers even though the total amount of scanning is identical. The CPU faces the same choice thousands of times a second, and which policy it picks decides whether your machine feels instant or sluggish under the same load.

The scheduler is where operating systems stop being descriptive and become genuinely interesting, because there is no single right answer — only trade-offs you choose deliberately. Serve strictly in arrival order and it's dead fair but one big job stalls everyone behind it (the supermarket's full-trolley problem, called the convoy effect). Always serve the shortest job and average waiting drops to its mathematical minimum — but a long job might never get served if short ones keep arriving (starvation). Rotate everyone through small time slices and it feels responsive — but you pay the context-switch tax from the last section on every rotation. Each policy optimizes a different thing and quietly sacrifices another; the engineer's job is to know which sacrifice their workload can afford.

What makes this section special is that you don't have to take any of that on faith. You will simulate all four policies in plain Python and read the numbers off yourself. The two numbers that matter are waiting time (how long a job sat ready but ignored — this is the customer's felt rage) and turnaround time (arrival to finish — the total trip). A batch system that runs overnight cares about turnaround; an interactive system where a human is drumming their fingers cares about waiting time and responsiveness. Same workload, different metric, different "best" policy — which is why the labs below make you measure rather than memorize.

The mistake to sidestep: believing there's a universally optimal scheduler you can just look up. There isn't, and interviewers love to catch people asserting one. SJF being "provably optimal for average wait" is true and also nearly useless in practice, because it assumes you know each job's length in advance — you almost never do. The real lesson isn't the four algorithms; it's the habit of naming what you're optimizing for, then choosing the policy that buys it at an acceptable cost. With that mindset, here are the four classics and the metrics we'll hold them to.

The scheduler decides which ready thread runs next. Four classic policies: FCFS (first-come-first-served — a plain queue), SJF (shortest-job-first — run the smallest burst next), round-robin (each job gets a fixed time slice, then goes to the back of the queue), and priority (run the most important first). We measure each with two numbers: waiting time (ticks spent ready but not running) and turnaround time (finish − arrival).

same ready queue — three jobs: A(burst 7) · B(burst 4) · C(burst 1) A · 7 B · 4 C · 1 FCFS A B C arrival order SJF C B A shortest first RR q=2 A B C A B rotate slices priority B A C most important first same jobs, four orderings → four different average waits The policy is just the ordering rule. One identical ready queue, four schedulers, four different run orders — and therefore four different average waiting times, even though the total work is the same.
🗺️ How to read this diagram

Read it top-to-bottom: the same three jobs (top row) are re-ordered by each policy on the four rows below — left-to-right is the order they get the CPU.

  • The top blue row is the ready queue: jobs A, B, C with their CPU burst lengths (7, 4, 1).
  • FCFS (amber) keeps arrival order A→B→C; the long A goes first and everyone waits behind it — the convoy effect.
  • SJF (green) reorders to C→B→A, running the shortest burst first; that is what drives average wait to its minimum.
  • RR (blue-purple) chops each job into fixed slices and rotates, so you see A, B, C, then A and B again — responsive, but more hand-offs.
  • Priority (red) ignores both arrival and length and runs the most-important job (here B) first.

In short: A scheduler is just a rule for ordering the same queue — and the labs below let you read off how much that ordering changes the average wait.

PolicyRuleStrengthWeakness
FCFSrun in arrival ordersimple, no starvationone long job blocks all (convoy effect)
SJFrun shortest burst nextprovably minimal average waitneeds burst known; starves long jobs
Round-robinfixed slice, then rotatefair, responsiveoverhead if slice too small
Priorityhighest priority firsthonours importancelow-priority jobs can starve
Step 3 · Non-preemptive schedulers: FCFS & SJF (model)
scheduler_nonpre.py# MODEL of two non-preemptive schedulers. Each returns per-job (wait, turnaround).
# Non-preemptive = once a job starts it runs to completion.
from dataclasses import dataclass

@dataclass
class Job:
    pid: int; arrival: int; burst: int

def run_nonpreemptive(jobs, pick_next):
    """pick_next(ready, now) -> the Job to run next."""
    jobs = sorted(jobs, key=lambda j: j.arrival)
    now, done, metrics = 0, [], {}
    pending = list(jobs)
    while pending:
        ready = [j for j in pending if j.arrival <= now]
        if not ready:                      # CPU idle until next arrival
            now = min(j.arrival for j in pending); continue
        j = pick_next(ready, now)
        start = now
        now += j.burst                     # runs to completion
        turnaround = now - j.arrival
        wait = start - j.arrival
        metrics[j.pid] = (wait, turnaround)
        pending.remove(j)
    return metrics

def report(name, m):
    avg_w = sum(w for w, _ in m.values()) / len(m)
    avg_t = sum(t for _, t in m.values()) / len(m)
    print(f"{name:5s} avg_wait={avg_w:.2f} avg_turnaround={avg_t:.2f}")

jobs = [Job(1, 0, 7), Job(2, 2, 4), Job(3, 4, 1), Job(4, 5, 4)]
fcfs = run_nonpreemptive(jobs, lambda ready, now: min(ready, key=lambda j: j.arrival))
sjf  = run_nonpreemptive(jobs, lambda ready, now: min(ready, key=lambda j: j.burst))
report("FCFS", fcfs)
report("SJF", sjf)
FCFS  avg_wait=4.75 avg_turnaround=8.75
SJF   avg_wait=4.00 avg_turnaround=8.00
SJF wins on average wait — and that is a theoremFor a fixed set of jobs, shortest-job-first gives the minimum possible average waiting time of any non-preemptive schedule. The catch is real: you must know each burst in advance, and a stream of short jobs can starve a long one forever.
Step 4 · Preemptive round-robin (model)
round_robin.py# MODEL: round-robin gives each job a fixed time "quantum", then rotates.
# Preemptive = a running job can be interrupted before it finishes.
from collections import deque
from dataclasses import dataclass

@dataclass
class Job:
    pid: int; arrival: int; burst: int

def round_robin(jobs, quantum):
    jobs = sorted(jobs, key=lambda j: j.arrival)
    remaining = {j.pid: j.burst for j in jobs}
    finish = {}
    now, i, q = 0, 0, deque()
    def admit(t):                    # move arrivals up to time t into the queue
        nonlocal i
        while i < len(jobs) and jobs[i].arrival <= t:
            q.append(jobs[i].pid); i += 1
    admit(now)
    while q:
        pid = q.popleft()
        slice_ = min(quantum, remaining[pid])
        now += slice_
        remaining[pid] -= slice_
        admit(now)                   # new arrivals queue ahead of the re-added job
        if remaining[pid] > 0:
            q.append(pid)            # not done -> back of the line
        else:
            finish[pid] = now
        if not q:                    # CPU would idle: pull in a future arrival
            admit(now)
            if not q and i < len(jobs):
                now = jobs[i].arrival; admit(now)
    by_pid = {j.pid: j for j in jobs}
    metrics = {}
    for pid, f in finish.items():
        j = by_pid[pid]
        turnaround = f - j.arrival
        metrics[pid] = (turnaround - j.burst, turnaround)  # (wait, turnaround)
    return metrics

jobs = [Job(1, 0, 7), Job(2, 2, 4), Job(3, 4, 1), Job(4, 5, 4)]
for qn in (2, 4):
    m = round_robin(jobs, qn)
    avg_w = sum(w for w, _ in m.values()) / len(m)
    print(f"RR q={qn} avg_wait={avg_w:.2f}")
RR q=2 avg_wait=5.00
RR q=4 avg_wait=4.50
Reading the quantum knobA smaller quantum (q=2) rotates more often — more responsive, but more switching and, here, a higher average wait for this workload. As the quantum grows past the largest burst, round-robin degenerates into FCFS. There is no universally best quantum; it is a workload-dependent tuning decision.

5 · System calls & the user/kernel boundary

Here's a question that stumps people who have written code for years: when your Python program runs open("data.txt"), what actually opens the file? Not your program. Your program is not allowed to touch the disk — it has no idea where the disk even is, and it would be a catastrophe if it did. Instead it politely asks the kernel to do it. That asking has a mechanism, a cost, and a hardware-enforced wall behind it, and the day you profile a slow service and discover it's spending 80% of its time crossing that wall, you'll be glad you know it's there.

The analogy that makes it click: think of the kernel as a bank teller behind bulletproof glass. You (user code) cannot walk into the vault and grab cash — obviously. You slide a request through a slot ("withdraw $50"), the teller checks you're allowed, does the privileged thing in the secure area, and slides the result back. A system call is that slot. Your program fills out a request, the CPU flips from user mode (restricted — can't touch hardware) into kernel mode (full privilege), the kernel validates and performs the operation, then flips you back to user mode with the answer. Every file read, every network packet, every new process you spawn goes through that slot. There is no other way in.

Why build a wall at all, instead of just letting programs do what they want? Because "trust every program with the hardware" is how you get a single buggy app that overwrites another app's memory, corrupts the disk, or wedges the whole machine. The user/kernel boundary is the hardware saying "prove you're allowed before I let you near the dangerous parts." It's the same isolation instinct as the process boundaries from section 2, but enforced one level deeper — in the CPU itself, which literally refuses to execute privileged instructions while in user mode. That refusal is what keeps one crashing tab from taking down your entire laptop.

Now the practical payoff, because this is where the boundary bites in real systems. Crossing it is not free — every system call pays for the mode switch and the associated overhead, on the order of a microsecond or more. That sounds tiny until you do it a million times. The classic beginner mistake is writing a loop that does one tiny write per record: a hundred thousand records become a hundred thousand boundary crossings, and the crossings, not the actual writing, dominate your runtime. The fix — batching, one big write instead of many small ones — is why buffered writers, bulk database inserts, and vectorized APIs exist everywhere in production code. The model below makes this concrete, and it's the intuition behind Exercise 6. Here is the precise definition and the trap that trips into the kernel.

Your program cannot touch the disk or network directly — it would be a security and stability disaster. Instead it makes a system call: a controlled request that traps into the kernel, switches the CPU from user mode (restricted) to kernel mode (full privilege), runs the privileged operation, and returns. open, read, write, fork and socket are all system calls. The mode switch is the hardware-enforced line between "your code" and "trusted code".

User code Trap Kernel runs op Return
🗺️ How to read this diagram

Read it left-to-right along the arrows: this traces one system call crossing into the kernel and back out.

  • The left box, "User code," is your restricted program — it cannot touch hardware, so it must ask.
  • The "Trap" box is the controlled entry point where the CPU flips from user mode to kernel mode; the "Kernel runs op" box is the privileged work being done safely on the other side of the wall.
  • The "Return" box flips the CPU back to user mode and hands the answer back — the arrows show it is a one-way-in, one-way-out round trip, not a shortcut into the hardware.

In short: A syscall is user code → trap → kernel does the privileged op → return, and each crossing of that wall costs a mode switch.

Step 5 · A syscall gate (model)
syscall_gate.py# MODEL: a syscall is a guarded entry point. In user mode you may NOT run a
# privileged op directly; you must request it and let the "kernel" validate + run it.
class Kernel:
    def __init__(self):
        self.mode = "user"
        self._files = {}
    def syscall(self, name, *args):
        self.mode = "kernel"            # trap: switch to kernel mode
        try:
            handler = getattr(self, f"_sys_{name}", None)
            if handler is None:
                return (-1, "ENOSYS")   # no such system call
            return handler(*args)
        finally:
            self.mode = "user"          # return: back to user mode
    def _sys_write(self, path, data):
        self._files.setdefault(path, "")
        self._files[path] += data
        return (0, len(data))
    def _sys_read(self, path):
        return (0, self._files.get(path, ""))

k = Kernel()
print(k.syscall("write", "/tmp/log", "hello"))   # (0, 5)
print(k.syscall("read", "/tmp/log"))             # (0, "hello")
print(k.syscall("launch_missiles"))              # (-1, "ENOSYS")
print("mode after return:", k.mode)              # back in user mode
(0, 5)
(0, 'hello')
(-1, 'ENOSYS')
mode after return: user
Why the boundary is worth its costEvery syscall pays for a mode switch, so batching I/O (one big write beats a thousand tiny ones) is a real optimisation. But the boundary is what lets the OS enforce permissions and keep a crashing program from taking down the machine — the same isolation principle behind the stateless app servers in SD5.

✓ Checkpoint — you can move on when you can…

  • Name the three core jobs of an OS and where the kernel/user line falls.
  • State two concrete differences between a process and a thread.
  • Run the scheduler sim and explain why SJF beat FCFS on average wait.
  • Trace a write() system call across the user/kernel boundary.

Knowledge check

check yourself
✓ Knowledge check

SJF gives the minimum possible average waiting time for a fixed set of jobs, yet real operating systems rarely use pure SJF. Give the two reasons the lesson names.

Show answer
First, SJF needs each job's CPU burst known in advance, which is generally not available. Second, a steady stream of short jobs can starve a long job — it may never get the CPU. Real schedulers approximate SJF (e.g. estimating bursts) and add aging to prevent starvation.
✓ Knowledge check

A system call switches the CPU from user mode to kernel mode and back. Why does this boundary exist at all, and what practical performance advice follows from the fact that crossing it is not free?

Show answer
The boundary exists for protection and isolation: user code cannot be trusted to touch hardware directly, so privileged operations run only in the kernel where permissions are enforced and a crash is contained. Because each crossing costs a mode switch, you should batch I/O — one large read/write instead of many tiny ones.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Implement FCFS by handBeginner

Context: First-come-first-served is the plainest scheduler and the baseline every other policy is compared against.

Your task: Given a list of (pid, arrival, burst) jobs, compute each job's completion, turnaround and waiting time under FCFS and print the averages.

Requirements:

  • Sort jobs by arrival time
  • Track a running clock; if the CPU would idle, jump it to the next arrival
  • turnaround = completion − arrival; wait = turnaround − burst
  • Print per-job metrics and the two averages

💡 Hint: The clock only moves forward: now = max(now, arrival) + burst per job.

Show solution

FCFS is just "run them in arrival order," jumping the clock forward when the CPU would otherwise idle:

def fcfs(jobs):  # jobs: list of (pid, arrival, burst)
    jobs = sorted(jobs, key=lambda j: j[1])
    now, metrics = 0, {}
    for pid, arrival, burst in jobs:
        start = max(now, arrival)      # idle-jump if CPU is waiting
        completion = start + burst
        turnaround = completion - arrival
        wait = turnaround - burst
        metrics[pid] = (wait, turnaround)
        now = completion
    return metrics

m = fcfs([(1, 0, 7), (2, 2, 4), (3, 4, 1), (4, 5, 4)])
for pid, (w, t) in m.items():
    print(f"pid={pid} wait={w} turnaround={t}")
print("avg wait:", sum(w for w, _ in m.values()) / len(m))

The clock only ever moves forward, so start = max(now, arrival) is the whole scheduling decision. This is the baseline SJF and RR are measured against.

Exercise 2 · Add SJF and compareIntermediate

Context: Shortest-job-first is provably optimal for average wait; comparing it to FCFS on the same workload makes the convoy effect visible.

Your task: Extend your scheduler so it can pick the shortest ready burst instead of the earliest arrival, and print FCFS vs SJF average waiting time side by side.

Requirements:

  • Reuse one driver; pass in a pick_next(ready, now) selector
  • FCFS selector picks min arrival; SJF selector picks min burst
  • Only jobs that have arrived by now are eligible
  • Report average waiting time for both policies on identical jobs

💡 Hint: The only difference between the two policies is the key function you pass in.

Show solution

One driver, two selectors — the policy is the key function:

def run(jobs, pick_next):
    pending = sorted(jobs, key=lambda j: j[1])  # (pid, arrival, burst)
    now, metrics = 0, {}
    pending = list(pending)
    while pending:
        ready = [j for j in pending if j[1] <= now]
        if not ready:
            now = min(j[1] for j in pending); continue
        pid, arrival, burst = pick_next(ready, now)
        wait = now - arrival
        now += burst
        metrics[pid] = (wait, now - arrival)
        pending.remove((pid, arrival, burst))
    return metrics

def avg_wait(m): return sum(w for w, _ in m.values()) / len(m)

jobs = [(1, 0, 7), (2, 2, 4), (3, 4, 1), (4, 5, 4)]
fcfs = run(jobs, lambda r, now: min(r, key=lambda j: j[1]))
sjf  = run(jobs, lambda r, now: min(r, key=lambda j: j[2]))
print(f"FCFS avg wait {avg_wait(fcfs):.2f}")
print(f"SJF  avg wait {avg_wait(sjf):.2f}")

SJF beats FCFS here (4.00 vs 4.75) because it refuses to let the 7-tick job block the three shorter ones — that blocking is the convoy effect.

Exercise 3 · Preemptive round-robin with a quantumAdvanced

Context: Round-robin is what makes an interactive machine feel responsive; the quantum is the knob that trades responsiveness against switch overhead.

Your task: Implement round-robin with a configurable quantum and show how average waiting time changes as the quantum grows from small toward the largest burst.

Requirements:

  • Use a ready queue (deque); a running job that isn't done goes to the back
  • Admit newly-arrived jobs before re-queueing the preempted one
  • Sweep the quantum over several values and print average wait for each
  • Note the limit: quantum ≥ max burst degenerates to FCFS

💡 Hint: Track remaining[pid]; subtract min(quantum, remaining) each turn.

Show solution

Round-robin with a swept quantum:

from collections import deque

def round_robin(jobs, quantum):        # (pid, arrival, burst)
    jobs = sorted(jobs, key=lambda j: j[1])
    remaining = {p: b for p, a, b in jobs}
    arrival = {p: a for p, a, b in jobs}
    burst = {p: b for p, a, b in jobs}
    now, i, q, finish = 0, 0, deque(), {}
    def admit(t):
        nonlocal i
        while i < len(jobs) and jobs[i][1] <= t:
            q.append(jobs[i][0]); i += 1
    admit(now)
    while q:
        pid = q.popleft()
        s = min(quantum, remaining[pid]); now += s; remaining[pid] -= s
        admit(now)
        if remaining[pid] > 0: q.append(pid)
        else: finish[pid] = now
        if not q and i < len(jobs):
            now = jobs[i][1]; admit(now)
    return {p: ((finish[p] - arrival[p]) - burst[p], finish[p] - arrival[p]) for p in finish}

jobs = [(1, 0, 7), (2, 2, 4), (3, 4, 1), (4, 5, 4)]
for q in (1, 2, 4, 8):
    m = round_robin(jobs, q)
    print(f"q={q}: avg wait {sum(w for w,_ in m.values())/len(m):.2f}")

As the quantum climbs past the largest burst (8) the schedule stops preempting at all and becomes identical to FCFS — the quantum has no single best value, it trades responsiveness against switch overhead.

Exercise 4 · Priority scheduling with agingExpert

Context: Pure priority scheduling starves low-priority jobs; production schedulers fix this with aging — slowly boosting a waiting job's priority.

Your task: Implement non-preemptive priority scheduling, then add aging so a job that has waited a long time cannot be starved indefinitely, and demonstrate the difference.

Requirements:

  • Lower number = higher priority; pick the best ready priority each step
  • Without aging, show a low-priority job's wait growing unbounded under load
  • With aging, raise a job's effective priority by 1 for every k ticks it waits
  • Show the aged job eventually runs where the un-aged one starved

💡 Hint: Effective priority = base − (ticks_waited // k); recompute it when you pick.

Show solution

Priority with optional aging:

def priority_sched(jobs, k=None):      # (pid, arrival, burst, base_priority)
    pending = sorted(jobs, key=lambda j: j[1])
    pending = list(pending)
    now, metrics, entered = 0, {}, {}
    while pending:
        ready = [j for j in pending if j[1] <= now]
        if not ready:
            now = min(j[1] for j in pending); continue
        for j in ready: entered.setdefault(j[0], j[1])
        def eff(j):
            base = j[3]
            if k:                       # aging: waiting lowers the number
                base -= (now - entered[j[0]]) // k
            return base
        j = min(ready, key=eff)         # lowest effective priority number wins
        pid, arrival, burst, _ = j
        metrics[pid] = (now - arrival, now - arrival + burst)
        now += burst
        pending.remove(j)
    return metrics

# job 1 is low priority (base 4); a steady stream of priority-1 jobs keeps arriving,
# so without aging job 1 is bumped again and again.
jobs = [(1, 0, 2, 4), (2, 0, 2, 1), (3, 2, 2, 1),
        (4, 4, 2, 1), (5, 6, 2, 1), (6, 8, 2, 1)]
print("no aging, job 1:", priority_sched(jobs)[1])       # (wait, turnaround)
print("aging k=1, job 1:", priority_sched(jobs, k=1)[1])

Prints no aging, job 1: (10, 12) then aging k=1, job 1: (4, 6). Without aging the low-priority job waits behind every priority-1 arrival — a wait of 10. Aging subtracts waited // k from its number, so its effective priority climbs until it beats the newcomers and it runs far sooner — that is exactly how real schedulers prevent starvation.

Exercise 5 · A metrics harness across all four policiesProfessional

Context: Choosing a scheduler is an empirical decision: you run the candidate policies on a representative workload and compare aggregate metrics.

Your task: Build a harness that runs FCFS, SJF, round-robin and priority on the same generated workload and prints a comparison table of average wait, average turnaround and (for RR) context-switch count.

Requirements:

  • Generate a reproducible workload (seed the RNG)
  • Run all four policies on the identical job list
  • Report avg wait, avg turnaround, and switches where meaningful
  • Draw a one-line conclusion about which policy suits interactive vs batch load

💡 Hint: Keep each scheduler pure (jobs in → metrics out) so the harness just loops over them.

Show solution

A harness that runs every policy on one seeded workload and tabulates the result:

import random
from collections import deque

def gen(n, seed=7):
    r = random.Random(seed)
    return [(i + 1, r.randint(0, 10), r.randint(1, 9)) for i in range(n)]

def run_np(jobs, pick):
    pend, now, m = list(sorted(jobs, key=lambda j: j[1])), 0, {}
    while pend:
        ready = [j for j in pend if j[1] <= now]
        if not ready: now = min(j[1] for j in pend); continue
        pid, a, b = pick(ready); m[pid] = (now - a, now - a + b); now += b; pend.remove((pid, a, b))
    return m

def run_rr(jobs, q):
    jobs = sorted(jobs, key=lambda j: j[1]); rem = {p: b for p, a, b in jobs}
    arr = {p: a for p, a, b in jobs}; bur = {p: b for p, a, b in jobs}
    now, i, dq, fin, sw = 0, 0, deque(), {}, 0
    def admit(t):
        nonlocal i
        while i < len(jobs) and jobs[i][1] <= t: dq.append(jobs[i][0]); i += 1
    admit(0)
    while dq:
        pid = dq.popleft(); sw += 1
        s = min(q, rem[pid]); now += s; rem[pid] -= s; admit(now)
        if rem[pid] > 0: dq.append(pid)
        else: fin[pid] = now
        if not dq and i < len(jobs): now = jobs[i][1]; admit(now)
    m = {p: ((fin[p] - arr[p]) - bur[p], fin[p] - arr[p]) for p in fin}
    return m, sw

def avg(m, idx): return sum(v[idx] for v in m.values()) / len(m)

W = gen(8)
fcfs = run_np(W, lambda r: min(r, key=lambda j: j[1]))
sjf  = run_np(W, lambda r: min(r, key=lambda j: j[2]))
rr, sw = run_rr(W, 3)
print(f"{'policy':6s}{'avg_wait':>10s}{'avg_turn':>10s}{'switches':>10s}")
print(f"{'FCFS':6s}{avg(fcfs,0):10.2f}{avg(fcfs,1):10.2f}{'-':>10s}")
print(f"{'SJF':6s}{avg(sjf,0):10.2f}{avg(sjf,1):10.2f}{'-':>10s}")
print(f"{'RR(3)':6s}{avg(rr,0):10.2f}{avg(rr,1):10.2f}{sw:10d}")

The numbers depend on the seed, but the shape is stable: SJF minimises average wait (batch-friendly), round-robin trades a little average wait for responsiveness and incurs the switch count (interactive-friendly). That empirical comparison is how schedulers are actually chosen.

Exercise 6 · Model a syscall-batching winIndustry scenario

Context: A service doing one syscall per record spends most of its time crossing the user/kernel boundary; batching is a classic real-world latency fix.

Your task: Model the cost of writing N records as (syscall overhead + per-byte cost) and show that batching M records per write beats one-record-per-write, then find the batch size that minimises modelled total time.

Requirements:

  • Model total = num_syscalls × switch_cost + total_bytes × byte_cost
  • Compare unbatched (N syscalls) vs batched (N/M syscalls)
  • Sweep batch size M and report the modelled total for each
  • Label the result as a MODEL — no real I/O is performed
  • State the intuition: batching amortises the fixed mode-switch cost

💡 Hint: The win comes entirely from cutting the number of boundary crossings; the byte cost is unchanged.

Show solution

A cost model (no real I/O) of writing 100k records at various batch sizes:

def model_time(n_records, batch, bytes_per_record=200,
               switch_cost_ns=1500, byte_cost_ns=2):
    """MODEL: total nanoseconds to write n_records, batch records per syscall."""
    import math
    syscalls = math.ceil(n_records / batch)
    total_bytes = n_records * bytes_per_record
    return syscalls * switch_cost_ns + total_bytes * byte_cost_ns

N = 100_000
for m in (1, 10, 100, 1000, 10_000):
    t = model_time(N, m)
    print(f"batch={m:>6}: modelled {t/1e6:.2f} ms")

The per-byte cost is identical across runs; the only thing batching changes is the number of boundary crossings, so total time falls sharply as the batch grows and then flattens once the fixed syscall cost is fully amortised. This is why buffered writers and bulk APIs exist. These are modelled constants, not measured numbers on your machine.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in