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.
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.
1 · What an operating system actually does
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.
2 · Processes vs threads
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 | Thread | |
|---|---|---|
| Memory | private address space | shared with siblings |
| Creation cost | high (new address space) | low |
| Communication | IPC (pipes, sockets, shared mem) | shared variables |
| Crash blast radius | isolated — one dies alone | a bad thread can corrupt the process |
| Scheduled by OS? | yes | yes (kernel threads) |
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
3 · Context switching — the illusion of many at once
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.
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
4 · CPU scheduling — a runnable simulator
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).
| Policy | Rule | Strength | Weakness |
|---|---|---|---|
| FCFS | run in arrival order | simple, no starvation | one long job blocks all (convoy effect) |
| SJF | run shortest burst next | provably minimal average wait | needs burst known; starves long jobs |
| Round-robin | fixed slice, then rotate | fair, responsive | overhead if slice too small |
| Priority | highest priority first | honours importance | low-priority jobs can starve |
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
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
5 · System calls & the user/kernel boundary
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".
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
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 yourselfSJF 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
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
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
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.
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
noware 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.
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.
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.
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.
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.