OSOperating Systems

Block A · Topics 1–7

Basics

The machine underneath: what the kernel is, how your code asks it for things, what a process and a thread actually are, and how the CPU gets divided between them.

01

What an OS does

kernel modeuser modeprivilege ringsresource manager
Why it exists

Without an OS, every program would have to know your exact hardware, and any program could scribble over any other's memory or take the CPU forever. The OS exists to be the one program that is trusted, so that no other program has to be.

The two jobs

Everything an OS does is one of these, and saying so out loud organises an otherwise sprawling subject:

  • Abstraction. Turn awkward hardware into clean interfaces — disks become files, network cards become sockets, physical RAM becomes a private address space. Programs get portability; the OS absorbs the mess.
  • Arbitration. Decide who gets the CPU, the memory, the disk bandwidth — and enforce that decision against programs that would rather have all of it. This is where scheduling, quotas, and isolation live.

Kernel mode vs user mode

The CPU itself has a privilege bit. On x86 these are rings: ring 0 is kernel mode, ring 3 is user mode (rings 1 and 2 exist and are essentially unused). The mode determines which instructions are legal.

User mode (ring 3)Kernel mode (ring 0)
RunsYour application codeThe kernel and its drivers
Can execute privileged instructionsNo — attempting one trapsYes: load page tables, disable interrupts, do raw I/O, halt
Memory accessOnly its own mapped pagesAll physical memory
A crash meansOne process dies. Segmentation fault.The machine dies. Kernel panic / BSOD.
Gets to the other mode bySyscall, interrupt or exceptionReturning from one

This single bit is the enforcement mechanism for the entire isolation model. When your process tries to write to address 0, the CPU checks the page tables (which only kernel mode can change), finds no mapping, and traps into the kernel — which kills your process instead of letting it corrupt someone else's.

Kernel architectures

DesignIdeaTradeoffExamples
MonolithicEverything — drivers, file systems, networking — runs in kernel modeFast (no mode switches between subsystems), but a buggy driver panics the machine and the codebase is enormousLinux, BSD
MicrokernelKernel does only IPC, scheduling and basic memory; drivers and file systems are user-space serversA crashed driver is a restarted process, not a dead machine — but every request becomes IPC, which costs performanceQNX, MINIX, seL4
HybridMicrokernel structure, performance-critical parts kept in kernelThe pragmatic compromise; harder to reason aboutWindows NT, macOS (XNU)
Unikernel / library OSApplication and just-enough-OS compiled into one imageTiny attack surface and very fast boot; no general-purpose flexibilityMirageOS, Firecracker guests

Linux is monolithic but modular — drivers load and unload at runtime as kernel modules. That's a packaging distinction, not an isolation one: a loaded module still runs in ring 0 and can still panic the box.

Say this

"The OS does two things: it abstracts hardware into clean interfaces like files and sockets, and it arbitrates between programs competing for the CPU, memory and I/O. The mechanism that makes arbitration enforceable rather than advisory is the CPU's privilege bit — user code physically cannot execute the instructions that would let it escape, so every request for a privileged operation has to go through the kernel, which gets to say no."

02

System calls & interrupts

syscalltrapinterruptmode switch
Why it exists

User code needs privileged work done but cannot be trusted with privilege. A system call is the controlled doorway: a single, well-defined entry point where the CPU raises privilege, the kernel validates every argument, and control returns to exactly where it left off.

The three ways into the kernel

EventSourceSynchronous?Example
System call (trap)The running program, deliberatelyYes — you asked for itread(), open(), fork()
Exception (fault)The running program, accidentallyYes — caused by the current instructionPage fault, divide by zero, invalid opcode
InterruptHardware, asynchronouslyNo — unrelated to what's runningTimer tick, disk completion, keypress, packet arrival

All three vector through the same machinery: the CPU switches to kernel mode, looks up a handler in the interrupt descriptor table, and runs it. The distinction that matters for interviews is who caused it and whether the current instruction can be resumed. A page fault is resumable — fix the mapping and re-execute. A divide by zero is not.

