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.
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)
Runs
Your application code
The kernel and its drivers
Can execute privileged instructions
No — attempting one traps
Yes: load page tables, disable interrupts, do raw I/O, halt
Memory access
Only its own mapped pages
All physical memory
A crash means
One process dies. Segmentation fault.
The machine dies. Kernel panic / BSOD.
Gets to the other mode by
Syscall, interrupt or exception
Returning 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.
Fast (no mode switches between subsystems), but a buggy driver panics the machine and the codebase is enormous
Linux, BSD
Microkernel
Kernel does only IPC, scheduling and basic memory; drivers and file systems are user-space servers
A crashed driver is a restarted process, not a dead machine — but every request becomes IPC, which costs performance
QNX, MINIX, seL4
Hybrid
Microkernel structure, performance-critical parts kept in kernel
The pragmatic compromise; harder to reason about
Windows NT, macOS (XNU)
Unikernel / library OS
Application and just-enough-OS compiled into one image
Tiny attack surface and very fast boot; no general-purpose flexibility
MirageOS, 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
Event
Source
Synchronous?
Example
System call (trap)
The running program, deliberately
Yes — you asked for it
read(), open(), fork()
Exception (fault)
The running program, accidentally
Yes — caused by the current instruction
Page fault, divide by zero, invalid opcode
Interrupt
Hardware, asynchronously
No — unrelated to what's running
Timer 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
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); } // childelse 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 process
Private to each thread
Code (text) segment
Stack
Heap — the source of every data race
Registers, including the program counter
Global and static variables
Thread ID
Open file descriptors
Signal mask
Current working directory, PID, signal handlers
Thread-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
Model
Mapping
Tradeoff
Many-to-one (pure user threads)
N user threads → 1 kernel thread
Switching 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-one
1 user thread → 1 kernel thread
True 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 threads
Best 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
Model
Good for
Costs
Process per task
Untrusted or crash-prone work; needing a real fault and security boundary
Highest memory and switch cost; IPC to communicate
Thread per task
CPU-bound parallelism; moderate concurrency with blocking calls
Shared-memory bugs; ~8 MB virtual stack each; scheduler pressure past a few thousand
Thread pool
Bounded parallelism for short tasks — the sane default
A 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 thread
One blocking or CPU-heavy call stalls everything; harder control flow
Green threads / coroutines
High concurrency with straight-line code
Needs 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
An interrupt or trap enters the kernel (timer tick, syscall that blocks, or a
higher-priority task waking).
Save the outgoing task's registers, program counter and stack pointer into its
PCB.
The scheduler picks the next task.
If it's a different process, switch address spaces — load the new page
table root (CR3 on x86). This is the expensive part.
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
Algorithm
Picks
Preemptive
Wins
Loses
FCFS
Earliest arrival
No
Trivially simple and fair in order
Convoy effect: one long job makes everyone wait. Terrible response time.
SJF
Shortest burst
No
Provably optimal average waiting time among non-preemptive schedules
Needs the future (burst lengths are unknown); starves long jobs
SRTF
Shortest remaining
Yes
Optimal average waiting time overall
Same clairvoyance problem, plus heavy switching and worse starvation
Round robin
Next in queue, for one quantum
Yes
Bounded response time, no starvation — the right shape for interactive work
Poor average turnaround; throughput falls as the quantum shrinks
Priority
Highest priority
Either
Expresses what matters to you
Starvation of low priority. Needs ageing (raise priority over time).
HRRN
Highest response ratio (W+B)/B
No
Favours short jobs but ages long ones in — no starvation
Still 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.75P3 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.00At 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.00Best 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.50Worst 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.
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
Class
Policy
Behaviour
Real-time
SCHED_FIFO
Runs until it blocks or yields. No quantum. A busy loop here freezes everything below it.
Real-time
SCHED_RR
Like FIFO but round-robins among equal priorities.
Deadline
SCHED_DEADLINE
Earliest-deadline-first with an admission test — the kernel refuses tasks it can't guarantee.
Normal
SCHED_OTHER
CFS. Everything you normally run.
Idle
SCHED_IDLE
Runs 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 runnableplus 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."