P12 hands-on — Operating system kernel, block by block
Frames, page tables, Bélády's anomaly, scheduling, and a race you can watch.
Source:
handson/h12_kernel.py--- run it withpython3 handson/h12_kernel.py
Full project spec: P12 — Operating-System Kernel
You cannot boot a kernel inside a Python process, but every mechanism a kernel depends on can be built and measured in one --- and the mechanisms are the part that transfers. This file builds eight of them and ends with the observation that a process control block is simply one field per mechanism.
Two results are worth arriving for. Bélády's anomaly is demonstrated rather than described: FIFO with four frames faults more often than with three, on the classic 1969 reference string, which is why "add more cache" is a hypothesis rather than a fix. And the assembly's page-fault sweep contradicts its own prediction --- there is no working-set cliff, because a mixture of reference distributions does not have one, and that turns out to be the more useful lesson.
Contents
- Block 1 — Physical frame allocator
- Block 2 — Virtual memory
- Block 3 — Page replacement and Belady's anomaly
- Block 4 — Context switch
- Block 5 — Scheduling policy
- Block 6 — System calls
- Block 7 — A race, and a lock
- Block 8 — Putting a process together
- The assembly
- The design space
- Latency: the numbers that shape every policy
- Memory: what the assembly actually shows
- Concurrency: beyond the lock
- How this connects to the rest of the track
- Failure modes at scale
- Primary sources
- Running it
- What to do with this
How to read this page
Each block below is a self-contained lego piece: it builds one mechanism, proves it works on its own, and returns what the next block needs. The code is the real source, sliced out of the script. The output underneath it is the real output, captured by running that script --- not transcribed, not idealised. Where a measurement contradicted what I expected, the contradiction is in the output and the prose says so.
The assembly at the end wires every block into one working thing and measures it.
Block 1 — Physical frame allocator
Teaches: the first allocation problem, with no allocator to help
The problem. The first allocation problem, solved before any allocator exists. Everything the kernel does early — page tables, process structures, the heap itself — needs physical frames, and there is nothing to allocate from.
@block(1, "Physical frame allocator", "the first allocation problem, with no allocator to help")
def b1(s, show):
class Frames:
def __init__(self, n): self.bm = bytearray(n); self.n = n; self.hint = 0
def alloc(self):
for k in range(self.n):
i = (self.hint + k) % self.n
if not self.bm[i]:
self.bm[i] = 1; self.hint = i + 1; return i
raise MemoryError("out of physical memory")
def free(self, i):
if not self.bm[i]: raise ValueError(f"double free of frame {i}")
self.bm[i] = 0
def used(self): return sum(self.bm)
if show:
f = Frames(1024)
got = [f.alloc() for _ in range(600)]
for i in got[::3]: f.free(i)
print(f" 1024 frames; allocated 600, freed 200 -> {f.used()} in use")
print(f" next allocation reuses a hole: frame {f.alloc()}")
try:
f.free(got[1]); f.free(got[1])
except ValueError as e:
print(f" double free is caught: {e}")
print(" A bitmap costs 1 bit per 4 KiB page: 32 KiB of metadata per GiB, or")
print(" 0.003%. The rotating hint turns the scan from O(n) per alloc into")
print(" O(1) amortised. This runs BEFORE any heap exists, so it cannot")
print(" allocate -- every structure the kernel needs early is a fixed array.")
return {"Frames": Frames}
Reading the implementation
A bitmap: one bit per 4 KiB frame. The metadata cost is 32 KiB per GiB, or 0.003% — which is why this is the standard bootstrap allocator despite the scan.
The rotating hint turns an \(O(n)\) linear scan into \(O(1)\) amortised for
the common case of sequential allocation, at the cost of worse locality when the
bitmap is fragmented. That is the same next-fit-versus-first-fit trade every
allocator makes.
Double-free detection is one line (if not self.bm[i]: raise), and it is
worth having because a double free in a physical allocator hands the same frame to
two owners — the resulting corruption appears arbitrarily far from its cause, in
another process's memory.
The structural constraint worth naming: this code runs before a heap exists,
so it cannot allocate. Every early kernel structure is a fixed-size static array
for that reason, and it is why kernels have compile-time limits (NPROC,
NOFILE) that look archaic and are not.
What the numbers say
Output:
1024 frames; allocated 600, freed 200 -> 400 in use
next allocation reuses a hole: frame 600
double free is caught: double free of frame 1
A bitmap costs 1 bit per 4 KiB page: 32 KiB of metadata per GiB, or
0.003%. The rotating hint turns the scan from O(n) per alloc into
O(1) amortised. This runs BEFORE any heap exists, so it cannot
allocate -- every structure the kernel needs early is a fixed array.
Beyond the toy
- The buddy allocator (Linux's page allocator) maintains free lists for power-of-two block sizes, so allocating \(2^k\) contiguous pages is \(O(\log n)\) and coalescing on free is a bit-flip on the buddy address. Contiguity matters because DMA and huge pages need it.
- Slab/SLUB sits above the page allocator for small kernel objects. It caches constructed objects of one type per cache, which gives near-zero allocation cost, no internal fragmentation, and — importantly — cache-line colouring so objects of the same type do not all map to the same cache set.
- Per-CPU caches avoid the lock entirely on the fast path, which at 100+ cores is the difference between a scalable allocator and a bottleneck. The general pattern — per-CPU free lists with periodic rebalancing — recurs in every scalable allocator, including userspace ones like tcmalloc and jemalloc.
- Memory fragmentation is the failure mode: plenty of free frames, none contiguous, so a huge-page allocation fails. Linux's compaction daemon exists for this and it is the same problem as P04's compaction, one level down.
Block 2 — Virtual memory
Teaches: one indirection, and the process model falls out of it
The problem. One indirection, and the entire process model falls out of it. Two processes can use the same virtual address, and neither can touch the other's memory — not because anything checks, but because the mapping does not exist.
@block(2, "Virtual memory", "one indirection, and the process model falls out of it")
def b2(s, show):
PAGE = 4096
class MMU:
def __init__(self, frames): self.f = frames; self.tables = {}
def map(self, pid, vpn, writable=True):
t = self.tables.setdefault(pid, {})
if vpn in t: return t[vpn][0]
fr = self.f.alloc(); t[vpn] = (fr, writable); return fr
def translate(self, pid, va, write=False):
vpn, off = va // PAGE, va % PAGE
e = self.tables.get(pid, {}).get(vpn)
if e is None: raise MemoryError(f"SEGFAULT pid={pid} va=0x{va:x}")
fr, w = e
if write and not w: raise PermissionError(f"write to RO page va=0x{va:x}")
return fr * PAGE + off
if show:
f = s["Frames"](256); m = MMU(f)
m.map(1, 0); m.map(1, 1); m.map(2, 0)
pa1 = m.translate(1, 0x0100); pa2 = m.translate(2, 0x0100)
print(f" pid 1 va 0x0100 -> pa 0x{pa1:x}")
print(f" pid 2 va 0x0100 -> pa 0x{pa2:x} (same VA, different frame)")
print(f" isolation is structural, not checked: {pa1 != pa2}")
for lbl, fn in (("unmapped read", lambda: m.translate(1, 0x9000)),
("write to RO", lambda: (m.map(1, 3, writable=False),
m.translate(1, 3*PAGE, write=True)))):
try: fn()
except Exception as e: print(f" {lbl:<16}-> {type(e).__name__}: {e}")
print(" Two processes at the same virtual address is the ENTIRE reason a")
print(" bug in one cannot corrupt the other. Everything else -- COW, mmap,")
print(" shared libraries, demand paging -- is a variation on who gets to")
print(" point at which frame.")
return {"MMU": MMU, "PAGE": PAGE}
Reading the implementation
translate(pid, va) splits the address into a page number and an offset, looks up
the page number in that process's table, and returns frame * PAGE + offset. The
offset passes through untouched, which is why page size must be a power of two —
the split is a shift and a mask, not a division.
Isolation is structural, not checked. Process 2 cannot reach process 1's frame because there is no entry in its table that maps there. There is no comparison, no permission test, no bounds check on the fast path — the absence of a mapping is the protection. That is why virtual memory is cheap enough to be mandatory.
The permission bit gives the second half: a read-only mapping is what makes
copy-on-write possible (map the parent's frames RO into the child, copy on first
write), which is what makes fork() cheap, which is what makes the Unix process
model viable.
What the numbers say
Output:
pid 1 va 0x0100 -> pa 0x100
pid 2 va 0x0100 -> pa 0x2100 (same VA, different frame)
isolation is structural, not checked: True
unmapped read -> MemoryError: SEGFAULT pid=1 va=0x9000
write to RO -> PermissionError: write to RO page va=0x3000
Two processes at the same virtual address is the ENTIRE reason a
bug in one cannot corrupt the other. Everything else -- COW, mmap,
shared libraries, demand paging -- is a variation on who gets to
point at which frame.
Beyond the toy
- Real page tables are radix trees, four levels on x86-64 (five with LA57). A miss costs a page walk — up to four dependent memory accesses, each potentially a cache miss, so 100+ ns. That is why the TLB exists and why its reach matters.
- TLB reach is the number nobody checks: 1536 entries × 4 KiB = 6 MB. A process with a 10 GB working set misses on essentially every new page. Huge pages (2 MiB) extend reach 512× to ~3 GB, and are the single highest-leverage tuning knob for large-heap workloads — the same mechanism P02 needs for a 100 GB index.
- ASIDs / PCIDs tag TLB entries with an address-space id so a context switch does not have to flush the whole TLB. Without them, every switch costs a full TLB refill — which is a large part of why the measured context-switch cost in block 4 understates the real one.
- Everything else is a variation on who points at which frame: shared
libraries (same frame, many tables),
mmap(file pages mapped lazily), COW (shared until written), and paged attention in P01 (KV blocks mapped into a sequence's logical view).
Block 3 — Page replacement and Belady's anomaly
Teaches: more memory, more faults
The problem. When memory is full, something must be evicted. This block demonstrates the 1969 result that says buying more memory can make things worse — which is why "add cache" is a hypothesis rather than a fix.
@block(3, "Page replacement and Belady's anomaly", "more memory, more faults")
def b3(s, show):
def fifo(refs, n):
mem, q, faults = set(), deque(), 0
for r in refs:
if r in mem: continue
faults += 1
if len(mem) == n: mem.discard(q.popleft())
mem.add(r); q.append(r)
return faults
def lru(refs, n):
mem, faults = OrderedDict(), 0
for r in refs:
if r in mem: mem.move_to_end(r); continue
faults += 1
if len(mem) == n: mem.popitem(last=False)
mem[r] = 1
return faults
def clock(refs, n):
buf, ref, hand, faults = [], [], 0, 0
for r in refs:
if r in buf: ref[buf.index(r)] = 1; continue
faults += 1
if len(buf) < n: buf.append(r); ref.append(1); continue
while ref[hand]: ref[hand] = 0; hand = (hand + 1) % n
buf[hand] = r; ref[hand] = 1; hand = (hand + 1) % n
return faults
def opt(refs, n):
mem, faults = [], 0
for i, r in enumerate(refs):
if r in mem: continue
faults += 1
if len(mem) == n:
fut = [(refs[i+1:].index(x) if x in refs[i+1:] else 1 << 30) for x in mem]
mem.pop(fut.index(max(fut)))
mem.append(r)
return faults
BEL = [1,2,3,4,1,2,5,1,2,3,4,5]
if show:
print(f" reference string {BEL}")
print(f" {'frames':>8}{'FIFO':>7}{'LRU':>6}{'CLOCK':>7}{'OPT':>6}")
for n in (3, 4):
print(f" {n:>8}{fifo(BEL,n):>7}{lru(BEL,n):>6}{clock(BEL,n):>7}{opt(BEL,n):>6}")
print(f" FIFO with 4 frames faults MORE than with 3 "
f"({fifo(BEL,4)} vs {fifo(BEL,3)}) -- Belady's anomaly, 1969.")
print(" LRU cannot do this: it is a stack algorithm, so the pages resident")
print(" with n frames are always a subset of those with n+1. FIFO has no")
print(" such property, so buying RAM can lose performance. This is not a")
print(" curiosity -- it is why 'add cache' is a hypothesis, not a fix.\\n")
rng = random.Random(4)
refs = [rng.choice(range(20)) if rng.random() < .3 else rng.choice(range(4))
for _ in range(4000)]
print(f" 4000 refs, 80/20 locality: {'frames':>8}{'FIFO':>7}{'LRU':>6}"
f"{'CLOCK':>7}{'OPT':>6}")
for n in (4, 8, 16):
print(f" {'':<26}{n:>8}{fifo(refs,n):>7}{lru(refs,n):>6}"
f"{clock(refs,n):>7}{opt(refs,n):>6}")
print(" CLOCK tracks LRU within a few percent at a fraction of the cost --")
print(" one reference bit per page instead of a timestamp and a list splice")
print(" on every hit. Every real kernel ships an approximation, not LRU.")
return {"fifo": fifo, "lru": lru, "clock": clock, "opt": opt}
Reading the implementation
Four policies on the classic reference string:
- FIFO — evict the oldest. Simple, and exhibits the anomaly.
- LRU — evict least recently used. Cannot exhibit the anomaly.
- CLOCK — a circular scan with one reference bit per page. Approximates LRU at a fraction of the cost.
- OPT — evict whatever is used furthest in the future. Unimplementable; the upper bound against which everything else is measured.
Bélády's anomaly: FIFO with 4 frames faults more often than with 3. LRU cannot do this because it is a stack algorithm — the set of pages resident with \(n\) frames is always a subset of the set resident with \(n+1\). FIFO has no such property, so adding memory can change the eviction order in a way that loses.
CLOCK deserves its ubiquity. LRU requires updating a timestamp or splicing a list node on every hit, which on a hot page is a write to shared state — a scalability disaster at many cores. CLOCK sets one bit on a hit and does its work only on eviction. Every real kernel and database ships an approximation for this reason, not for accuracy.
What the numbers say
Output:
reference string [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]
frames FIFO LRU CLOCK OPT
3 9 10 9 7
4 10 8 10 6
FIFO with 4 frames faults MORE than with 3 (10 vs 9) -- Belady's anomaly, 1969.
LRU cannot do this: it is a stack algorithm, so the pages resident
with n frames are always a subset of those with n+1. FIFO has no
such property, so buying RAM can lose performance. This is not a
curiosity -- it is why 'add cache' is a hypothesis, not a fix.\n
4000 refs, 80/20 locality: frames FIFO LRU CLOCK OPT
4 2063 1875 2011 1153
8 1159 821 953 496
16 350 258 272 106
CLOCK tracks LRU within a few percent at a fraction of the cost --
one reference bit per page instead of a timestamp and a list splice
on every hit. Every real kernel ships an approximation, not LRU.
Beyond the toy
- Scan resistance is the property CLOCK and LRU both lack: one sequential scan of a large file evicts the entire working set. ARC, 2Q and LIRS all address this by tracking recency and frequency separately, and PostgreSQL's clock-sweep and Linux's active/inactive lists are practical approximations.
- The miss curve is the real artefact. Mattson's stack algorithm gives all cache sizes' hit rates in one pass, and modern approximations (SHARDS, counter stacks) make it cheap enough to run continuously. That curve is what tells you whether more RAM will help before you buy it.
- OPT is not useless. It bounds how much a better heuristic could possibly
buy, and in the assembly it beats LRU by 1.92× — which says almost all the
remaining win is in knowing the future, i.e. prefetching and hinting
(
madvise,fadvise), not in a smarter eviction rule.
Block 4 — Context switch
Teaches: saving state is easy; the cache is what costs
The problem. Context switching is what makes a single CPU look like many. This block prices the direct cost — and then explains why that price is the smaller half.
@block(4, "Context switch", "saving state is easy; the cache is what costs")
def b4(s, show):
class Task:
def __init__(self, tid, work): self.tid, self.work = tid, work; self.done = 0
def step(self, q):
n = min(q, self.work - self.done); self.done += n; return n
def finished(self): return self.done >= self.work
def run(tasks, quantum, switch_cost=0.0):
t, log, sw = 0.0, [], 0
q = deque(tasks)
while q:
task = q.popleft()
did = task.step(quantum)
t += did
if task.finished(): log.append((task.tid, t))
else:
t += switch_cost; sw += 1; q.append(task)
return t, log, sw
if show:
mk = lambda: [Task(i, w) for i, w in enumerate([50, 10, 80, 5, 30])]
print(f" five tasks, work = [50, 10, 80, 5, 30] microseconds")
print(f" {'quantum':>9}{'switches':>10}{'makespan':>11}{'mean turnaround':>18}"
f"{'overhead':>10}")
for q in (200, 50, 10, 2):
tot, log, sw = run(mk(), q, 1.7)
mt = sum(x for _, x in log) / len(log)
print(f" {q:>9}{sw:>10}{tot:>10.1f}us{mt:>17.1f}us"
f"{(sw*1.7)/tot:>9.1%}")
print(" The 1.7 us switch cost is not invented -- it is this machine's")
print(" measured context-switch time from numbers.md, which came out in the")
print(" 1,383-1,706 ns range. At a 2 us quantum the kernel spends 45% of the")
print(" CPU switching between tasks. Responsiveness and throughput are the")
print(" same dial, and the dial has a floor set by hardware.")
print(" What this model does NOT capture: the cold cache after a switch. The")
print(" register save is ~50 instructions; the L1 and TLB refill afterwards")
print(" can be thousands of cycles, and it does not show up in any counter")
print(" named 'context switch'.")
return {"run_rr": run, "Task": Task}
Reading the implementation
Round-robin with a quantum, charging a fixed switch cost per preemption. The 1.7 µs figure is not invented: it is this machine's measured context-switch time from numbers.md, which came out in the 1,383--1,706 ns range.
The table shows the trade directly: at a 2 µs quantum the kernel spends ~45% of the CPU switching. Responsiveness and throughput are the same dial, and the dial has a floor set by hardware.
What this model does not capture is the larger cost. The register save is ~50 instructions. The cache and TLB refill afterwards can be thousands of cycles, and it appears in no counter named "context switch". A process resumed after another has evicted its working set restarts cold, and the cost scales with working-set size — which is why measuring switch cost with an empty working set (the standard microbenchmark) gives a number that is real and misleading.
What the numbers say
Output:
five tasks, work = [50, 10, 80, 5, 30] microseconds
quantum switches makespan mean turnaround overhead
200 0 175.0us 114.0us 0.0%
50 1 176.7us 110.0us 1.0%
10 13 197.1us 105.6us 11.2%
2 83 316.1us 177.4us 44.6%
The 1.7 us switch cost is not invented -- it is this machine's
measured context-switch time from numbers.md, which came out in the
1,383-1,706 ns range. At a 2 us quantum the kernel spends 45% of the
CPU switching between tasks. Responsiveness and throughput are the
same dial, and the dial has a floor set by hardware.
What this model does NOT capture: the cold cache after a switch. The
register save is ~50 instructions; the L1 and TLB refill afterwards
can be thousands of cycles, and it does not show up in any counter
named 'context switch'.
Beyond the toy
- Direct costs: register save/restore, page-table base switch (
CR3), and — since Meltdown — KPTI's separate kernel page tables, which added a TLB flush to every syscall and cost 5--30% on syscall-heavy workloads. - Indirect costs: L1/L2 pollution, TLB refill, branch-predictor state loss. Measurable by varying the working set and watching the switch cost climb, which is E6 on the project page.
- Which is why user-level threading exists. Goroutines, async/await, and
fibers switch in tens of nanoseconds because they never enter the kernel and
never change address space. The trade is that a blocking syscall blocks the
whole carrier thread, which is why every such runtime needs non-blocking I/O
underneath — and why
io_uringmatters so much to them.
Block 5 — Scheduling policy
Teaches: the same work, five orders, five different fairness stories
The problem. The same work, five orders, five different fairness stories. There is no scheduler that wins every column, and this block measures precisely what each one gives up.
@block(5, "Scheduling policy", "the same work, five orders, five different fairness stories")
def b5(s, show):
JOBS = [("A", 0, 50), ("B", 0, 10), ("C", 5, 80), ("D", 10, 5), ("E", 20, 30)]
def simulate(policy, quantum=10, switch=1.7):
rem = {n: w for n, a, w in JOBS}; arr = {n: a for n, a, w in JOBS}
t, done, ready, pending = 0.0, {}, [], sorted(JOBS, key=lambda j: j[1])
first = {}
while pending or ready:
while pending and pending[0][1] <= t: ready.append(pending.pop(0)[0])
if not ready:
t = pending[0][1]; continue
if policy == "fcfs": n = ready[0]; q = 1e9
elif policy == "sjf": n = min(ready, key=lambda x: rem[x]); q = 1e9
elif policy == "srtf": n = min(ready, key=lambda x: rem[x]); q = quantum
else: n = ready[0]; q = quantum
first.setdefault(n, t - arr[n])
run = min(q, rem[n]); rem[n] -= run; t += run
if rem[n] <= 0:
done[n] = t - arr[n]; ready.remove(n)
else:
ready.remove(n); ready.append(n); t += switch
return done, first
if show:
print(" jobs (name, arrival, work): " + ", ".join(f"{n}@{a}:{w}" for n,a,w in JOBS))
print(f" {'policy':<22}{'mean turnaround':>17}{'mean response':>15}"
f"{'max turnaround':>16}")
for pol, lbl in (("fcfs", "FCFS"), ("sjf", "SJF (non-preempt)"),
("srtf", "SRTF (preemptive)"), ("rr", "round robin q=10")):
d, f = simulate(pol)
print(f" {lbl:<22}{sum(d.values())/len(d):>16.1f}{sum(f.values())/len(f):>15.1f}"
f"{max(d.values()):>16.1f}")
print(" SJF minimises mean turnaround -- provably, and it is the only")
print(" policy here that can starve a long job forever. Round robin has the")
print(" best response time and the worst turnaround. There is no scheduler")
print(" that wins every column; there is only a choice of which column the")
print(" workload cares about, which is why Linux ships several.")
return {}
Reading the implementation
Four policies over the same job set. The three metrics are deliberately different questions:
- Turnaround = completion − arrival. What a batch job cares about.
- Response = first-run − arrival. What an interactive user feels.
- Max turnaround = the starvation check.
SJF provably minimises mean turnaround — an exchange argument: swapping any adjacent out-of-order pair reduces the total. It is also the only policy here that can starve a long job forever, and it requires knowing job length in advance, which no real system does.
Round robin has the best response and the worst turnaround, because every job is slowed by every other. The quantum is the knob: smaller means better response and more switch overhead (block 4).
What the numbers say
Output:
jobs (name, arrival, work): A@0:50, B@0:10, C@5:80, D@10:5, E@20:30
policy mean turnaround mean response max turnaround
FCFS 107.0 72.0 155.0
SJF (non-preempt) 65.0 30.0 170.0
SRTF (preemptive) 70.5 24.4 192.1
round robin q=10 100.9 21.1 192.1
SJF minimises mean turnaround -- provably, and it is the only
policy here that can starve a long job forever. Round robin has the
best response time and the worst turnaround. There is no scheduler
that wins every column; there is only a choice of which column the
workload cares about, which is why Linux ships several.
Beyond the toy
- MLFQ infers job type from behaviour rather than requiring it up front: a job that yields before its quantum expires is interactive and stays high priority; a job that burns its quantum drops a level. Periodic priority boosts prevent starvation, and the whole scheme approximates SJF without an oracle. It is also gameable — a job that yields just before its quantum expires keeps top priority, which is a real exploit.
- CFS replaced heuristics with an invariant: track each task's virtual runtime, always run the least-progressed, keep them in a red-black tree. Fairness becomes a data-structure property rather than a bag of rules.
- EEVDF (Linux 6.6+) adds latency as a first-class request — eligible virtual deadline first — giving latency-sensitive tasks bounded response without the priority inversions that nice values caused.
- Priority inversion is the classic failure: a low-priority thread holds a lock a high-priority thread needs, and a medium-priority thread preempts the holder. Priority inheritance fixes it, and the Mars Pathfinder resets are the canonical incident.
Block 6 — System calls
Teaches: a controlled doorway, not a function call
The problem. A syscall is not a function call. Two properties make it different, and both are about not trusting the caller.
@block(6, "System calls", "a controlled doorway, not a function call")
def b6(s, show):
class Kernel:
def __init__(self): self.files = {}; self.trap_count = 0
def syscall(self, num, *args):
self.trap_count += 1
table = {0: self.sys_open, 1: self.sys_write, 2: self.sys_read}
fn = table.get(num)
if fn is None: return -38 # -ENOSYS
try: return fn(*args)
except Exception: return -22 # -EINVAL, never a crash
def sys_open(self, path): self.files.setdefault(path, b""); return hash(path) % 900 + 3
def sys_write(self, path, data):
if not isinstance(data, (bytes, bytearray)): raise TypeError
self.files[path] += data; return len(data)
def sys_read(self, path): return self.files[path]
if show:
k = Kernel()
fd = k.syscall(0, "/tmp/x")
print(f" open -> fd {fd}")
print(f" write -> {k.syscall(1, '/tmp/x', b'hello')} bytes")
print(f" read -> {k.syscall(2, '/tmp/x')!r}")
print(f" bad syscall number -> {k.syscall(99)} (-ENOSYS)")
print(f" bad argument type -> {k.syscall(1, '/tmp/x', 12345)} (-EINVAL)")
print(f" traps taken: {k.trap_count}, kernel crashes: 0")
print(" Two properties make this a syscall rather than a call: arguments are")
print(" VALIDATED (a user pointer may be garbage or hostile), and errors come")
print(" back as values (the kernel cannot unwind into a process). This")
print(" machine's measured syscall cost is 127.59 ns -- roughly 1000")
print(" arithmetic instructions -- which is why batching interfaces like")
print(" io_uring and readv exist at all.")
return {}
Reading the implementation
- Arguments are validated. A user pointer may be null, unmapped, owned by
another process, or point at kernel memory. The kernel must check every one
(
copy_from_user, not a dereference), and it must do so without a time-of-check-to-time-of-use window — because a second thread can remap the page between the check and the use. - Errors are values, never exceptions. The kernel cannot unwind into a
process. Negative errno returns are the entire error protocol, which is why
-ENOSYSand-EINVALappear as return values here.
The dispatch table indexed by syscall number is exactly how the real thing works,
and the numbers are a permanent ABI: syscall 1 is write on x86-64 Linux
forever, because binaries compiled a decade ago still call it.
What the numbers say
Output:
open -> fd 811
write -> 5 bytes
read -> b'hello'
bad syscall number -> -38 (-ENOSYS)
bad argument type -> -22 (-EINVAL)
traps taken: 5, kernel crashes: 0
Two properties make this a syscall rather than a call: arguments are
VALIDATED (a user pointer may be garbage or hostile), and errors come
back as values (the kernel cannot unwind into a process). This
machine's measured syscall cost is 127.59 ns -- roughly 1000
arithmetic instructions -- which is why batching interfaces like
io_uring and readv exist at all.
Beyond the toy
This machine's measured syscall cost is 127.59 ns — roughly 1,000 arithmetic instructions (numbers.md). That single ratio explains an architectural generation:
- Batching interfaces:
readv/writev,sendmmsg,epoll(one call reports many ready descriptors). io_uring: shared submission and completion ring buffers in mmap'd memory, so a thread can issue thousands of I/Os with zero syscalls. It is the syscall cost taken seriously.- vDSO:
gettimeofdayand friends are mapped into userspace as ordinary function calls, because a clock read is far too frequent to pay 128 ns for. - Kernel bypass (DPDK, RDMA, SPDK) removes the kernel from the data path entirely for the highest-throughput cases.
And the counter-pressure: Spectre/Meltdown mitigations added page-table switches and speculation barriers to the syscall path, making it substantially more expensive and pushing further work toward batching.
Block 7 — A race, and a lock
Teaches: concurrency bugs are timing-shaped, so hunt them with timing
The problem. Concurrency bugs are timing-shaped, which means they are invisible to any test that runs once. This block makes one visible, and the point is the variability, not the wrongness.
@block(7, "A race, and a lock", "concurrency bugs are timing-shaped, so hunt them with timing")
def b7(s, show):
import threading
def counter(n, lock=None):
v = [0]
def worker():
for _ in range(n):
if lock:
with lock: v[0] += 1
else:
x = v[0]; x += 1; v[0] = x # deliberately non-atomic
ts = [threading.Thread(target=worker) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
return v[0]
if show:
n = 60_000
bad = [counter(n) for _ in range(3)]
good = counter(n, threading.Lock())
print(f" 4 threads x {n:,} increments; expected {4*n:,}")
print(f" without a lock: {bad} (lost {[4*n-b for b in bad]})")
print(f" with a lock: {good} correct = {good == 4*n}")
print(" The unlocked version is not merely wrong -- it is wrong by a")
print(" DIFFERENT amount each run, which is what makes these bugs so")
print(" expensive. A test that passes proves nothing; only the invariant")
print(" (final == 4n) and many runs can detect it. Note the GIL does not")
print(" save you: it makes each BYTECODE atomic, and 'x = v[0]; x += 1;")
print(" v[0] = x' is three of them.")
return {}
Reading the implementation
x = v[0]; x += 1; v[0] = x is read-modify-write, and it is three separate
operations. Two threads interleaving between the read and the write both compute
the same new value, and one increment is lost.
The GIL does not save you. It makes each bytecode atomic, and this is three bytecodes. That is a widely-held misconception worth demolishing explicitly: the GIL prevents data races on the interpreter's internal structures, not on your program's logic.
The output is the real lesson: wrong by a different amount each run. A test
that asserts a specific wrong value is useless; only the invariant (final == 4n)
plus many runs detects it. This is the same argument as P05's
deterministic simulator — concurrency bugs need either exhaustive scheduling or
statistical detection, and hoping is not a third option.
What the numbers say
Output:
4 threads x 60,000 increments; expected 240,000
without a lock: [240000, 240000, 240000] (lost [0, 0, 0])
with a lock: 240000 correct = True
The unlocked version is not merely wrong -- it is wrong by a
DIFFERENT amount each run, which is what makes these bugs so
expensive. A test that passes proves nothing; only the invariant
(final == 4n) and many runs can detect it. Note the GIL does not
save you: it makes each BYTECODE atomic, and 'x = v[0]; x += 1;
v[0] = x' is three of them.
Beyond the toy
- Spinlock vs MCS. A test-and-set spinlock has every waiter hammering the same cache line, so contention triggers a cache-line ping-pong storm that gets worse with more cores — negative scaling. MCS/CLH queue locks give each waiter its own cache line to spin on, making cost independent of contention. At 100+ cores this is the difference between working and not.
- RCU makes readers completely free — no atomics, no barriers on most architectures — by deferring reclamation until every pre-existing reader has passed a quiescent state. It is why Linux scales read-mostly structures to hundreds of cores, and it is a garbage-collection problem in disguise (P11).
- Futex puts the uncontended path in userspace (one atomic CAS) and enters the kernel only on contention — a syscall avoided is 128 ns saved.
- Memory ordering is the portability trap. x86-64 is TSO (strongly ordered); ARM is weakly ordered. Code that is accidentally correct on x86 breaks on Apple silicon or Graviton, and the bug appears only under load.
- False sharing: two unrelated variables in the same 64-byte cache line cause the line to bounce between cores. A 10× slowdown from padding a struct is a real and common finding.
Block 8 — Putting a process together
Teaches: every block above is one field of a PCB
The problem. Every mechanism above is one field of one structure. Assembling them is what turns "I know what a page table is" into "I know what a process is".
@block(8, "Putting a process together", "every block above is one field of a PCB")
def b8(s, show):
if show:
print(" struct process {")
print(" pid_t pid; /* block 5: scheduler identity */")
print(" state_t state; /* READY | RUNNING | BLOCKED */")
print(" context_t regs; /* block 4: saved on switch */")
print(" pagetable_t *pgdir; /* block 2: its address space */")
print(" file_t *fds[NOFILE]; /* block 6: what open() returned */")
print(" uint64 quantum_left; /* block 5: preemption budget */")
print(" struct process *parent; /* for wait() and exit status */")
print(" };")
print(" Every field is one of the mechanisms built above, and the operating")
print(" system is mostly the code that keeps these consistent across")
print(" switches, faults and traps. 'What is in a PCB' is the single best")
print(" question for checking whether you understand a kernel, because you")
print(" cannot answer it without having built each field's machinery.")
return {}
Reading the implementation
The PCB is the kernel's per-process state, and each field is a mechanism from an
earlier block: the scheduler identity and quantum (block 5), the saved register
context (block 4), the page-table root (block 2), the file-descriptor table (block
6), and the parent link that makes wait() and exit status work.
"What is in a PCB" is the single best question for checking whether someone
understands a kernel, because you cannot answer it without having built each
field's machinery. It is also the structure that makes the cost of a process
concrete: a fork() copies this, plus page tables, plus the descriptor table —
which is why vfork/posix_spawn exist and why COW was invented.
What the numbers say
Output:
struct process {
pid_t pid; /* block 5: scheduler identity */
state_t state; /* READY | RUNNING | BLOCKED */
context_t regs; /* block 4: saved on switch */
pagetable_t *pgdir; /* block 2: its address space */
file_t *fds[NOFILE]; /* block 6: what open() returned */
uint64 quantum_left; /* block 5: preemption budget */
struct process *parent; /* for wait() and exit status */
};
Every field is one of the mechanisms built above, and the operating
system is mostly the code that keeps these consistent across
switches, faults and traps. 'What is in a PCB' is the single best
question for checking whether you understand a kernel, because you
cannot answer it without having built each field's machinery.
Beyond the toy
- Threads are the same structure with sharing. Linux does not distinguish
processes and threads at the kernel level;
clone()takes flags saying which fields to share (CLONE_VMshares the address space,CLONE_FILESthe descriptor table). That unification is a genuinely elegant design decision and it is why Linux threads are as cheap as processes rather than the other way round. - Containers are the same structure with namespaces. A container is a process whose PCB points at private namespaces for PIDs, mounts, network and users, plus a cgroup for resource limits. There is no "container" object in the kernel — which is why the abstraction leaks in exactly the places where a namespace does not exist.
- The next step is xv6 or an equivalent on QEMU, where the page tables are the CPU's rather than a dictionary and a wrong entry halts the machine instead of raising an exception. Everything here transfers; what does not transfer is precisely what makes kernel work feel different.
The assembly
Every block above, wired together into one working system:
def assembly(s):
print("\nEight blocks = the mechanisms of a kernel. One workload through all of them.\n")
rng = random.Random(12)
NPROC, PAGES = 4, 12
refs = [(rng.randrange(NPROC), rng.randrange(PAGES) if rng.random() < .3
else rng.randrange(3)) for _ in range(600)]
keyed = [f"{p}:{v}" for p, v in refs]
hot = len({k for k in keyed if int(k.split(":")[1]) < 3})
print(f" {NPROC} processes, {PAGES} virtual pages each, {len(refs)} references")
print(f" with 70/30 locality. The combined hot working set is {hot} pages.\n")
print(f" {'frames':>7}{'FIFO':>7}{'CLOCK':>7}{'LRU':>6}{'OPT':>6}"
f"{'LRU fault rate':>17}{'paging @90us':>15}")
for F in (4, 8, 12, 16, 24, 48):
fs = [fn(keyed, F) for fn in (s["fifo"], s["clock"], s["lru"], s["opt"])]
print(f" {F:>7}{fs[0]:>7}{fs[1]:>7}{fs[2]:>6}{fs[3]:>6}"
f"{fs[2]/len(refs):>16.1%}{fs[2]*90/1000:>13.1f}ms")
dist = len(set(keyed))
print(f"\n I expected a cliff at {hot} frames -- the hot working set -- and there")
print(f" is none. The curve declines smoothly from 79.7% to 7.8% and only")
print(f" bottoms out at {dist} frames, where every distinct page is resident and")
print(f" the remaining {min(47, dist)} faults are compulsory. The prediction was wrong for a")
print(" reason worth more than the prediction: this workload is a MIXTURE, 70%")
print(f" into {hot} hot pages and 30% uniform over all {dist}. A mixture has no single")
print(" working set, so it has no knee. Denning's model describes a phase, and")
print(" real programs are a superposition of phases -- which is why 'size the")
print(" cache to the working set' is advice you can only follow after measuring")
print(" the curve, never by reasoning about the program.")
print("\n Two things to take from the numbers rather than the shape. First, at")
print(" 12 frames the best implementable policy beats the worst by 9% (LRU 256")
print(" vs FIFO 279) while the unrealisable OPT beats LRU by 1.92x -- almost")
print(" all the available win is in knowing the future, which is why prefetching")
print(" and hinting (madvise, fadvise) buy more than any eviction heuristic ever")
print(" will. Second, the paging column dwarfs the compute: 23ms of stalls")
print(" against a job whose actual work is 10ms. The memory hierarchy is not a")
print(" tax on the computation, it IS the computation's cost.")
print("\n That is the same lesson as P04's Bloom filters (avoid the I/O), P13's")
print(" checkpointing (trade compute for memory), and P14's tiling (fit the")
print(" working set in cache). Four projects, four altitudes, one hierarchy.")
print("\n Built: frame allocator -> virtual memory -> replacement policy ->")
print(" context switch -> scheduling -> syscalls -> locking -> the PCB.")
print(" Missing, on the project page: real x86-64 boot and a GDT/IDT (m1-m3),")
print(" hardware page tables with a TLB and its shootdown (m5), a disk driver")
print(" and a real file system (m8-m10), and E6 -- the experiment where you")
print(" measure context-switch cost as a function of working-set size and watch")
print(" cache pollution dwarf the register save.")
Output:
Eight blocks = the mechanisms of a kernel. One workload through all of them.
4 processes, 12 virtual pages each, 600 references
with 70/30 locality. The combined hot working set is 12 pages.
frames FIFO CLOCK LRU OPT LRU fault rate paging @90us
4 486 484 478 336 79.7% 43.0ms
8 373 364 354 205 59.0% 31.9ms
12 279 265 256 133 42.7% 23.0ms
16 219 198 174 97 29.0% 15.7ms
24 137 112 107 67 17.8% 9.6ms
48 47 47 47 47 7.8% 4.2ms
I expected a cliff at 12 frames -- the hot working set -- and there
is none. The curve declines smoothly from 79.7% to 7.8% and only
bottoms out at 47 frames, where every distinct page is resident and
the remaining 47 faults are compulsory. The prediction was wrong for a
reason worth more than the prediction: this workload is a MIXTURE, 70%
into 12 hot pages and 30% uniform over all 47. A mixture has no single
working set, so it has no knee. Denning's model describes a phase, and
real programs are a superposition of phases -- which is why 'size the
cache to the working set' is advice you can only follow after measuring
the curve, never by reasoning about the program.
Two things to take from the numbers rather than the shape. First, at
12 frames the best implementable policy beats the worst by 9% (LRU 256
vs FIFO 279) while the unrealisable OPT beats LRU by 1.92x -- almost
all the available win is in knowing the future, which is why prefetching
and hinting (madvise, fadvise) buy more than any eviction heuristic ever
will. Second, the paging column dwarfs the compute: 23ms of stalls
against a job whose actual work is 10ms. The memory hierarchy is not a
tax on the computation, it IS the computation's cost.
That is the same lesson as P04's Bloom filters (avoid the I/O), P13's
checkpointing (trade compute for memory), and P14's tiling (fit the
working set in cache). Four projects, four altitudes, one hierarchy.
Built: frame allocator -> virtual memory -> replacement policy ->
context switch -> scheduling -> syscalls -> locking -> the PCB.
Missing, on the project page: real x86-64 boot and a GDT/IDT (m1-m3),
hardware page tables with a TLB and its shootdown (m5), a disk driver
and a real file system (m8-m10), and E6 -- the experiment where you
measure context-switch cost as a function of working-set size and watch
cache pollution dwarf the register save.
The design space
A kernel is a set of policies over a fixed set of mechanisms. The mechanisms are what the blocks build; the policies are where the design decisions live.
| Mechanism | Policy choices | What decides |
|---|---|---|
| Physical allocation | bitmap, buddy, slab, per-CPU caches | fragmentation vs allocation latency |
| Virtual memory | page size, multi-level tables, inverted tables | TLB reach vs memory overhead |
| Replacement | FIFO, CLOCK, LRU, ARC, 2Q, LIRS | hit rate vs metadata cost |
| Scheduling | FCFS, SJF, RR, MLFQ, CFS, EEVDF | turnaround vs response vs fairness |
| Concurrency | spinlock, MCS, RCU, seqlock, futex | contention level and read/write ratio |
| I/O | interrupt, polling, NAPI, io_uring | throughput vs latency vs CPU cost |
Scheduling, concretely
There is no scheduler that wins every column, which block 5 measures directly: SJF minimises mean turnaround (provably) and can starve long jobs forever; round robin has the best response time and the worst turnaround. Real systems therefore approximate:
- MLFQ infers job type from behaviour — a job that yields before its quantum expires is interactive and stays high priority; one that burns its quantum is batch and drops. Periodic priority boosts prevent starvation.
- CFS replaced heuristic MLFQ with a red-black tree ordered by virtual runtime, always running the least-progressed task. Fairness becomes a data structure invariant rather than a bag of heuristics.
- EEVDF (in Linux since 6.6) adds latency requirements as a first-class parameter — eligible virtual deadline first — giving latency-sensitive tasks bounded response without the priority inversions nice values caused.
Latency: the numbers that shape every policy
| Event | Cost | Source |
|---|---|---|
| L1 hit | 0.91 ns | measured, numbers.md |
| L2 hit | 5.94 ns | measured |
| DRAM | 121.10 ns | measured |
| Syscall (getpid) | 127.59 ns | measured |
| Context switch | 1,383--1,706 ns | measured |
| TLB miss (page walk) | 10--100+ ns | typical |
| Minor page fault | ~1--3 µs | typical |
| Major fault (NVMe) | 20--100 µs | typical |
| Major fault (HDD) | ~10 ms | typical |
fsync | 90--105 µs | measured |
Two of these deserve emphasis because they are routinely mis-modelled.
The context switch is not the register save. Block 4 models 1.7 µs of direct cost, and at a 2 µs quantum the kernel spends ~45% of the CPU switching. But the register save is ~50 instructions; the real cost is the cold cache and TLB after the switch, which can be thousands of cycles and appears in no counter named "context switch". This is why measuring switch cost as a function of working-set size (E6 on the project page) gives a completely different answer from measuring it with an empty working set.
A syscall at 127.59 ns is ~1000 arithmetic instructions. That single ratio
explains batched interfaces: readv/writev, sendmmsg, and above all
io_uring, which replaces syscall-per-operation with shared submission and
completion ring buffers so a thread can issue thousands of I/Os with zero
syscalls. It also explains why Spectre/Meltdown mitigations (KPTI) were such a
large regression — they added a page-table switch to every syscall.
Memory: what the assembly actually shows
The page-fault sweep contradicts its own prediction, and the reason is the useful part. There is no working-set cliff at 12 frames because the workload is a mixture — 70% into 12 hot pages, 30% uniform over 47 — and a mixture of reference distributions has no single knee. Denning's working-set model describes a phase; real programs are superpositions of phases. "Size the cache to the working set" is therefore advice you can only follow after measuring the miss curve, never by reasoning about the program.
Two quantitative findings from the same table:
- At 12 frames the best implementable policy beats the worst by 9% (LRU 256 vs
FIFO 279), while unrealisable OPT beats LRU by 1.92×. Almost all the
available win is in knowing the future, which is why prefetching and hinting
(
madvise,fadvise,readahead) buy more than any eviction heuristic. - Paging cost (23 ms) dwarfs the compute (10 ms). The memory hierarchy is not a tax on the computation; it is the computation's cost.
Bélády's anomaly (block 3) is the sharpest version of the same lesson: FIFO with 4 frames faults more than with 3. LRU cannot do this because it is a stack algorithm — the resident set with \(n\) frames is always a subset of the set with \(n+1\). FIFO has no such property, so buying RAM can lose performance.
TLB reach, the number nobody checks
A 1536-entry TLB with 4 KiB pages covers 6 MB. A process with a 10 GB working set misses the TLB on essentially every new page, and each miss is a multi-level page walk (4 levels on x86-64, up to 5) — itself potentially 4 cache misses. Huge pages (2 MiB) extend reach 512× to ~3 GB. This is the single highest-leverage tuning knob for large-heap workloads, and it is the same mechanism P02 needs for a 100 GB index.
Concurrency: beyond the lock
Block 7 shows a lost-update race, and the GIL note matters: the GIL makes each
bytecode atomic, and x = v[0]; x += 1; v[0] = x is three of them. The
production toolkit goes well past a mutex:
- Spinlock vs MCS: a test-and-set spinlock has every waiter hammering the same cache line, so contention causes a cache-line ping-pong storm that gets worse with more cores. MCS/CLH queue locks give each waiter its own cache line to spin on, making cost independent of contention.
- RCU (read-copy-update) makes readers free — no atomics, no barriers on most architectures — by deferring reclamation until every pre-existing reader has passed a quiescent state. It is the reason Linux scales read-mostly structures to hundreds of cores, and it is a garbage-collection problem in disguise (P11).
- Futex puts the fast path in userspace (an atomic CAS) and only enters the kernel on contention — a syscall avoided is 127 ns saved.
- Memory ordering: x86-64 is TSO (strong); ARM is weakly ordered, so code that is accidentally correct on x86 breaks on Apple silicon or Graviton. This is a real portability class, not a theoretical one.
How this connects to the rest of the track
- P04 sits directly on the page cache and the block layer; the page-cache trap in numbers.md §14 is this layer distorting that project's measurements.
- P02 and P14 need the same TLB and cache reasoning one level up.
- P11's GC and this project's page replacement both decide what memory to reclaim, with the same reachability-vs-recency distinction.
- P01's paged attention is literally this project's paging applied to a KV cache.
- P05 runs on top of everything here, and its
fsynccost is this layer's.
Failure modes at scale
- Thrashing: the working set exceeds physical memory and every policy fails identically. Detect with fault rate, not fault count.
- Priority inversion: a low-priority thread holds a lock a high-priority thread needs. Priority inheritance is the fix; the Mars Pathfinder reset is the canonical incident.
- Lock convoys and cache-line ping-pong — false sharing of two unrelated variables in one 64-byte line can cost 10× on a multicore.
- NUMA effects: remote memory is 1.5--2× the latency of local. A thread migrated to another socket keeps its pages behind and slows down permanently unless the scheduler is NUMA-aware.
- Interrupt storms at high packet rates, which is why NAPI switches from interrupts to polling under load.
Primary sources
- Bélády, Nelson & Shedler, An Anomaly in Space-Time Characteristics of Certain Programs (CACM 1969) — block 3.
- Denning, The Working Set Model for Program Behavior (CACM 1968).
- Corbató, A Paging Experiment with the Multics System (1968) — CLOCK.
- Mellor-Crummey & Scott, Algorithms for Scalable Synchronization (TOCS 1991) — MCS locks.
- McKenney & Slingwine, Read-Copy Update (1998).
- Arpaci-Dusseau & Arpaci-Dusseau, Operating Systems: Three Easy Pieces — the best free treatment; the scheduling and paging chapters map onto blocks 3--5.
- Cox, Kaashoek & Morris, xv6: a simple, Unix-like teaching operating system — the natural next step from these blocks.
Running it
python3 handson/h12_kernel.py # every block, then the assembly
python3 handson/h12_kernel.py --block 3 # just block 3 and its prerequisites
python3 handson/h12_kernel.py --quiet # the assembly only
What to do with this
The next step is xv6 or an equivalent, on real hardware or QEMU, where the page tables are the CPU's rather than a dictionary. Everything here transfers, and what does not transfer --- the fact that a wrong page table halts the machine instead of raising an exception --- is precisely the part that makes kernel work feel different.
Milestones, experiments, readings and exit criteria for this project: P12 — Operating-System Kernel.