What a system call actually costs

// What you write
n = read(fd, buf, 4096);

// What happens
1. libc puts the syscall number in a register (rax) and args in rdi/rsi/rdx
2. executes SYSCALL  → CPU switches to ring 0, jumps to the kernel entry point
3. kernel saves user registers, switches to the kernel stack
4. validates: is fd open? is buf a writable user address? is len sane?
      // this validation is not optional — skipping it is a privilege escalation
5. does the work (maybe from the page cache; maybe blocks on disk)
6. restores registers, SYSRET → back to ring 3, right after the SYSCALL

Direct cost: ~50–100 ns on modern x86 (SYSCALL/SYSRET are fast).
Real cost: often much more, because the entry/exit path flushes speculation
state and pollutes caches. Spectre/Meltdown mitigations roughly doubled it.

Consequence: batching matters. Reading 1 MB in 4 KB chunks is 256 syscalls;
one 1 MB read is one. This is why buffered I/O exists, why io_uring exists,
and why "just add a syscall per event" designs collapse under load.
A syscall is not a function call

People conflate printf() with a syscall. printf is a library function that formats a string in user space and may call write() — or may not, because it buffers. That's why output ordering breaks when you mix printf with direct write, and why unflushed printf output vanishes when a process crashes. Knowing where the user/kernel line sits is the point of the question.

Interrupt handling and the two halves

An interrupt handler runs at a bad time — it has stolen the CPU from whatever was running, often with interrupts disabled. So it must be short. Linux splits the work:

  • Top half (hard IRQ): acknowledge the device, grab the data out of the way, schedule the rest. Microseconds. Cannot sleep, cannot block.
  • Bottom half (softirq, tasklet, or workqueue): the actual processing — parse the packet, wake the waiting process. Runs with interrupts enabled and can be preempted.

Why it matters practically: a network card at 1M packets/second would spend the entire CPU in interrupt handlers. The fix is interrupt coalescing (the device batches and interrupts once per N packets) and polling (Linux NAPI switches from interrupts to polling under load). This is the same batching insight as syscalls.

The boot path, briefly

power on → firmware (BIOS/UEFI) → POST, find a boot device
  → bootloader (GRUB) loads the kernel + initramfs into memory
  → kernel: set up page tables, detect hardware, mount root
  → start PID 1 (systemd/init) in user mode
  → PID 1 starts everything else
The only reason to know this: it explains why PID 1 is special (it adopts
orphans and cannot be killed) and where "kernel panic: no init found" comes from.
Say this

"There are three entries into the kernel: syscalls, which the program requests; exceptions like page faults, which the current instruction causes; and hardware interrupts, which are asynchronous. A syscall is only about 50–100 nanoseconds of direct cost, but the entry and exit disturb caches and speculation state, so the effective cost is higher — which is why batching with buffered I/O or io_uring matters, and why a design with one syscall per small event doesn't scale."

03

Processes

PCBaddress spacefork/execzombiestates
Why it exists

A program is a file on disk. A process is that program running: it needs a private address space nobody else can touch, its own open files, its own registers, and an identity the OS can schedule and account for. The process is the unit of isolation and the unit of resource ownership.

What a process is made of

Address space (virtual, per-process)      Kernel-side (the PCB)
┌──────────────────────┐ high             · PID, PPID, UID/GID
│ stack   ↓ grows down │                  · process state
├──────────────────────┤                  · saved registers + program counter
│      (unmapped)      │                  · page table pointer (CR3)
├──────────────────────┤                  · open file descriptor table
│ heap    ↑ grows up   │  ← malloc/brk    · signal handlers + pending signals
├──────────────────────┤                  · cwd, umask, resource limits
│ BSS  (zero-init)     │                  · accounting: CPU time, faults
├──────────────────────┤                  · exit status (kept for the parent)
│ data (initialised)   │
├──────────────────────┤            In Linux the PCB is task_struct,
│ text (code, r-x)     │ low         and it is per-THREAD, not per-process.
└──────────────────────┘

Process states

NEW READY in the run queue RUNNING on a CPU now WAITING blocked on I/O or a lock TERMINATED zombie until reaped admit dispatch exit blocks I/O done preempted — quantum expired or a higher priority arrived Linux names these in ps: R running/runnable · S interruptible sleep · D uninterruptible sleep (usually disk — cannot be killed) · Z zombie · T stopped
The transition that surprises people: running → ready. A process loses the CPU without wanting to, and without being blocked.

fork, exec, wait

fork()   duplicate the calling process. Returns TWICE:
           child gets 0, parent gets the child's PID, −1 on failure.
         The child gets a copy of the address space — but lazily, via
         copy-on-write: pages are shared read-only until one side writes.

exec()   REPLACE this process's image with a new program.
         Same PID, same fds (unless marked close-on-exec). Does not return.

wait()   parent blocks until a child exits, and collects its exit status.
         This is what REAPS the zombie.

// The universal shell pattern: fork, then exec in the child
pid_t pid = fork();
if (pid == 0)      { execvp("ls", args); _exit(127); }  // child
else if (pid > 0) { waitpid(pid, &status, 0); }             // parent
Zombies and orphans — the classic pair

Zombie: the child has exited but the parent hasn't wait()ed. The kernel keeps the PCB alive purely to hold the exit status, because the parent might still ask. It uses no CPU or memory beyond that entry — but it holds a PID, and enough of them exhaust the PID space. Fix: call wait(), or set SIGCHLD to SIG_IGN so the kernel reaps automatically.

Orphan: the parent exited first. The child is re-parented to PID 1, which reaps it. Orphans are harmless; zombies are the leak. Getting this pair the right way round is a very common interview discriminator.

Say this

"A process is a program with its own virtual address space, file descriptors and kernel bookkeeping in the PCB. fork duplicates it — copy-on-write, so the copy is cheap until someone writes — and exec replaces the image while keeping the PID. The state to explain carefully is running → ready: that's preemption, where the process loses the CPU while still perfectly able to run, which is what makes the system responsive rather than fair to whoever grabbed it first."

04

Threads

user vs kernel threadsthreading modelsshared heap
Why it exists

Processes are isolated, which is exactly what you want between programs and exactly what you don't want inside one. A web server handling 1,000 connections wants 1,000 concurrent flows that share the cache and the connection pool. Threads are concurrency without the isolation tax: creation is cheaper, switching is cheaper, and communication is a shared variable rather than IPC.

What's shared and what isn't

Shared across threads in a processPrivate to each thread
Code (text) segmentStack
Heap — the source of every data raceRegisters, including the program counter
Global and static variablesThread ID
Open file descriptorsSignal mask
Current working directory, PID, signal handlersThread-local storage, errno
The consequence people skip

A shared heap means one thread's bug corrupts the whole process. An out-of-bounds write in a worker thread can silently trash another thread's data structure; a segfault in any thread kills every thread. Process isolation costs more and buys you a fault boundary — which is exactly why Chrome uses a process per tab rather than a thread per tab.

Threading models

ModelMappingTradeoff
Many-to-one (pure user threads)N user threads → 1 kernel threadSwitching is a function call, so extremely fast and no syscall needed. But one blocking call blocks every thread, and you can never use more than one core.
One-to-one1 user thread → 1 kernel threadTrue parallelism, blocking is per-thread. Each thread costs kernel memory and a scheduler entry, so tens of thousands is expensive. Linux pthreads, Windows, Java.
Many-to-many (M:N)N user threads → M kernel threadsBest of both on paper: cheap switching plus real parallelism. Complex to implement — the runtime must handle blocking calls. Go goroutines, Erlang processes.

Goroutines are the model worth naming: a goroutine starts with a ~2–8 KB growable stack against a thread's 8 MB, and Go's runtime multiplexes them onto one OS thread per core, moving a goroutine off when it blocks. That's why a million goroutines is routine and a million OS threads is not.

Choosing: process, thread, or neither

ModelGood forCosts
Process per taskUntrusted or crash-prone work; needing a real fault and security boundaryHighest memory and switch cost; IPC to communicate
Thread per taskCPU-bound parallelism; moderate concurrency with blocking callsShared-memory bugs; ~8 MB virtual stack each; scheduler pressure past a few thousand
Thread poolBounded parallelism for short tasks — the sane defaultA blocking task can starve the pool; needs queue and sizing discipline
Event loop (async I/O)Very high concurrency, I/O-bound: 100k connections on one threadOne blocking or CPU-heavy call stalls everything; harder control flow
Green threads / coroutinesHigh concurrency with straight-line codeNeeds runtime support; FFI to blocking C code is a trap

Sizing heuristics worth quoting: for CPU-bound work, threads ≈ number of cores (more just adds switching). For I/O-bound work, threads ≈ cores × (1 + wait time / compute time), which is why an I/O-heavy service can usefully run far more threads than cores.

Say this

"Threads share the heap, globals and file descriptors, and have their own stack and registers. The shared heap is the whole point — communication is a variable rather than IPC — and also the whole danger, because one thread's memory bug corrupts the process and one segfault kills every thread. For a service I'd default to a bounded thread pool for CPU work and an event loop for high-concurrency I/O; thread-per-connection stops scaling in the low thousands because each thread costs kernel memory and a scheduler slot."

05

Context switching

saved stateTLB flushcache pollution
Why it exists

One CPU, many runnable things. Switching is the mechanism that makes the "I have the CPU to myself" illusion possible — and it is pure overhead. Every cycle spent switching is a cycle not spent computing, so the OS is constantly trading responsiveness against throughput.

What happens

  1. An interrupt or trap enters the kernel (timer tick, syscall that blocks, or a higher-priority task waking).
  2. Save the outgoing task's registers, program counter and stack pointer into its PCB.
  3. The scheduler picks the next task.
  4. If it's a different process, switch address spaces — load the new page table root (CR3 on x86). This is the expensive part.
  5. Restore the incoming task's registers and return to user mode. Execution resumes exactly where it stopped.

The real cost is indirect

Direct cost      1–3 µs: saving/restoring registers, scheduler bookkeeping.
                 Measurable, and honestly small.

Indirect cost    THE ACTUAL PROBLEM.
  · Cache pollution — the new task evicts the old task's L1/L2/L3 lines.
    When the old task resumes, it starts cold. Rebuilding a working set
    can cost tens of microseconds, 10–100× the direct cost.
  · TLB pressure — address-space switch invalidates translations. Tagged
    TLBs (PCID on x86) reduce but do not remove this.
  · Branch predictor and prefetcher state are lost too.

Thread → thread in the SAME process is cheaper: same page tables, so no
CR3 reload and much less TLB damage. Caches are still disturbed.

Rule of thumb: a switch effectively costs low tens of microseconds once
you count the cold cache. That is why a 4 ms quantum is fine (<1% overhead)
and a 10 µs quantum would be catastrophic.
Where this shows up in production

A service with 5,000 threads all waking on a shared condition variable ("thundering herd") can spend the majority of its CPU on context switches and lock handoffs while doing almost no work. The symptom is high system CPU, high cs in vmstat, and low throughput. The fix is fewer runnable threads — a bounded pool — not a faster machine. Being able to name that symptom is exactly what an SRE interview is looking for.

Say this

"A context switch saves registers and the program counter, and for a different process also swaps page tables. The direct cost is a couple of microseconds, but the real cost is indirect — the incoming task evicts the outgoing task's cache lines, so when it resumes it runs cold, which can be ten to a hundred times the direct cost. That's why quanta are milliseconds rather than microseconds, and why a service with thousands of runnable threads can burn most of its CPU switching instead of working."

06

Scheduling algorithms

FCFSSJFSRTFround robinpriority
Why it exists

More runnable tasks than CPUs. Whoever you pick, you're optimising something at someone's expense — and the exam version of this topic is arithmetic, while the interview version is "which would you choose and why". Know both.

The metrics

Arrival time — when the job appears. Burst time — CPU time it needs.
Completion time (CT) — when it finishes.
Turnaround time (TAT) = CT − Arrival  — total time in the system.
Waiting time (WT) = TAT − Burst  — time spent not running.
Response time — arrival → first time it runs. What users feel.

The algorithms

AlgorithmPicksPreemptiveWinsLoses
FCFSEarliest arrivalNoTrivially simple and fair in orderConvoy effect: one long job makes everyone wait. Terrible response time.
SJFShortest burstNoProvably optimal average waiting time among non-preemptive schedulesNeeds the future (burst lengths are unknown); starves long jobs
SRTFShortest remainingYesOptimal average waiting time overallSame clairvoyance problem, plus heavy switching and worse starvation
Round robinNext in queue, for one quantumYesBounded response time, no starvation — the right shape for interactive workPoor average turnaround; throughput falls as the quantum shrinks
PriorityHighest priorityEitherExpresses what matters to youStarvation of low priority. Needs ageing (raise priority over time).
HRRNHighest response ratio (W+B)/BNoFavours short jobs but ages long ones in — no starvationStill needs burst estimates

Worked example — the same jobs, four algorithms

Job  Arrival  Burst
P1     0        7
P2     2        4
P3     4        1
P4     5        4

FCFS            P1(0-7)  P2(7-11)  P3(11-12)  P4(12-16)
  WT: P1=0, P2=5, P3=7, P4=7                      avg WT = 4.75
  P3 needs 1ms and waits 7 — the convoy effect in one line.

SJF (non-preemptive)   P1(0-7)  P3(7-8)  P2(8-12)  P4(12-16)
  WT: P1=0, P2=6, P3=3, P4=7                      avg WT = 4.00
  At t=7 it picks P3 (burst 1) over P2 (burst 4). P1 already ran because
  nothing else had arrived at t=0 — non-preemptive can't undo that.

SRTF (preemptive SJF)
  P1(0-2) P2(2-4) P3(4-5) P2(5-7) P4(7-11) P1(11-16)
  WT: P1=9, P2=1, P3=0, P4=2                      avg WT = 3.00
  Best average — and P1, the longest job, absorbs all the pain.

Round robin (quantum = 2)
  P1(0-2) P2(2-4) P1(4-6) P3(6-7) P2(7-9) P4(9-11) P1(11-13) P4(13-15) P1(15-16)
  WT: P1=9, P2=5, P3=2, P4=6                      avg WT = 5.50
  Worst average waiting time — and the best RESPONSE time: every job
  runs within one round. That trade is the entire point of RR.
The insight to state, not just the number

SRTF wins on average waiting time and RR loses — yet every interactive system on earth is round-robin-shaped. Because average waiting time is the wrong metric for humans: a user cares that their keystroke echoes in 50 ms, not that the mean across all jobs is minimal. Saying which metric an algorithm optimises, and whether that metric matches the workload, is the answer they want.

Exam traps
  • Idle gaps. If nothing has arrived, the CPU idles — don't forget the gap when computing completion times.
  • RR queue order. When a job is preempted, does it go behind or in front of a job arriving at the same instant? State your convention; answers differ and examiners accept a stated assumption.
  • WT can't be negative. If you get a negative waiting time, you've mixed up turnaround and burst.
  • Preemptive priority ≠ SRTF. Related, not the same.
Say this

"SJF gives the provably lowest average waiting time, but it needs to know burst lengths in advance, which you never do, and it starves long jobs. Round robin gives up average turnaround to get a bounded response time and no starvation, which is what interactive workloads actually need. So the real question is which metric the workload cares about — batch processing wants throughput and would take shortest-job-first with estimates; a desktop or a web server wants response time and takes round robin with a few-millisecond quantum."

07

Scheduling in practice

MLFQCFSnicereal-timeload average
Why the textbook algorithms aren't enough

Real schedulers can't see the future and face a mixed workload: interactive tasks that block constantly and batch tasks that never do. They solve it by inferring behaviour from history — and that inference is the interesting part.

Multi-level feedback queues

The clever idea: rather than being told which jobs are short, find out. Start every job at the top priority. If it uses its whole quantum it's probably CPU-bound, so demote it. If it blocks before the quantum expires it's probably interactive, so keep it high.

Q0  quantum 8ms   ← new jobs start here; interactive tasks stay here
Q1  quantum 16ms  ← used a full quantum at Q0
Q2  quantum 32ms  ← CPU-bound; longer slices, fewer switches
Q3  FCFS          ← background batch

Rules:  higher queue always runs first
        used the whole quantum → drop a level
        blocked before it expired → stay (or move up)
        PERIODIC BOOST: every ~1s, move everything back to Q0

The boost exists for two reasons: it stops long-running jobs starving
forever, and it handles a job that CHANGES behaviour — a compiler that
finishes compiling and starts waiting on input. Without it, gaming the
scheduler is easy: issue a pointless I/O just before your quantum ends
and you stay at top priority forever.

Linux CFS — fair queuing instead of priorities

The Completely Fair Scheduler discards queues entirely. It tracks each task's virtual runtime (vruntime) — CPU time consumed, weighted by priority — in a red-black tree, and always runs the task with the smallest vruntime. In the limit, everyone converges to an equal share.

vruntime += actual_runtime × (NICE_0_WEIGHT / task_weight)

A nice value from −20 (highest priority) to +19 sets the weight; each step is ~1.25×, so a difference of 5 nice levels is roughly a 3× CPU share. Lower nice = higher priority = larger weight = vruntime grows more slowly = runs more often.

Why this design is elegant, and worth saying: starvation is impossible without any ageing machinery, because a starved task's vruntime stops growing and it automatically becomes the leftmost node. Fairness falls out of the data structure rather than being bolted on. A new or newly-woken task gets vruntime set near the minimum, so interactive tasks get served quickly without being explicitly detected. (Newer kernels replace CFS with EEVDF, which adds an explicit latency guarantee — same fairness idea, better tail behaviour.)

Scheduling classes and real-time

ClassPolicyBehaviour
Real-timeSCHED_FIFORuns until it blocks or yields. No quantum. A busy loop here freezes everything below it.
Real-timeSCHED_RRLike FIFO but round-robins among equal priorities.
DeadlineSCHED_DEADLINEEarliest-deadline-first with an admission test — the kernel refuses tasks it can't guarantee.
NormalSCHED_OTHERCFS. Everything you normally run.
IdleSCHED_IDLERuns only when nothing else wants the CPU.

Real-time means predictable, not fast. A hard real-time system guarantees a deadline is met (airbag controller); soft real-time tolerates occasional misses (video playback). Linux is not hard real-time out of the box — PREEMPT_RT exists precisely to reduce worst-case latency.

Multiprocessor scheduling

  • Per-CPU run queues avoid a single global lock, which would be a scalability disaster at high core counts.
  • Load balancing periodically migrates tasks between queues. Migration is costly — the task loses its warm cache and may land on a different NUMA node with slower memory.
  • Affinity keeps a task near its cache. Soft affinity is a preference; taskset gives hard pinning, which is standard for latency-sensitive services.
Load average is misread constantly

Linux's load average counts tasks that are runnable plus tasks in uninterruptible sleep (state D) — usually blocked on disk. So a load of 40 on an 8-core box might mean 40 tasks fighting for CPU, or 8 running and 32 stuck on a failing disk. Those need completely different fixes. Always read it alongside CPU utilisation and I/O wait; on its own it tells you something is queued, not what.

Say this

"Real schedulers can't know burst lengths, so they infer behaviour. MLFQ starts every task at high priority and demotes whatever uses a full quantum, with a periodic boost so nothing starves and so tasks that change behaviour get reclassified. Linux CFS goes further and drops priorities altogether: it tracks weighted CPU time per task and always runs the one that's had the least, so fairness comes from the data structure rather than from ageing rules — a starved task automatically becomes the next one to run."