CS Prep Hub

Operating Systems

A zero-to-hero reference on operating systems for technical interviews: how processes and threads are managed, how the CPU is scheduled, how memory is virtualized, how concurrent code stays correct, and how the pieces fit together in a real kernel like Linux. Work through it top to bottom, or jump straight to a topic from the sidebar.

Introduction to OS

What Does an OS Do?

An operating system sits between the raw hardware and the applications you write. It has two jobs that are in tension with each other: act as a resource manager (deciding who gets the CPU, memory, disk, and devices, and for how long) and act as an abstraction layer (hiding the ugly details of hardware behind clean interfaces like files, sockets, and processes).

  • Process management — creating, scheduling, and terminating processes/threads; providing each process the illusion of owning the CPU.
  • Memory management — giving each process the illusion of a large, private, contiguous address space via virtual memory.
  • File & storage management — organizing bytes on disk into files and directories, and managing free space.
  • I/O & device management — mediating access to devices via drivers, buffering, and interrupt handling.
  • Protection & security — isolating processes from each other and from the kernel via privilege levels and permissions.
  • Networking — implementing protocol stacks and exposing sockets to user space.
💡 Interview Tip

If asked "what is an OS?" in one line: "Software that virtualizes hardware resources (CPU, memory, devices) and multiplexes them safely among competing programs." Then mention the abstraction (process, virtual memory, file) and the enforcement mechanism (kernel/user mode) — that's usually what the interviewer wants to hear next.

Types of OS

TypeDescriptionExample
Batch OSJobs are collected, grouped, and run without user interaction; output collected later. No overlap between CPU and I/O of a job.Early IBM mainframes
Multiprogrammed OSMultiple jobs kept in memory at once; CPU switches to another job when one blocks on I/O, keeping CPU utilization high.Early Unix
Time-Sharing OSMultiprogramming + fast context switches so multiple interactive users feel like they each have the machine (response time in ms).Unix, Windows, Linux desktop
Distributed OSManages a collection of independent, networked computers and makes them appear as a single system to the user.Google's Borg/Google File System-backed clusters
Real-Time OS (RTOS)Correctness depends on both logical result and the time it was produced. Hard RTOS = missing a deadline is a system failure; Soft RTOS = missed deadlines degrade quality but aren't fatal.VxWorks (hard), Linux with PREEMPT_RT patches (soft/near-hard)
Embedded OSRuns on dedicated hardware with a specific function, typically resource-constrained (memory/power).FreeRTOS, embedded Linux in routers
Network OSProvides file/printer sharing and user management across machines that otherwise run their own local OS.Novell NetWare, Windows Server

System Calls vs Library Calls

A system call is a request from user-space code to the kernel to perform a privileged operation — reading a file, allocating memory pages, creating a process. It crosses the user/kernel boundary via a trap instruction (e.g. syscall on x86-64), which costs a mode switch and is measurably more expensive than a normal function call. A library call is an ordinary function call resolved entirely in user space (e.g. strlen, `std::sort`) — it may or may not invoke a system call internally. printf, for instance, is a library call that buffers output in user space and eventually issues a write() system call when the buffer flushes.

System CallLibrary Call
Executed inKernel modeUser mode
CostHigh (mode switch, TLB/cache effects)Low (regular call)
Examplesread(), write(), fork(), mmap()malloc() (wraps brk/mmap only sometimes), printf(), strcpy()
PortabilityOS-specific ABICan be pure, portable computation

Kernel Mode vs User Mode

CPUs implement at least two privilege levels enforced by a hardware mode bit (x86 calls these "rings", 0 = most privileged). In user mode, code cannot execute privileged instructions (like changing page tables, halting the CPU, or directly touching device registers) — attempting to do so triggers a trap into the kernel. In kernel mode, the OS has unrestricted hardware access. The only way to move from user to kernel mode is through a controlled entry point: a system call, an interrupt, or an exception (like a page fault or divide-by-zero) — never an arbitrary jump. This is the fundamental protection mechanism that keeps one buggy or malicious process from corrupting another process or the OS itself.

⚠️ Common Pitfall

Don't say "kernel mode is faster." It isn't inherently faster — it's more privileged. The reason syscalls are slow is the transition (trap, register save, potential TLB/cache pollution), not because kernel-mode instructions execute differently.

Monolithic vs Microkernel

Monolithic KernelMicrokernel
DesignEntire OS (scheduler, file system, drivers, network stack) runs in kernel space as one binary.Kernel provides only the bare minimum — IPC, basic scheduling, minimal address-space management. Drivers, file systems, network stacks run as user-space servers.
PerformanceFast — everything is a direct function call within kernel space.Slower — services communicate via IPC/message passing, which costs context switches.
ReliabilityA bug in any driver can crash the whole kernel.A crashing driver/service can often be restarted without taking down the kernel.
ExamplesLinux, traditional Unix, MS-DOSMinix, QNX, seL4
HybridWindows NT and macOS (XNU) are hybrids — a microkernel-flavored design that runs most services in kernel space for performance, blurring the line.
Q: Why does Linux, a monolithic kernel, support loadable kernel modules (LKMs) — doesn't that contradict "monolithic"?

No — LKMs are still linked into the same kernel address space and run with full kernel privilege once loaded; they're just loaded/unloaded dynamically instead of being compiled statically into the kernel image. The defining trait of "monolithic" is that all this code shares one address space and one privilege level, not how it's packaged at build time.

Q: What is a hypervisor, and how does it relate to kernel mode?

A hypervisor (Type 1/bare-metal, e.g. Xen, ESXi, or Type 2/hosted, e.g. VirtualBox) runs below the guest OS's kernel, at an even more privileged CPU level (e.g. VMX root mode on Intel VT-x). Each guest OS still thinks it's running in kernel mode, but the hypervisor intercepts and virtualizes privileged operations, giving each guest an isolated virtual machine.

Q: Give a concrete example of the user-mode → kernel-mode transition.

Calling read(fd, buf, n) in C compiles to loading the syscall number and arguments into registers and executing the syscall instruction. This traps into the kernel, the CPU switches to ring 0, the kernel's syscall dispatcher looks up the handler by syscall number, executes it (e.g. copies data from the page cache into the user buffer), then executes sysret to return to ring 3 with the result in a register.

Processes

Program vs Process vs Thread

A program is a passive entity — an executable file on disk containing instructions and static data. A process is a program in execution: an active entity with its own address space, open file descriptors, and execution state. A thread is a unit of execution within a process; all threads of a process share the same address space, heap, and file descriptors, but each has its own stack, registers, and program counter.

ProgramProcessThread
StatePassive (bytes on disk)Active (running/waiting)Active, part of a process
MemoryN/AOwns a private address spaceShares process's address space
Creation costN/AExpensive (new address space, PCB)Cheap (new stack + registers)
CommunicationN/ANeeds IPCDirect via shared memory

Process Control Block (PCB)

The kernel represents every process with a data structure — in Linux, struct task_struct — commonly called the Process Control Block. It's the process's identity as far as the OS is concerned.

FieldPurpose
PID / PPIDProcess ID and parent's process ID
Process stateNew, Ready, Running, Waiting, Terminated
Program counterAddress of the next instruction to execute
CPU registersSaved register values, restored on context switch
CPU scheduling infoPriority, scheduling queue pointers, time slice used
Memory management infoPage table pointer / base-limit registers, segment tables
Accounting infoCPU time used, time limits, process number
I/O status infoOpen file descriptor table, list of allocated devices
💡 Interview Tip

When asked "what happens on a context switch," the honest answer is "the kernel saves the current process's registers/PC into its PCB, picks the next process from the ready queue, and loads that process's saved state from its PCB." Grounding the answer in the PCB shows you understand it's a real data structure, not magic.

Process States

A process moves through a well-defined set of states over its lifetime:

State diagram
            admitted             scheduler dispatch
   NEW  ─────────────────▶ READY ─────────────────▶ RUNNING
                              ▲                         │
                              │   I/O or event           │ exit
                    interrupt │   completion              ▼
                              │                     TERMINATED
                              │                         ▲
                              │      I/O or event wait   │
                          WAITING ◀──────────────────────┘
                                     (running process
                                      blocks on I/O)
  • New — process is being created (PCB allocated, but not yet admitted to the ready queue).
  • Ready — process has everything it needs except the CPU; waiting in the ready queue for the scheduler.
  • Running — instructions are actually executing on a CPU core.
  • Waiting/Blocked — process is waiting on an event (I/O completion, a lock, a signal) and cannot make progress even if given the CPU.
  • Terminated — process has finished execution; its PCB may briefly remain (zombie) until the parent collects the exit status.

Context Switching

A context switch is the act of saving the state of the currently running process and restoring the state of another. It happens on: a timer interrupt (quantum expiry), a higher-priority process becoming ready, a blocking system call, or an interrupt. Context switching is pure overhead — no useful work is done during the switch itself. Costs include:

  • Saving/restoring registers and program counter (cheap, microseconds).
  • Switching page tables (updating CR3 on x86) — flushes TLB entries not tagged with an ASID/PCID, causing a burst of TLB misses afterward.
  • Cache pollution — the new process's working set evicts the old process's data from L1/L2 cache, causing cache misses as it warms back up.

This is exactly why thread context switches (same address space, no page-table switch) are cheaper than process context switches.

fork() / exec() & Copy-on-Write Intermediate

Unix splits "create a process" and "run a different program" into two separate calls, which is unusual compared to Windows' single CreateProcess — but it's extremely powerful because it lets the shell set up file descriptors (for redirection/pipes) between the fork and the exec.

C
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();          // duplicate the calling process

    if (pid < 0) {
        perror("fork failed");
    } else if (pid == 0) {
        // Child: pid == 0 here
        printf("child: about to exec\n");
        execlp("/bin/ls", "ls", "-l", NULL);   // replaces child's image
        perror("exec failed");   // only reached if execlp fails
        _exit(1);
    } else {
        // Parent: pid == child's real PID here
        int status;
        waitpid(pid, &status, 0);   // reap child, avoid zombie
        printf("child exited with status %d\n", WEXITSTATUS(status));
    }
    return 0;
}

fork() creates a near-identical copy of the calling process (same code, data, open file descriptors) and returns twice: 0 in the child, the child's PID in the parent. Naively copying the entire address space would be wasteful, especially since most forks are immediately followed by an exec() that throws the copy away. So modern Unix kernels use copy-on-write (COW): fork() duplicates only the page tables, marking every page read-only and shared between parent and child. The first time either process writes to a shared page, the CPU raises a page fault, and the kernel transparently allocates a private copy of just that one page for the writer. If neither process writes before the child execs, zero data pages are ever copied.

exec() family (execve, execlp, execvp, ...) replaces the calling process's address space, code, and data with a new program, keeping the same PID and open file descriptors (unless marked close-on-exec).

💡 Interview Tip

"Why is fork()+exec() split into two calls instead of one spawn()?" — because it lets the parent (typically a shell) modify the child's environment between the two calls: redirect stdin/stdout to a pipe, change the working directory, drop privileges, set resource limits — all using ordinary system calls on the child, before the new program's code ever runs.

Zombie & Orphan Processes

A zombie process has terminated (called exit()), but its PCB/exit-status entry still occupies a slot in the process table because its parent hasn't called wait()/waitpid() to collect it yet. Zombies consume no memory or CPU beyond the PCB slot, but if a parent leaks them indefinitely (never reaping), it can exhaust the system's PID table.

An orphan process is one whose parent terminated before it did. Orphans are automatically re-parented to init (PID 1) or a subreaper (e.g. systemd), which periodically calls wait() on its children specifically to reap zombies. So an orphan is not a leak — it's a zombie you'd actually have to worry about if it weren't for re-parenting.

ZombieOrphan
CauseParent alive but hasn't called wait()Parent died before child
Still running?No — already terminatedYes — still executing
FixParent calls wait()/waitpid(), or dies (re-parented, then reaped by init)Automatically re-parented to init/systemd
Q: How would you kill a zombie process?

You can't kill -9 a zombie — it isn't running, it has no code to receive a signal. The only fix is to make its parent call wait() (send the parent SIGCHLD, or fix the parent's code), or kill the parent, which orphans the zombie to init, which reaps it immediately.

Q: What does fork() return, and in which process?

It returns twice from a single call: 0 in the newly created child, and the child's PID (a positive integer) in the parent. A negative return value means the fork failed and no child was created.

Q: If a child process modifies a global variable after fork(), does the parent see the change?

No. After fork(), parent and child have logically independent address spaces (copy-on-write makes this efficient, but semantically it's a full copy). A write in the child triggers a COW page fault that gives the child its own private copy of that page — the parent's page is untouched.

Q: What's the difference between exit() and _exit()?

exit() (library call) flushes stdio buffers and runs registered atexit() handlers before calling the _exit() system call, which terminates the process immediately at the kernel level with no further user-space cleanup. Code right after a failed exec() in a forked child typically calls _exit() to avoid double-flushing the parent's inherited stdio buffers.

CPU Scheduling

Scheduling Criteria

MetricDefinitionGoal
CPU utilizationFraction of time the CPU is doing useful work (not idle)Maximize
ThroughputNumber of processes completed per unit timeMaximize
Turnaround timeCompletion time − arrival time (total time a process spends in the system)Minimize
Waiting timeTime spent in the ready queue (turnaround − burst time)Minimize
Response timeTime from arrival until the first response/CPU burst (not completion) — critical for interactive systemsMinimize

Long / Short / Medium-Term Schedulers

  • Long-term scheduler (admission scheduler) — decides which jobs are admitted into the ready queue from the job pool. Controls the degree of multiprogramming. Runs infrequently.
  • Short-term scheduler (CPU scheduler / dispatcher) — decides which ready process runs next on the CPU. Runs very frequently (every few milliseconds), so it must be fast.
  • Medium-term scheduler — swaps processes out of memory (to disk) to reduce the degree of multiprogramming when memory is under pressure, and swaps them back in later. Sits between "in memory, ready" and "suspended."

FCFS (First-Come, First-Served) Basic

Non-preemptive; processes run strictly in arrival order. Simple, but suffers the convoy effect: a single long CPU-bound process at the front makes every short process behind it wait, tanking average waiting time.

ProcessBurst TimeStartFinishWaiting Time
P1240240
P23242724
P33273027
P43303330

Average waiting time = (0 + 24 + 27 + 30) / 4 = 20.25. Reordering to P2, P3, P4, P1 (shortest first) would drop this dramatically — which is exactly the motivation for SJF below.

SJF — Shortest Job First (Preemptive & Non-Preemptive) Intermediate

Picks the process with the smallest CPU burst next. Provably optimal for minimizing average waiting time among non-preemptive algorithms, but requires knowing burst times in advance — in practice these are only estimated (e.g. via exponential averaging of past bursts). The preemptive variant is called SRTF (Shortest Remaining Time First): a newly arrived process with a shorter remaining burst can preempt the currently running one.

ProcessBurst TimeOrder RunWaiting Time
P231st (0–3)0
P332nd (3–6)3
P433rd (6–9)6
P1244th (9–33)9

Average waiting time = (0 + 3 + 6 + 9) / 4 = 4.5 — far better than FCFS's 20.25 on the same workload. The catch: starvation — a long process can be perpetually skipped if short processes keep arriving.

Priority Scheduling

Each process gets a priority number; the CPU is allocated to the highest-priority ready process (convention varies — often lower number = higher priority). Can be preemptive or non-preemptive. Same starvation risk as SJF (a low-priority process may never run) — solved with aging: gradually increase the priority of processes that have waited a long time.

⚠️ Common Pitfall

Don't confuse "priority scheduling starvation" with "priority inversion" — they're different problems. Starvation is a low-priority process never getting the CPU because higher-priority processes keep arriving. Priority inversion (covered later) is a high-priority process being blocked by a lower-priority one holding a lock, regardless of scheduling order. See the Priority Inversion section.

Round Robin Intermediate

Preemptive, time-sliced version of FCFS: each process gets a fixed time quantum; if it doesn't finish, it's preempted and moved to the back of the ready queue. Fair and good for response time, but throughput depends heavily on quantum size — too small and context-switch overhead dominates; too large and it degenerates into FCFS.

Worked example: P1 = 24, P2 = 3, P3 = 3, quantum = 4.

Gantt chart
| P1 | P2 | P3 | P1 | P1 | P1 | P1 | P1 |
0    4    7   10   14   18   22   26   30

P2 and P3 finish in their first slice (burst ≤ quantum). P1 needs 6 total slices of work (24/4). Waiting times: P1 = 30 − 24 = 6, P2 = 7 − 3 = 4, P3 = 10 − 3 = 7. Average waiting time = (6 + 4 + 7) / 3 = 5.67.

Multilevel Queue

The ready queue is split into several separate queues by process category (e.g. "foreground/interactive" and "background/batch"), each with its own scheduling algorithm (e.g. RR for foreground, FCFS for background), plus a fixed policy for scheduling between queues (e.g. strict priority, or a time-slice split like 80% CPU to foreground, 20% to background). Processes are permanently assigned to a queue at creation — there's no movement between queues, which is the main weakness this algorithm has relative to the next one.

Multilevel Feedback Queue (MLFQ)

The most general CPU-scheduling algorithm: like multilevel queue, but processes can move between queues based on observed behavior. Typical setup: multiple queues with increasing quantum sizes and decreasing priority. A new process starts in the highest-priority (shortest quantum) queue. If it uses its entire quantum (behaves CPU-bound), it's demoted to a lower-priority, longer-quantum queue. If it blocks on I/O before its quantum expires (behaves interactively), it stays at the same or is promoted to a higher-priority level. This approximates SJF without requiring the OS to know burst times in advance — interactive/short jobs naturally stay in the fast queues, CPU-bound jobs sink to the slow ones. To prevent starvation of long jobs, MLFQ implementations periodically boost all processes back to the top queue.

Algorithm Comparison

AlgorithmPreemptive?Starvation RiskOverheadBest For
FCFSNoNo, but convoy effectVery lowBatch systems
SJF / SRTFSRTF: yesYes (long jobs)Requires burst-time estimationBatch, when burst times known/predictable
PriorityEitherYes (low priority)LowSystems with explicit job importance
Round RobinYesNoMedium (quantum-dependent context switches)Time-sharing, interactive systems
Multilevel QueueYesDepends on inter-queue policyMediumSystems with clearly separable job classes
MLFQYesNo (with aging/boost)Higher (adaptive bookkeeping)General-purpose OS (Windows, historically BSD)

Linux CFS (Completely Fair Scheduler)

Linux's default scheduler since 2.6.23 (being succeeded by EEVDF in newer kernels, but CFS concepts are still the standard interview answer). Instead of fixed time slices, CFS models an idealized "perfectly fair" CPU that gives every runnable task an equal, infinitesimally small share of the CPU simultaneously, then approximates it in practice. Each task accumulates vruntime (virtual runtime) proportional to the CPU time it has actually consumed, weighted by its nice value (lower nice = higher weight = vruntime accumulates slower = gets scheduled more). Runnable tasks are kept in a red-black tree keyed by vruntime; the scheduler always picks the leftmost node (smallest vruntime = most "owed" CPU time). Running a task increases its vruntime, which naturally moves it rightward in the tree, letting other tasks take a turn — no fixed quantum bookkeeping needed the way RR requires.

Q: Why is SJF called "optimal" if it can starve processes?

It's optimal specifically for minimizing average waiting time among the set of non-preemptive scheduling algorithms — that's the narrow claim, provable by an exchange argument (swapping two adjacent out-of-order jobs in any schedule reduces average wait). It says nothing about fairness or worst-case waiting time for any individual process, which is where starvation lives.

Q: What happens if the RR quantum is set larger than the longest burst time in the system?

Round Robin degenerates into FCFS — every process finishes within its first slice, so preemption never actually triggers before completion.

Q: Why does a very small RR quantum hurt performance despite improving response time?

Each quantum expiry triggers a context switch, which is pure overhead (register save/restore, TLB/cache pollution). If the quantum approaches the cost of a context switch itself, the CPU spends a large fraction of its time switching rather than executing — throughput collapses even though response time looks great on paper.

Q: How does CFS's red-black tree avoid needing a fixed quantum like Round Robin?

CFS computes a task's "ideal" slice dynamically from the number of runnable tasks and a target latency, rather than using one global fixed quantum. Because scheduling decisions are driven by comparing accumulated vruntime rather than counting down a shared quantum, it naturally adapts fairness as the runnable task count changes, without the queue-migration bookkeeping MLFQ needs.

Threads & Concurrency

User-Level vs Kernel-Level Threads

User-Level ThreadsKernel-Level Threads
Managed byA user-space threading library (no kernel involvement)The OS kernel directly
Creation/switch costVery cheap — no system call, no mode switchMore expensive — goes through the kernel scheduler
Blocking syscallBlocks the entire process (kernel doesn't know about other user threads)Only that one thread blocks; others keep running
Multicore parallelismCannot run in true parallel on multiple cores (kernel schedules the process as one unit)Can run truly in parallel across cores
ExampleOld green-thread libraries, early Java threads (pre-1.2 "green threads")Linux/Windows/macOS native threads (pthreads mapped 1:1)

Multithreading Models

These describe how user-level threads are mapped onto kernel-level threads:

  • Many-to-One — many user threads map to a single kernel thread. Fast creation/switching, but one blocking syscall blocks all threads, and no real parallelism across cores. (Old green threads, early Solaris Green Threads.)
  • One-to-One — each user thread maps to its own kernel thread. True parallelism, a blocking syscall only blocks that thread — but thread creation is as expensive as kernel thread creation, and OSes often cap the number of kernel threads. (Linux pthreads, Windows threads.)
  • Many-to-Manym user threads multiplexed onto n kernel threads (n ≤ m). Combines flexibility of many-to-one with the parallelism of one-to-one; the runtime/kernel can create as many kernel threads as needed up to hardware limits. (Historic Solaris; conceptually similar to how Go's goroutine scheduler multiplexes goroutines onto OS threads, though Go's is a user-space M:N scheduler layered on top of one-to-one OS threads rather than an OS-level M:N model.)

Thread Pools

Creating a new OS thread per task is expensive (kernel thread creation + stack allocation) and an unbounded number of threads can exhaust memory or cause excessive context-switch thrashing. A thread pool pre-creates a fixed (or bounded, dynamically-sized) set of worker threads that pull tasks off a shared queue, amortizing thread-creation cost across many tasks and capping concurrency to something the hardware can actually handle in parallel (commonly sized around the core count for CPU-bound work).

C++
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>

class ThreadPool {
public:
    explicit ThreadPool(size_t n) {
        for (size_t i = 0; i < n; ++i) {
            workers_.emplace_back([this] { workerLoop(); });
        }
    }

    void submit(std::function<void()> task) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            tasks_.push(std::move(task));
        }
        cv_.notify_one();
    }

    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto &t : workers_) t.join();
    }

private:
    void workerLoop() {
        while (true) {
            std::function<void()> task;
            {
                std::unique_lock<std::mutex> lock(mtx_);
                cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
                if (stop_ && tasks_.empty()) return;
                task = std::move(tasks_.front());
                tasks_.pop();
            }
            task();   // run outside the lock
        }
    }

    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
};

Benefits & Costs of Multithreading

BenefitsCosts
Responsiveness — one thread can serve UI/requests while another does heavy workSynchronization complexity — races, deadlocks, correctness bugs
Resource sharing — threads share the process's memory/files without needing IPCLock contention — threads can serialize on shared locks, capping speedup
Economy — thread creation/context switch is far cheaper than process creationHarder debugging — non-deterministic bugs, heisenbugs
Scalability — can exploit multiple cores for true parallel speedupDiminishing/negative returns beyond core count, and false-sharing/cache effects

Green Threads

Green threads are threads scheduled entirely by a runtime/library in user space rather than by the OS kernel — essentially the many-to-one or many-to-many model applied by a language runtime. They enable extremely cheap "threads" (sometimes millions of them) since creation/switching avoids syscalls entirely. Examples: original Java green threads (pre-JDK 1.2), Ruby's fibers, Go's goroutines (M:N over OS threads), Erlang's lightweight processes. The tradeoff is the same as many-to-one/many-to-many: the runtime needs cooperative scheduling points or must intercept blocking syscalls (e.g. via a non-blocking I/O + event loop underneath) to avoid stalling every green thread when one does blocking I/O.

Q: Why can't many-to-one threading utilize multiple CPU cores?

Because the kernel only sees a single kernel-level thread (schedulable entity) per process. Even with 100 user threads inside, the kernel schedules that one kernel thread onto one core at a time; the user-space scheduler multiplexes the 100 user threads onto that single core only.

Q: What's the difference between concurrency and parallelism?

Concurrency is about structure — dealing with multiple tasks that are in progress at overlapping times (they may or may not literally execute at the same instant; a single core can be concurrent via time-slicing). Parallelism is about execution — multiple tasks physically running at the same instant, which requires multiple cores. You can have concurrency without parallelism (single-core time-sharing) but not parallelism without concurrency.

Q: Why might increasing thread count beyond the number of CPU cores hurt a CPU-bound workload?

Once every core is saturated with CPU-bound work, additional threads only add context-switch overhead and cache/TLB thrashing between threads competing for the same cores, without adding any real parallel capacity — throughput can actually decrease.

Process Synchronization

Race Conditions

A race condition occurs when the correctness of a program depends on the relative timing/interleaving of concurrent operations on shared data. The classic example: two threads both executing counter++ on a shared integer.

C++
int counter = 0;

void increment() {
    counter++;   // NOT atomic! Compiles roughly to:
                 //   load  reg, counter
                 //   add   reg, 1
                 //   store counter, reg
}

If two threads interleave load-add-store, both can read counter = 5, both compute 6, both store 6 — one increment is silently lost. This is why counter++ on a plain int shared across threads is a bug even though it looks like one operation in source code.

Critical Section Problem

A critical section is a code segment that accesses shared resources and must not be executed by more than one thread/process concurrently. Any correct solution must satisfy three requirements:

  • Mutual exclusion — no two processes may be inside their critical sections simultaneously.
  • Progress — if no process is in its critical section, and some processes wish to enter, only those processes not in their remainder section can participate in deciding who enters next, and this decision can't be postponed indefinitely.
  • Bounded waiting — there's a limit on how many times other processes can enter their critical section after a process has requested entry and before that request is granted (prevents starvation).

Peterson's Solution Intermediate

A classic software-only solution for two processes, using a flag[] array and a turn variable.

C
volatile int flag[2] = {0, 0};
volatile int turn;

// Process 0
void enter_critical_p0() {
    flag[0] = 1;
    turn = 1;
    while (flag[1] && turn == 1) { /* busy wait */ }
    // critical section
}
void exit_critical_p0() { flag[0] = 0; }

// Process 1
void enter_critical_p1() {
    flag[1] = 1;
    turn = 0;
    while (flag[0] && turn == 0) { /* busy wait */ }
    // critical section
}
void exit_critical_p1() { flag[1] = 0; }

Each process announces intent (flag[i] = 1), then politely yields the "turn" to the other. It satisfies all three critical-section requirements for exactly two processes. It's mostly of historical/theoretical interest today: it relies on sequential memory consistency, which modern compilers and out-of-order CPUs don't guarantee without explicit memory barriers — a compiler is free to reorder those stores/loads, and Peterson's algorithm silently breaks on real hardware without volatile/atomics with proper memory ordering.

⚠️ Warning

Never present Peterson's solution as production-ready. On modern multicore CPUs with weak memory models, it needs explicit memory fences to actually work — that's exactly why real systems use hardware-backed atomics and OS-provided primitives (mutexes, semaphores) instead of hand-rolled flag-based algorithms.

Mutex vs Semaphore vs Monitor

MutexSemaphoreMonitor
Value rangeBinary lock/unlock, owned by the locking threadInteger counter (binary = 0/1, counting = 0..N)High-level construct: a lock + condition variables built into the language
OwnershipYes — only the thread that locked it may unlock itNo — any thread can signal/post, regardless of who waitedYes (via the implicit lock)
Use caseProtecting a critical section (mutual exclusion only)Signaling between threads, bounding access to N resourcesEncapsulated synchronized objects (Java synchronized, C++ classes using std::mutex + std::condition_variable internally)
Examplesstd::mutex, pthread_mutexsem_t, std::counting_semaphore (C++20)Java's synchronized/wait/notify, Python's threading primitives
⚠️ Common Pitfall

"Mutex and binary semaphore are the same thing" is a common but imprecise claim. The key difference is ownership: a mutex can only be unlocked by the thread that locked it (many implementations enforce this and will error/UB otherwise), while a binary semaphore has no ownership concept — thread A can wait() and thread B can post() the same semaphore. That makes semaphores usable for signaling between threads, which a mutex is not designed for.

Producer-Consumer Problem Intermediate

Also called the bounded-buffer problem: producers generate items into a fixed-size buffer, consumers remove them. Need to block producers when the buffer is full, block consumers when it's empty, and protect the buffer index/count from concurrent access.

C (semaphores)
#define N 8
sem_t empty_slots, filled_slots;
pthread_mutex_t mtx;
int buffer[N], in = 0, out = 0;

void init() {
    sem_init(&empty_slots, 0, N);   // N empty slots initially
    sem_init(&filled_slots, 0, 0);  // 0 filled slots initially
    pthread_mutex_init(&mtx, NULL);
}

void producer(int item) {
    sem_wait(&empty_slots);         // wait for a free slot
    pthread_mutex_lock(&mtx);
    buffer[in] = item;
    in = (in + 1) % N;
    pthread_mutex_unlock(&mtx);
    sem_post(&filled_slots);        // signal: one more item available
}

int consumer() {
    sem_wait(&filled_slots);        // wait for an item
    pthread_mutex_lock(&mtx);
    int item = buffer[out];
    out = (out + 1) % N;
    pthread_mutex_unlock(&mtx);
    sem_post(&empty_slots);         // signal: one more free slot
    return item;
}

The two counting semaphores (empty_slots, filled_slots) handle blocking/signaling; the mutex handles mutual exclusion on the shared indices. A full C++ std::mutex + std::condition_variable implementation appears later in Practical Coding.

Readers-Writers Problem Intermediate

Multiple readers may access shared data concurrently (reads don't conflict), but a writer needs exclusive access (no readers or other writers at the same time). The "first" readers-writers problem favors readers (a writer can starve if readers keep arriving); the "second" favors writers. A fair solution needs to bound both.

C (reader-preference variant)
int read_count = 0;
pthread_mutex_t read_count_mtx;   // protects read_count
sem_t resource;                   // guards the actual data (writers hold this)

void reader() {
    pthread_mutex_lock(&read_count_mtx);
    read_count++;
    if (read_count == 1) sem_wait(&resource);   // first reader locks out writers
    pthread_mutex_unlock(&read_count_mtx);

    // ---- read shared data ----

    pthread_mutex_lock(&read_count_mtx);
    read_count--;
    if (read_count == 0) sem_post(&resource);    // last reader lets writers in
    pthread_mutex_unlock(&read_count_mtx);
}

void writer() {
    sem_wait(&resource);
    // ---- write shared data ----
    sem_post(&resource);
}

In production code, use a purpose-built primitive instead of hand-rolling this: POSIX pthread_rwlock_t, or C++17 std::shared_mutex (lock_shared() for readers, lock() for writers).

Dining Philosophers Problem Advanced

Five philosophers sit at a round table with five forks (one between each adjacent pair). Each philosopher needs both their left and right fork to eat. If every philosopher simultaneously picks up their left fork, none can get a right fork — deadlock via circular wait. This models resource allocation with cyclic dependencies. Standard fixes:

  • Resource ordering — number the forks; every philosopher picks up the lower-numbered fork first. Breaks the circular-wait condition (see Deadlock Prevention).
  • Asymmetry — have one philosopher (arbitrarily) pick up right-then-left while all others pick up left-then-right, breaking the symmetric cycle.
  • Arbitrator/waiter — a mutex/semaphore that only allows N−1 philosophers to attempt picking up forks simultaneously, guaranteeing at least one can always complete.
C (resource-ordering fix)
pthread_mutex_t fork_mtx[5];

void philosopher(int i) {
    int left = i;
    int right = (i + 1) % 5;
    int first  = (left < right) ? left  : right;   // lower-numbered first
    int second = (left < right) ? right : left;

    pthread_mutex_lock(&fork_mtx[first]);
    pthread_mutex_lock(&fork_mtx[second]);

    // ---- eat ----

    pthread_mutex_unlock(&fork_mtx[second]);
    pthread_mutex_unlock(&fork_mtx[first]);
}

Sleeping Barber Problem Advanced

A barbershop has one barber, one barber chair, and N waiting chairs. If there are no customers, the barber sleeps. A customer who arrives while the barber sleeps wakes him up; a customer who arrives while the barber is busy sits in a waiting chair if one is free, or leaves if all are full. This models a bounded-buffer producer-consumer scenario with a twist: it's a classic exercise in expressing "wake a sleeping server" and "reject when full" correctly with semaphores.

C
#define CHAIRS 4
sem_t customers;      // count of waiting customers, barber waits on this
sem_t barber_ready;   // signals a customer that the barber is ready
pthread_mutex_t mtx;  // protects waiting count
int waiting = 0;

void barber() {
    while (1) {
        sem_wait(&customers);      // sleep until a customer arrives
        pthread_mutex_lock(&mtx);
        waiting--;
        pthread_mutex_unlock(&mtx);
        sem_post(&barber_ready);   // invite the customer to the chair
        // ---- cut hair ----
    }
}

void customer() {
    pthread_mutex_lock(&mtx);
    if (waiting < CHAIRS) {
        waiting++;
        pthread_mutex_unlock(&mtx);
        sem_post(&customers);      // notify barber
        sem_wait(&barber_ready);   // wait for barber's chair
        // ---- get haircut ----
    } else {
        pthread_mutex_unlock(&mtx);
        // no chairs free — leave
    }
}
Q: Why does Peterson's solution fail on modern multicore hardware without extra work?

Both the compiler and the CPU are allowed to reorder independent-looking memory operations for performance, and each core may cache values instead of immediately publishing them to main memory. Peterson's algorithm relies on the write to flag[i] being visible to the other core before the read of flag[j]/turn — a guarantee not provided by default on architectures with weak memory models (e.g. ARM) or by an optimizing compiler unless you insert explicit memory barriers or use properly-ordered atomics.

Q: In the producer-consumer solution, why is the mutex separate from the two semaphores?

The semaphores handle blocking on capacity (wait if full/empty) — a fundamentally different job from mutual exclusion on the shared indices (in/out), which the mutex handles. If you tried to use a single semaphore for both, you'd either lose the ability to block correctly on capacity, or allow two producers to race on updating in simultaneously.

Q: What would happen in the producer-consumer code if sem_wait(&empty_slots) and pthread_mutex_lock(&mtx) were swapped in order in the producer?

You'd risk deadlock: if the producer locks the mutex first and then blocks on sem_wait(&empty_slots) because the buffer is full, it holds the mutex while sleeping — and the consumer, which needs that same mutex to remove an item and free a slot, can never acquire it. Always acquire the semaphore that might block before the mutex that must not be held while blocked.

Q: How does resource ordering solve dining philosophers, in terms of the four deadlock conditions?

It eliminates the circular wait condition. If every philosopher must acquire the lower-numbered fork before the higher-numbered one, there's no cycle of philosophers each waiting on a fork held by the next philosopher in the cycle — the philosopher holding the highest-numbered fork in any potential cycle would have needed to acquire a lower-numbered fork first, so at least one philosopher in the ring can always get both forks.

Q: Why can't you just make the entire critical section in these classic problems a single global lock?

You could, and it would be correct (mutual exclusion holds), but it would serialize all operations — no two readers could read concurrently, no producer and consumer could work on different buffer slots concurrently — destroying the performance benefit of using separate synchronization primitives in the first place. The whole point of these classic problems is expressing precise, minimal synchronization rather than reaching for one big lock.

Deadlocks

Four Necessary Conditions (Coffman Conditions)

A deadlock can only occur if all four of these hold simultaneously:

  1. Mutual exclusion — at least one resource is held in a non-shareable mode.
  2. Hold and wait — a process holding at least one resource is waiting to acquire additional resources held by others.
  3. No preemption — a resource can only be released voluntarily by the process holding it, never forcibly taken away.
  4. Circular wait — a set of processes {P0, P1, ..., Pn} exists such that P0 waits on a resource held by P1, P1 waits on P2, ..., Pn waits on P0.

Breaking any one of these four is sufficient to prevent deadlock — this is exactly the strategy behind deadlock prevention below.

Resource Allocation Graph

A directed graph with two types of nodes (processes, resources) and two types of edges: a request edge (P → R, process requests resource) and an assignment edge (R → P, resource is held by process). If the graph contains no cycle, no deadlock exists. If it contains a cycle: with single-instance resources, a cycle always means deadlock; with multi-instance resources, a cycle is necessary but not sufficient — deadlock may still be avoided if enough instances are free for some process in the cycle to finish and release its hold.

Deadlock Prevention

Attack one of the four necessary conditions structurally so deadlock becomes impossible:

Condition AttackedStrategyDrawback
Mutual exclusionMake resources shareable where possible (e.g. read-only files)Not possible for inherently exclusive resources (a printer, a write lock)
Hold and waitRequire processes to request all resources upfront, or release held resources before requesting new onesPoor resource utilization; starvation for resource-heavy processes
No preemptionAllow the OS to forcibly preempt resources from a waiting processOnly works for resources whose state can be saved/restored (CPU, memory) — not for a printer mid-job
Circular waitImpose a total ordering on resource types; processes must request in increasing orderCan be inconvenient to program against, still needs discipline (as in the dining philosophers fix)

Deadlock Avoidance — Banker's Algorithm Advanced

Rather than statically preventing deadlock, avoidance dynamically checks, before granting a resource request, whether granting it could still leave the system in a safe state — a state where there exists at least one order in which all processes can finish, even if each immediately requests its declared maximum. The Banker's algorithm requires each process to declare its maximum resource need in advance.

Worked example — 5 processes (P0–P4), 3 resource types (A, B, C), Available = (3, 3, 2):

ProcessAllocation (A B C)Max (A B C)Need = Max − Allocation
P00 1 07 5 37 4 3
P12 0 03 2 21 2 2
P23 0 29 0 26 0 0
P32 1 12 2 20 1 1
P40 0 24 3 34 3 1

Safety algorithm: find a process whose Need ≤ Available; simulate it finishing (add its Allocation back to Available); repeat.

  • Available = (3,3,2). Need[P1] = (1,2,2) ≤ (3,3,2) ✓ → run P1, release (2,0,0) → Available = (5,3,2)
  • Need[P3] = (0,1,1) ≤ (5,3,2) ✓ → run P3, release (2,1,1) → Available = (7,4,3)
  • Need[P4] = (4,3,1) ≤ (7,4,3) ✓ → run P4, release (0,0,2) → Available = (7,4,5)
  • Need[P0] = (7,4,3) ≤ (7,4,5) ✓ → run P0, release (0,1,0) → Available = (7,5,5)
  • Need[P2] = (6,0,0) ≤ (7,5,5) ✓ → run P2

Safe sequence found: P1 → P3 → P4 → P0 → P2. Since a safe sequence exists, the system is in a safe state. On every new resource request, the Banker's algorithm tentatively grants it, re-runs this safety check, and rolls back the grant if no safe sequence exists — that request is made to wait instead.

💡 Interview Tip

Emphasize the practical limitation when discussing Banker's algorithm: it requires processes to declare their maximum resource needs in advance, which real-world programs rarely do. This is exactly why avoidance is mostly a theoretical/textbook technique — production systems lean on prevention (structural rules) or detection+recovery instead.

Deadlock Detection & Recovery

If prevention/avoidance aren't used, the OS can instead allow deadlocks to happen, periodically run a detection algorithm (essentially the same cycle-search as the resource-allocation graph, generalized to multi-instance resources via a wait-for graph or the Banker's-style algorithm with actual allocations instead of max claims), and then recover:

  • Process termination — kill all deadlocked processes (simple, wasteful), or kill them one at a time, re-checking for deadlock after each, until the cycle breaks.
  • Resource preemption — forcibly take a resource from one process and give it to another, rolling that process back to a safe checkpoint. Needs care to avoid starving the same process repeatedly (must bound how many times a process can be picked as a victim).

Livelock vs Deadlock vs Starvation

DeadlockLivelockStarvation
Processes making progress?No — all blocked, waiting foreverNo useful progress — states keep changing but nothing completesSome processes make progress; one specific process never does
ExampleCircular wait on locksTwo people repeatedly stepping aside for each other in a hallway, foreverLow-priority process perpetually preempted by higher-priority arrivals
Typical fixBreak one of the 4 Coffman conditionsIntroduce randomized backoff so the "polite" behavior doesn't perfectly resonateAging — gradually raise priority the longer a process waits
Q: Can a cycle in a resource-allocation graph exist without a deadlock?

Yes, if the resource types involved have multiple instances. A cycle only guarantees deadlock when every resource type in the cycle has exactly one instance. With multiple instances, it's possible that a process outside the cycle holds a needed instance and will release it soon, breaking the cycle before anyone actually deadlocks — so the cycle is necessary but not sufficient for deadlock in that case.

Q: Why is deadlock avoidance (Banker's algorithm) rarely used in real operating systems?

It requires every process to declare its maximum resource requirement up front, which is impractical for general-purpose computing (most programs don't know or won't declare this), and the safety check itself has non-trivial runtime cost on every request. Real systems more commonly use simple prevention rules (like fixed lock ordering) or just let deadlocks happen rarely and rely on timeouts/watchdogs.

Q: Give a real-world circular-wait example outside of textbook resources.

Two database transactions each locking a different row and then trying to lock the row the other transaction already holds — a classic DB deadlock. Most RDBMSes run their own detection (wait-for graph over locks) and recover by aborting one transaction (choosing the "victim" by cost heuristics) rather than blocking forever.

Inter-Process Communication (IPC)

Because each process has its own private address space, they can't just share variables the way threads can — they need the kernel's help to exchange data. Here are the standard Unix/Linux mechanisms.

Pipes

An unnamed, unidirectional, in-kernel byte stream created with pipe(), with a read end and a write end. Only usable between processes that share a common ancestor (typically parent/child after fork()), since the file descriptors must be inherited. This is exactly what the shell's | operator uses under the hood.

C
int fd[2];
pipe(fd);                 // fd[0] = read end, fd[1] = write end
pid_t pid = fork();
if (pid == 0) {
    close(fd[0]);          // child writes
    write(fd[1], "hi", 2);
    close(fd[1]);
} else {
    close(fd[1]);          // parent reads
    char buf[3] = {0};
    read(fd[0], buf, 2);
    close(fd[0]);
}

Named Pipes (FIFOs)

Like a pipe, but has a name in the filesystem (created with mkfifo()), so unrelated processes can open it by path and communicate — no shared ancestry required. Still a byte stream, still unidirectional (though two FIFOs give you bidirectional communication).

Message Queues

A kernel-managed queue of discrete, typed messages (not a raw byte stream) that unrelated processes can send to and receive from by referencing the queue (System V msgget/msgsnd/msgrcv, or POSIX mq_open/mq_send/mq_receive). Messages persist in the queue even if no process is currently reading, and can be prioritized/typed for selective receive.

Shared Memory

The fastest IPC mechanism: the kernel maps the same physical memory pages into multiple processes' address spaces (shmget/shmat, or POSIX shm_open + mmap). After setup, there is zero kernel involvement in the actual data transfer — processes read/write directly, just like threads would. The tradeoff is that the kernel provides no synchronization: you must pair shared memory with an explicit synchronization mechanism (a semaphore, typically also placed in shared memory, or a named mutex) to avoid races.

Sockets

A general-purpose, bidirectional communication endpoint that works both locally (Unix domain sockets, identified by a filesystem path, avoids network stack overhead) and across a network (TCP/UDP sockets, identified by IP + port). The only IPC mechanism here that transparently extends to communicating with a process on a different machine.

Signals

Asynchronous notifications sent to a process to inform it of an event, interrupting its normal control flow to run a signal handler (or a default action if none is registered). Signals carry no data payload beyond the signal number itself (traditional signals — real-time signals add a small integer payload).

SignalMeaningDefault ActionCatchable?
SIGINTInterrupt from keyboard (Ctrl+C)TerminateYes
SIGKILLForceful killTerminateNo — cannot be caught, blocked, or ignored
SIGTERMPolite termination requestTerminateYes
SIGSEGVInvalid memory accessTerminate + core dumpYes (rarely useful to catch)
SIGCHLDChild process terminated/stoppedIgnoredYes — used to reap children asynchronously
SIGSTOPPause processStopNo

IPC Comparison Table

MechanismDirectionRelated processes only?SpeedBuilt-in sync?
PipeUnidirectionalYesFastYes (blocking read/write)
Named pipe (FIFO)UnidirectionalNoFastYes
Message queueBidirectional (two queues, or reply queue)NoMediumYes (queue semantics)
Shared memoryBidirectionalNoFastestNo — must add your own
SocketsBidirectionalNo (works across machines)Slower (esp. over network)Yes (stream semantics)
SignalsUnidirectional, no payloadNoFast, asyncN/A (event notification, not data transfer)
Q: Why is shared memory the fastest IPC mechanism?

Because after the initial setup (mapping the same physical pages into both address spaces), reads and writes are ordinary memory accesses — no system call, no data copying through the kernel, no context switch on every message. Every other IPC mechanism (pipes, sockets, message queues) requires at least one copy through kernel space and typically a syscall per message.

Q: Why can't SIGKILL be caught or ignored?

By design — it's the OS's guaranteed way to terminate a runaway or unresponsive process. If SIGKILL could be caught, a misbehaving or malicious process could install a handler that ignores it, making it impossible for the system to ever forcibly terminate that process.

Q: When would you choose a Unix domain socket over a named pipe for local IPC?

When you need bidirectional communication, multiple concurrent clients (a socket can listen()/accept() many connections; a FIFO is fundamentally one channel), or message-boundary-preserving datagram semantics (SOCK_DGRAM). Also useful if you might want to move the same code to a networked TCP socket later with minimal changes.

Memory Management

Contiguous Allocation

Each process is allocated one contiguous block of physical memory.

  • Fixed partitioning — physical memory is divided into fixed-size partitions at boot; each partition holds exactly one process. Simple, but wastes memory when a process is smaller than its partition (internal fragmentation), and caps the maximum process size.
  • Variable partitioning — partitions are created exactly the size of each incoming process. No internal fragmentation, but as processes come and go, memory becomes a patchwork of used/free blocks of varying sizes (external fragmentation) — allocators use strategies like first-fit, best-fit, and worst-fit to pick which free hole to use.

Fragmentation & Compaction

Internal FragmentationExternal Fragmentation
CauseAllocated block is larger than what's requested (leftover space inside the block is wasted)Free memory exists but is scattered in small, non-contiguous chunks too small to satisfy a request
Occurs inFixed-size partitioning, paging (last page of a process)Variable-size partitioning, segmentation
FixUse a smaller allocation unit (smaller pages) — tradeoff against page-table overheadCompaction (relocate processes to coalesce free space), or avoid the problem entirely with paging

Compaction shuffles allocated blocks together to merge scattered free space into one large block — but it requires relocatable code (base-register relocation) and pausing/copying running processes, which is expensive. This expense is one of the main reasons paging (below) largely displaced pure contiguous allocation in general-purpose OSes: paging sidesteps external fragmentation entirely by allocating in fixed-size frames that don't need to be contiguous.

Paging

Physical memory is divided into fixed-size frames; a process's logical address space is divided into same-size pages. A per-process page table maps each virtual page number to a physical frame number — pages need not be contiguous in physical memory, which eliminates external fragmentation completely (at the cost of some internal fragmentation in the last page).

Address translation
Logical address = (page number p, offset d)

physical_address = page_table[p].frame_number * FRAME_SIZE + d

// e.g. 32-bit address, 4KB pages (12-bit offset):
// virtual addr 0x004031A4
//   page number  = 0x00403 (top 20 bits)
//   offset       = 0x1A4   (bottom 12 bits)

Multilevel & Inverted Page Tables

A single flat page table for a modern 64-bit address space would be enormous (e.g. a naive single-level table could require gigabytes just for the table itself), so real systems use:

  • Multilevel (hierarchical) page tables — the page table itself is paged. A top-level table's entries point to second-level tables, which point to actual frames (x86-64 uses 4 levels: PML4 → PDPT → PD → PT). Unused regions of the address space never need their lower-level tables allocated at all, saving memory. Cost: a TLB miss now requires walking multiple levels (multiple memory accesses) instead of one.
  • Inverted page table — instead of one table per process, keep a single system-wide table with one entry per physical frame, storing which (process, virtual page) currently occupies it. This makes table size proportional to physical memory rather than virtual address space × number of processes, but lookups by virtual address now require a search — mitigated with a hash table keyed by (PID, virtual page).

Translation Lookaside Buffer (TLB)

A small, fast, hardware cache of recent virtual-to-physical page-table translations, located in the CPU's memory management unit (MMU). On every memory access, the MMU checks the TLB first:

  • TLB hit — translation found in the TLB; physical address obtained in roughly one cycle, no page-table walk needed.
  • TLB miss — translation not cached; the MMU (or, on some architectures, the OS) walks the page table (potentially multiple memory accesses for a multilevel table), then caches the result in the TLB for next time.
Effective Access Time (EAT)
EAT = hit_ratio * (TLB_time + memory_time)
    + (1 - hit_ratio) * (TLB_time + page_table_walk_time + memory_time)

// e.g. TLB access = 10ns, memory access = 100ns, hit ratio = 98%
EAT = 0.98 * (10 + 100) + 0.02 * (10 + 100 + 100)
    = 0.98 * 110 + 0.02 * 210
    = 107.8 + 4.2 = 112 ns

On a context switch, TLB entries from the old process are potentially stale for the new process (different address space). Some CPUs tag TLB entries with an address-space ID (ASID on ARM, PCID on x86) so entries from multiple processes can coexist without a full flush; without that, the OS must flush the entire TLB on every context switch, causing a burst of TLB misses right after — another reason process context switches are costlier than thread switches.

Segmentation

Memory is divided along logical boundaries meaningful to the program — code segment, stack segment, heap segment, data segment — rather than fixed-size mechanical chunks. Each segment has a base and limit; a logical address is (segment number, offset). This maps naturally to how programmers/compilers think (matches the structure of a program) and allows different protection per segment (code = read/execute, stack = read/write), but reintroduces external fragmentation since segments are variable-sized, just like variable partitioning.

Segmentation with Paging

Combine both: divide the address space into logical segments, then divide each segment into fixed-size pages. This is what x86 historically did (segments containing paged memory) — you get segmentation's logical/protection benefits and paging's fragmentation-free physical allocation. Modern x86-64 in practice uses segmentation only minimally (mostly flat segments) and relies on paging for the heavy lifting, but the concept remains a common interview topic because it explains why both mechanisms exist and how they can compose.

Q: Why does paging eliminate external fragmentation but not internal fragmentation?

Because pages/frames are fixed-size, any free frame can satisfy the next allocation request regardless of history — there's no "hole too small to use" scenario. But a process's last page is rarely exactly full; whatever's left over in that final page (up to page_size − 1 bytes) is wasted — that's internal fragmentation, bounded by the page size.

Q: Why do multilevel page tables actually save memory, given they add extra table levels?

Because a process typically only uses a small fraction of its available virtual address space. With a single flat table, you'd have to allocate an entry for every possible page in the entire address space up front. With a multilevel table, entire subtrees for unused regions are simply never allocated — the top-level entries pointing to them are just marked invalid, and no second/third/fourth-level tables exist for that range at all.

Q: What's the practical effect of a low TLB hit ratio on performance?

Every miss adds one or more extra memory accesses (the page-table walk) on top of the actual data access — for a 4-level page table, a miss could mean 4 extra memory round-trips before the real access even happens. Workloads with poor locality (e.g. randomly scattered pointer chasing across a huge working set) or that thrash the TLB via frequent context switches see this directly as increased effective memory latency.

Virtual Memory

Demand Paging

Instead of loading a process's entire address space into physical memory at start, pages are loaded lazily, only when actually referenced. Each page-table entry has a valid/invalid bit; a page not yet in memory is marked invalid. This lets processes run with a memory footprint far smaller than their full virtual address space, and lets total virtual memory across all processes exceed physical RAM.

Page Faults

Accessing a page marked invalid triggers a page fault trap into the kernel. The handler:

  1. Checks whether the access is actually valid (page exists on disk/swap but isn't loaded) or truly illegal (segmentation fault) — checked against the process's valid virtual memory regions.
  2. If valid: find a free physical frame (or evict one via a page replacement algorithm if none is free).
  3. Schedule a disk read to load the page's contents into that frame (this is why a page fault is orders of magnitude slower than a TLB miss — it involves actual disk/SSD I/O).
  4. Update the page table (mark valid, set frame number) and the TLB.
  5. Restart the faulting instruction.

Page Replacement Algorithms Advanced

When physical memory is full and a new page must be brought in, the OS must evict an existing page. Worked example — reference string 1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5 with 3 frames:

Ref123412512345
Frame 1111444555333
Frame 222211111144
Frame 33332222225
Fault?

FIFO (evict the oldest-loaded page, shown above): 9 page faults with 3 frames. See Belady's Anomaly for what happens with 4 frames on this same string.

  • FIFO — evicts the page that has been in memory the longest, regardless of usage. Simple (just a queue), but can behave counter-intuitively (Belady's anomaly).
  • Optimal (OPT / Bélády's algorithm) — evicts the page that won't be used for the longest time in the future. Provably minimizes page faults, but requires knowing the future reference string, so it's only usable as a theoretical benchmark to compare other algorithms against.
  • LRU (Least Recently Used) — evicts the page that hasn't been used for the longest time in the past, as an approximation of OPT (relies on temporal locality — recently used pages are likely to be used again soon). Exact LRU needs a timestamp or stack update on every memory reference, which is expensive in hardware, so real systems use approximations:
    • Clock / Second-Chance algorithm — frames arranged in a circular list with a "use" (reference) bit set by hardware on access. A clock hand sweeps looking for a frame with use-bit = 0 to evict; if it finds use-bit = 1, it clears the bit and gives that page a "second chance," advancing the hand. Approximates LRU cheaply — this is what most real kernels (including Linux's variant, the "two-list" active/inactive LRU-ish scheme) actually implement, since true LRU bookkeeping is too costly at scale.

Belady's Anomaly

Counter-intuitively, for some page-replacement algorithms (FIFO being the classic example), adding more physical frames can increase the number of page faults, even though intuition says more memory should only help. Running the same reference string (1,2,3,4,1,2,5,1,2,3,4,5) with FIFO and 4 frames instead of 3:

FramesPage Faults
39
410

More frames, more faults — the anomaly. LRU and OPT never exhibit this anomaly; they belong to a class called stack algorithms, where the set of pages held with n frames is always a subset of the set held with n+1 frames, which mathematically guarantees faults can only decrease (or stay the same) as frames increase. FIFO is not a stack algorithm, which is precisely why it can misbehave this way.

Thrashing & Working Set Model

Thrashing occurs when a process (or the system as a whole) spends more time servicing page faults than doing actual useful work — typically because the degree of multiprogramming is too high relative to available physical memory, so every process's pages keep getting evicted by other processes before they're reused, causing a cascading storm of faults. CPU utilization actually drops as thrashing worsens, which historically caused OSes to (wrongly) respond by admitting more processes to raise utilization — making thrashing worse.

The working set model is the standard fix: define a process's working set as the set of pages it has referenced in the last Δ (delta) time units. The OS tracks each process's working set size and only keeps a process resident if the sum of all working sets fits in physical memory; otherwise, it suspends (swaps out) some processes entirely via the medium-term scheduler, rather than letting everyone starve for frames simultaneously.

Copy-on-Write

Already covered in depth under Process Creation and revisited at the page-table level in Advanced Topics — the short version: pages are shared read-only between parent and child after fork(), and a private copy is made lazily, only on the first write, via a page fault.

Memory-Mapped Files

mmap() maps a file (or device) directly into a process's virtual address space, so file I/O becomes ordinary memory reads/writes instead of explicit read()/write() syscalls. The kernel uses the exact same demand-paging machinery: accessing an unmapped page of the file triggers a page fault, and the kernel transparently loads that page from disk into the page cache and maps it in. Benefits: no need to copy data between kernel buffers and user buffers (the page cache is the mapped memory — zero-copy), and multiple processes mapping the same file share the same physical pages automatically. This is also how shared libraries (.so/.dll) are loaded, and how shared memory (shm_open + mmap) is implemented under the hood.

Q: Why is Optimal page replacement not usable in practice despite being provably best?

It requires knowing the entire future sequence of memory references before making an eviction decision — information no online (real-time, causally-ordered) system can have. It's used purely as an offline benchmark: run it on a captured reference trace to know the theoretical best-case fault count, then measure how close a practical algorithm like LRU/Clock gets to it.

Q: What's the practical difference between thrashing and just "the system is busy"?

When the system is legitimately busy doing CPU-bound work, CPU utilization is high. When thrashing, CPU utilization is low despite high apparent activity (high disk I/O, processes constantly blocked) — the tell-tale sign is a system that looks maxed out (disk light constantly on) but whose actual throughput has collapsed. That inverse relationship (more load → less useful output) is the diagnostic signature of thrashing.

Q: How does mmap() avoid extra copying compared to read()?

read() requires the kernel to copy data from the page cache into a separate user-space buffer you provide — one full copy. With mmap(), the process's page table entries point directly at the same physical pages that make up the kernel's page cache; there is no separate user buffer to copy into, the process just reads/writes the mapped memory directly, and the kernel's page cache and the process's view of the file are the same physical pages.

File Systems

File Allocation Methods

MethodHow it worksProsCons
ContiguousEach file occupies a contiguous run of blocks, tracked by (start block, length)Fast sequential and random access; minimal seekExternal fragmentation; hard to grow a file in place
LinkedEach block holds a pointer to the next block of the file; directory stores only the first blockNo external fragmentation; files can grow easilySlow random access (must traverse from start); pointer overhead per block; reliability risk (one bad pointer breaks the chain)
Indexed (inodes)A dedicated index block (inode) stores pointers to all of a file's data blocks, often via direct + indirect + doubly/triply-indirect pointers for large filesFast random access (O(1)-ish lookups via the index); no external fragmentationOverhead of the index block itself, more complex for very large files (multi-level indirection)

Unix-family file systems (ext2/3/4, etc.) use indexed allocation via inodes: a fixed-size inode holds file metadata (permissions, timestamps, size) plus a small number of direct block pointers, one single-indirect pointer (points to a block full of pointers), one double-indirect (points to a block of pointers to blocks of pointers), and one triple-indirect — letting a compact, fixed-size inode address files ranging from a few bytes to many terabytes.

Free Space Management

  • Bitmap — one bit per block, 1 = allocated, 0 = free. Compact, and finding contiguous free runs is a fast bit-scan, but the bitmap itself must be kept in memory for performance and persisted carefully.
  • Linked list of free blocks — free blocks are chained together; no extra structure size cost, but no way to quickly find a large contiguous run.
  • Grouping — the first free block stores addresses of the next N free blocks (one of which itself stores the addresses of the next N, and so on), enabling fast retrieval of many free blocks at once.
  • Counting — since free blocks often come in contiguous runs, store (start block, count of contiguous free blocks) pairs instead of one entry per block — much more compact for typical fragmentation patterns.

Directory Structures

StructureDescription
Single-levelOne directory for the entire file system. Simple but no organization; all filenames must be unique system-wide.
Two-levelOne directory per user, under a master directory. Solves cross-user name collisions but no further nesting.
Tree-structuredArbitrary nested subdirectories (what virtually every modern OS uses). Each file has exactly one path from the root.
Acyclic graphAllows a file/directory to be referenced from multiple places (hard links, symbolic links) as long as no cycles are introduced. Needs reference counting or garbage collection to know when it's safe to actually free a file's data.
General graphAllows arbitrary links including cycles. Requires cycle detection (or restricting operations, e.g. disallowing hard links to directories) to avoid infinite loops during traversal, and complicates garbage collection (simple reference counting can't reclaim a cycle no longer reachable from the root).

Journaling File Systems

A crash (power loss, kernel panic) in the middle of a multi-step metadata update (e.g. creating a file: allocate inode, write directory entry, update free-space bitmap) can leave the file system in an inconsistent state. Journaling borrows the write-ahead-log idea from databases: before making the actual changes, the file system writes a description of the intended changes to a dedicated journal/log area. If the system crashes mid-update, on reboot the file system replays the journal to either complete or cleanly roll back the interrupted operation, rather than needing a slow full-disk consistency scan (like old fsck on non-journaled ext2).

  • Metadata-only journaling (e.g. ext3/ext4 default ordered mode) — only file-system metadata (inodes, bitmaps, directory entries) is journaled; actual file data is written directly. Fast, protects structural consistency, but data written right before a crash may still be lost/incomplete.
  • Full (data) journaling — both metadata and data are journaled. Strongest consistency guarantee, but roughly doubles the write I/O (data is written once to the journal, once to its final location).

Disk Scheduling Algorithms Advanced

Worked example — request queue 98, 183, 37, 122, 14, 124, 65, 67, disk head starting at cylinder 53, cylinders range 0–199 (assume the head moves toward higher-numbered cylinders first where direction matters):

AlgorithmHow it worksService orderTotal head movement
FCFSService requests strictly in arrival order98,183,37,122,14,124,65,67640
SSTF (Shortest Seek Time First)Always service the closest request to the current head position65,67,37,14,98,122,124,183236
SCAN (elevator)Sweep in one direction to the end of the disk, then reverse65,67,98,122,124,183,199(end),37,14331
C-SCANSweep in one direction to the end, jump back to the start, continue in the same direction65,67,98,122,124,183,199,0,14,37382
LOOKLike SCAN, but reverses at the last request instead of the physical disk end65,67,98,122,124,183,37,14299
C-LOOKLike C-SCAN, but jumps only to the lowest pending request instead of the physical disk end65,67,98,122,124,183,14,37322

SSTF minimizes movement for this specific snapshot but can starve requests far from the current hotspot if nearby requests keep arriving. SCAN/LOOK variants bound the worst-case wait (a request is serviced at most within one full sweep), which is why real disk schedulers (and the conceptually similar Linux I/O elevator schedulers) favor SCAN/LOOK-family algorithms over pure SSTF. C-SCAN/C-LOOK give more uniform wait times than SCAN/LOOK because they treat the disk as a circular list (no double-density of service near the reversal point).

Q: Why does C-SCAN provide more uniform wait times than plain SCAN?

In SCAN, cylinders near where the head just reversed get serviced twice in quick succession (once right before reversing, once right after), while cylinders near the far end wait almost a full sweep. C-SCAN always services in one direction and jumps back without servicing on the return trip, so every cylinder waits roughly the same amount of time between visits — that uniformity matters for predictable latency, e.g. in RAID controllers or database storage engines.

Q: Are these disk scheduling algorithms still relevant for SSDs?

Less directly — SSDs have no mechanical seek time, so "minimize head movement" isn't meaningful the same way. But the concept persists in I/O schedulers as request merging/reordering to maximize throughput and fairness (e.g. Linux's mq-deadline, kyber, bfq schedulers), and the underlying idea of ordering requests to respect fairness/deadlines while batching still applies, just tuned for flash characteristics (parallel channels, wear leveling) instead of seek time.

Q: What does an inode NOT store, that people often assume it does?

The filename. Filenames live in directory entries, which map a name to an inode number — that's exactly why hard links work (multiple directory entries, possibly with different names, pointing at the same inode) and why renaming a file is cheap (just updates a directory entry, not the inode or the data blocks).

Concurrency Primitives & Modern Concepts

Spinlocks vs Mutexes Intermediate

Both provide mutual exclusion, but they handle contention differently:

SpinlockMutex
On contentionBusy-waits in a tight loop, burning CPU, continuously polling the lockBlocks — the OS descheduls the thread, freeing the core for other work
Best whenCritical section is very short (shorter than a context switch would cost) and you're on a multicore system where the lock holder is likely running on another core right nowCritical section is longer, or you're uncertain, or you're on a single core (spinning would just burn the only CPU without letting the holder ever run)
Typical userKernel code (interrupt handlers, very hot paths) where sleeping isn't even legal in some contextsApplication-level code (std::mutex, pthread mutex)
⚠️ Common Pitfall

Never use a spinlock on a single-core system for a lock that might be held for a while — the spinning thread can't be preempted off that lone core (in kernel context especially) fast enough to let the actual lock holder run, so you can spin forever waiting for a lock that will never be released. This is a classic bug source in early SMP-oblivious kernel code.

Atomic Operations & Compare-And-Swap

An atomic operation completes as a single indivisible step from every other thread/core's point of view — no other thread can observe it half-done. Modern CPUs provide hardware-level atomic instructions (e.g. x86 LOCK-prefixed instructions, ARM LDXR/STXR) that lock-free algorithms and languages' atomic types build on. The most important one is Compare-And-Swap (CAS):

C++
#include <atomic>

std::atomic<int> counter{0};

void lock_free_increment() {
    int expected = counter.load();
    int desired;
    do {
        desired = expected + 1;
        // atomically: if counter == expected, set counter = desired,
        // and return true. Otherwise, load the *current* value into
        // `expected` and return false, so the loop retries.
    } while (!counter.compare_exchange_weak(expected, desired));
}

CAS(address, expected, new_value) atomically checks whether the memory at address still equals expected; if so, it writes new_value and reports success, otherwise it does nothing and reports failure (usually also returning the current value so the caller can retry). This single primitive is powerful enough to implement essentially all lock-free data structures — it's the hardware foundation everything else in this section builds on.

Memory Barriers & Memory Model Basics

Both compilers and CPUs reorder instructions for performance, as long as the reordering is invisible to a single-threaded observer. Across threads, though, this reordering can become visible and break assumptions — a classic case: thread A writes data = 42; ready = true;, thread B reads if (ready) use(data); — without ordering guarantees, the CPU or compiler could make ready = true visible to thread B before data = 42 is, and B would read a stale/garbage data.

A memory barrier (fence) is an instruction that constrains this reordering — e.g. "no load/store after this point may be reordered before this point." C++'s std::atomic exposes this via memory ordering parameters:

OrderingGuarantee
memory_order_relaxedOnly atomicity — no ordering guarantee relative to other memory operations
memory_order_acquireNo later read/write in this thread can be reordered before this load (used when reading a "ready" flag)
memory_order_releaseNo earlier read/write in this thread can be reordered after this store (used when publishing data before setting a "ready" flag)
memory_order_seq_cstStrongest/default — acts as if there's one single global order of all seq_cst operations across all threads

std::mutex internally establishes acquire/release semantics around lock/unlock, which is exactly why plain int shared data protected by a mutex is safe without any explicit atomics — the mutex's lock/unlock already act as the necessary fences.

False Sharing

CPU caches operate on fixed-size cache lines (commonly 64 bytes), not individual variables. If two threads on different cores frequently write to two different variables that happen to sit on the same cache line, the cache-coherence protocol (e.g. MESI) will bounce that line between cores' caches on every write from either side — even though the threads aren't logically sharing any data. This looks exactly like real lock contention in a profiler, but there's no actual synchronization primitive involved.

C++
struct Counters {
    std::atomic<long> a;   // written by thread 1
    std::atomic<long> b;   // written by thread 2 — likely same cache line as `a`!
};

// Fix: pad so each counter owns its own cache line
struct alignas(64) PaddedCounter {
    std::atomic<long> value;
    char padding[64 - sizeof(std::atomic<long>)];
};

Lock-Free Data Structures

A lock-free algorithm guarantees system-wide progress: at least one thread always completes its operation in a bounded number of steps, even if other threads are paused, crash, or are descheduled indefinitely at arbitrary points. This is a much stronger guarantee than "no explicit locks used" — it's a formal progress property. Contrast with:

  • Obstruction-free — a thread makes progress only if it eventually runs without interference from other threads (weakest).
  • Lock-free — the system as a whole always makes progress, though any individual thread could theoretically be starved forever by other threads winning every CAS race.
  • Wait-free — every individual thread is guaranteed to complete in a bounded number of steps, regardless of what other threads do (strongest, hardest to achieve).

A canonical building block is a lock-free stack using CAS on the head pointer:

C++ (simplified — ignores the ABA problem)
template <typename T>
struct LockFreeStack {
    struct Node { T value; Node* next; };
    std::atomic<Node*> head{nullptr};

    void push(T v) {
        Node* n = new Node{v, head.load()};
        while (!head.compare_exchange_weak(n->next, n)) {
            // n->next was updated to the current head on failure; retry
        }
    }

    bool pop(T& out) {
        Node* old_head = head.load();
        while (old_head &&
               !head.compare_exchange_weak(old_head, old_head->next)) {
            // retry with the updated old_head on failure
        }
        if (!old_head) return false;
        out = old_head->value;
        delete old_head;   // unsafe without a reclamation scheme — see note below
        return true;
    }
};

The delete above is a simplification that's actually unsafe in a truly concurrent setting — a thread could still be reading old_head when another thread pops and frees it (the ABA problem and use-after-free hazards). Production lock-free code needs a memory reclamation scheme (hazard pointers, epoch-based reclamation, or RCU — see RCU) to safely free memory that other threads might still be dereferencing.

std::mutex vs std::atomic in Practice

std::mutexstd::atomic
ProtectsArbitrary-sized critical sections, multiple variables, invariants spanning several fieldsA single variable's individual read-modify-write operations
Blocking?Yes — contending threads sleepNo — operations retry via CAS loops or complete via hardware atomics directly, never sleeping
Failure modeDeadlock possible (lock ordering violations)No deadlock, but ABA problems and subtle ordering bugs possible
When to useProtecting an invariant across multiple fields, or a critical section with any real amount of work in itA single counter/flag/pointer where lock-free semantics matter (e.g. hot-path counters, simple flags, building block for lock-free structures)
💡 Interview Tip

A very common trap: using several separate std::atomic variables to protect a compound invariant (e.g. "these two counters must always sum to N"). Each individual atomic operation is atomic, but the combination is not — another thread can observe an intermediate state where the invariant is broken. If more than one variable must change together consistently, that's a signal you need a mutex around all of them, not multiple atomics.

Q: Why is a spinlock sometimes faster than a mutex, if a mutex "does less work" when uncontended?

Uncontended, they're comparable — both boil down to one atomic operation. The difference shows up under contention: a mutex, once it detects contention, asks the OS to deschedule the waiting thread (a syscall, plus a later wake-up syscall from the releasing thread) — real overhead in the hundreds of nanoseconds to microseconds range. A spinlock just keeps retrying an atomic instruction in a loop, which can be cheaper than two syscalls if the lock is held only for a few dozen nanoseconds by another core.

Q: What is the ABA problem?

A CAS-based algorithm checks that a value is still equal to some `expected`, and proceeds if so — but if the value changed from A to B and back to A between the read and the CAS, the CAS succeeds even though the underlying state meaningfully changed in between (e.g. a node was popped, freed, and a new node happened to be allocated at the same address). Common fixes: tagged pointers (pair the pointer with a version counter that only ever increases), hazard pointers, or epoch-based reclamation.

Q: How would you detect false sharing in a real profiling session?

Hardware performance counters for cache-coherence traffic — e.g. Linux `perf c2c` (cache-to-cache) directly reports cache lines with heavy cross-core contention and which load/store instructions touch them. The behavioral tell is: contention/slowdown that scales with core count on data that, logically, shouldn't be shared at all — that mismatch between "no logical sharing" and "measured contention" is the signature of false sharing.

Advanced & Rare Topics

Topics in this section go beyond what most standard courses cover — useful for senior/staff-level systems interviews, or simply to stand out.

Real-Time Scheduling: EDF & RMS Advanced

Real-time scheduling cares about deadlines, not just fairness/throughput.

  • Rate Monotonic Scheduling (RMS) — a static-priority algorithm: each periodic task gets a fixed priority inversely proportional to its period (shorter period = higher priority). Optimal among static-priority algorithms. A well-known sufficient (not necessary) schedulability test for n tasks: total CPU utilization ≤ n(2^(1/n) − 1) (approaches ~69% as n grows).
  • Earliest Deadline First (EDF) — a dynamic-priority algorithm: at any instant, run whichever ready task has the nearest absolute deadline. Provably optimal among all scheduling algorithms (static or dynamic) for uniprocessor systems — if any algorithm can schedule a task set to meet all deadlines, EDF can too. It can achieve up to 100% CPU utilization (vs RMS's ~69% worst-case bound), but priorities change dynamically at runtime, which adds bookkeeping overhead and makes worst-case analysis and implementation trickier than RMS's fixed priorities.

NUMA Architecture

On multi-socket systems, each CPU socket has its own local bank of physical RAM ("Non-Uniform Memory Access"). Any CPU can access any RAM, but accessing memory attached to a remote socket is significantly slower (extra hop over the inter-socket interconnect, e.g. Intel's UPI or AMD's Infinity Fabric) than accessing its own local memory. This has direct scheduling and memory-allocation implications: the OS scheduler tries to keep a thread running on the same NUMA node as the memory it allocated ("NUMA affinity"), and migrating a thread across nodes can silently tank performance even though the CPU itself is idle and "available." High-performance systems often pin threads to a NUMA node and allocate memory with node-local policies (Linux numactl, mbind()) rather than trusting the default scheduler to get this right under load.

Read-Copy-Update (RCU) Advanced

RCU is a synchronization mechanism (heavily used in the Linux kernel) optimized for workloads that are overwhelmingly read-heavy with occasional writes. The core idea: readers access shared data with zero synchronization overhead — no locks, no atomic operations, just a plain pointer dereference — while writers create a new copy of the data, modify the copy, and then atomically swap a single pointer to publish it. Old readers that grabbed the pointer before the swap keep safely reading the old version until they finish; the old version is only freed after the kernel proves no reader could still hold a reference to it (via a "grace period" — waiting until every CPU has passed through a quiescent state, guaranteeing no in-progress RCU read-side critical section from before the update is still running).

This trades write cost (copy + wait for grace period + free) for essentially free reads — the opposite tradeoff of a reader-writer lock, which adds overhead to every reader to protect against writers. RCU shines in kernel data structures like routing tables that are read millions of times per second but updated rarely.

fork() + Copy-on-Write at the Page-Table Level

Revisiting fork() at the mechanism level: when fork() is called, the kernel does not copy any physical page frames. Instead it:

  1. Allocates a new page table for the child, copying the parent's page-table entries (not the underlying data) — child and parent's entries now point at the same physical frames.
  2. Clears the writable bit on every page-table entry (both parent's and child's) that was previously writable, and marks the underlying physical page as COW (tracked via a reference count on the frame, incremented to 2).
  3. Returns from the fork; both processes now run with identical, shared, read-only-marked memory.
  4. The first time either process writes to one of these pages, the CPU raises a page fault because the page-table entry says read-only even though the process's logical permissions say writable. The kernel's fault handler recognizes this specific case (COW fault, distinguishable from a real permissions violation via a bit tracked per-VMA/mapping), allocates a fresh physical frame, copies the page's contents into it, updates only the faulting process's page-table entry to point at the new frame with write permission restored, and decrements the original frame's reference count.
  5. If the reference count on the original frame drops to 1, the remaining process's entry can be marked writable again directly (no more sharing, no need to keep faulting).

This is why fork() is fast even for processes with huge address spaces — the cost is proportional to page-table size (a few memory pages' worth of entries), not to actual memory content size, and if the child execs immediately (the overwhelmingly common case), zero data pages are ever duplicated.

Containers vs Virtual Machines Advanced

Both provide isolation, but at fundamentally different layers:

Virtual MachineContainer
Isolation layerHardware — a hypervisor virtualizes CPU/memory/devices; each VM runs its own full kernelOS kernel — containers share the host kernel, isolated via kernel features
OverheadHeavy — full guest OS boot, its own memory footprint, virtualized devicesLight — just a process (or process group) with restricted views; starts in milliseconds
Isolation strengthVery strong — a guest kernel exploit is contained by the hypervisor boundaryWeaker — a host kernel vulnerability can potentially be exploited by any container to escape, since they share one kernel
ExampleVMware, KVM/QEMU, VirtualBox, Hyper-VDocker, containerd, Podman

Docker-style containers rely on two Linux kernel mechanisms:

  • Namespaces — restrict what a process group can see. Each namespace type virtualizes one kind of global kernel resource: pid (process sees itself as PID 1, can't see host processes), mnt (its own filesystem mount view/root), net (its own network interfaces, routing table, ports), uts (its own hostname), ipc (its own IPC/shared-memory namespace), user (its own UID/GID mapping — root inside the container can map to an unprivileged UID outside).
  • cgroups (control groups) — restrict what a process group can use: CPU shares/quotas, memory limits (with OOM-killing on breach), block I/O bandwidth, and (cgroup v1) device access. This is how Docker enforces --memory and --cpus limits.

A container is, under the hood, just an ordinary Linux process (visible in the host's own ps output with a normal host PID) that happens to have been launched inside a set of namespaces and attached to a cgroup — there's no separate "container kernel" or hypervisor involved, which is exactly why containers are so much lighter than VMs, and also exactly why a kernel-level exploit can potentially escape a container in a way it can't escape a VM.

Interrupt Handling: Top & Bottom Halves

When a hardware device (disk, NIC, keyboard) needs attention, it raises a hardware interrupt, which the CPU services by jumping to an Interrupt Service Routine (ISR) — but ISRs run with (some) interrupts disabled and must be extremely fast, or the system becomes unresponsive to other events. Linux (and most modern kernels) splits interrupt handling into two halves:

  • Top half — the actual hardware interrupt handler. Does the absolute minimum required immediately (e.g. acknowledge the interrupt to the device, copy a small amount of critical data), then schedules the rest of the work to run later and returns as fast as possible.
  • Bottom half — the deferred, larger chunk of work, run later with interrupts enabled (so it doesn't block other hardware from interrupting). Linux implements this via softirqs (static, compiled-in, used for the highest-throughput paths like networking), tasklets (built on softirqs, dynamically registerable, guaranteed not to run the same tasklet concurrently on two CPUs), and workqueues (run in normal (schedulable, can sleep) process context, for bottom-half work that needs to block, e.g. on a mutex or I/O).

This split is why a network card can sustain extremely high packet rates without freezing the rest of the system — the top half does only the minimal urgent work per packet, and the bulk of protocol processing happens in a softirq that can be preempted by the next top-half interrupt if needed.

Syscall Overhead & vDSO

Every system call costs a user→kernel→user mode transition — saving/restoring registers, potential TLB and cache effects, and (on x86, historically) the cost of the syscall/sysenter instruction itself, further inflated post-Spectre/Meltdown by mitigations like KPTI (kernel page-table isolation, which forces an extra page-table switch on every syscall to prevent user-space from speculatively reading kernel memory). For very frequently called, read-only-ish syscalls — most notably gettimeofday()/clock_gettime() — this overhead is disproportionate to the actual work (reading a counter).

The Linux kernel's answer is the vDSO (virtual Dynamic Shared Object): the kernel maps a small, read-only page of actual kernel code (plus kernel-updated data like the current time) directly into every process's address space. Calling clock_gettime() through glibc transparently calls into this mapped vDSO code — a plain function call in user space, no trap, no mode switch at all — which reads the kernel-maintained time data directly. This turns a syscall that would otherwise cost hundreds of nanoseconds into one costing a few nanoseconds, for exactly the handful of syscalls where it's safe (no side effects on kernel state, purely observational).

Priority Inversion & Priority Inheritance Advanced

Priority inversion occurs when a high-priority task is indirectly blocked by a low-priority task, because the low-priority task holds a lock the high-priority task needs — but the situation gets worse when an unrelated, medium-priority task preempts the low-priority lock holder (since the scheduler sees no reason not to, the medium task isn't waiting on anything), starving the high-priority task indefinitely even though it's "more important" than both.

⚠️ The Mars Pathfinder Story

In 1997, NASA's Mars Pathfinder rover began experiencing total system resets on the Martian surface. The cause was exactly this: a low-priority meteorological data-bus task held a mutex protecting shared memory; a high-priority bus-management task blocked waiting for that same mutex; a set of medium-priority communications tasks then repeatedly preempted the low-priority task (since nothing was formally blocking them), preventing it from ever finishing and releasing the mutex — so the high-priority task missed its deadline, tripping a watchdog timer that reset the whole system. Engineers diagnosed and fixed it remotely, on Mars, by uploading a patch that enabled the priority inheritance protocol already present but disabled in the VxWorks RTOS.

The priority inheritance protocol is the standard fix: when a high-priority task blocks waiting for a lock held by a lower-priority task, the lock holder temporarily inherits the waiting task's higher priority for as long as it holds the lock — preventing any merely-medium-priority task from preempting it in the meantime. Once it releases the lock, its priority reverts to normal. This bounds the priority-inversion delay to the length of one critical section, instead of potentially unbounded time.

Q: Why is EDF optimal but RMS still widely used in real RTOS deployments?

RMS's fixed priorities are simpler to implement, easier to reason about and certify for safety-critical systems (predictable, static behavior — important for aerospace/medical certification processes), and cheaper at runtime (no need to recompute priorities as deadlines approach). EDF's optimality comes with dynamic priority recomputation overhead and behaves less predictably under transient overload (a phenomenon called "domino effect" where one missed deadline can cascade into many more), which matters a lot for hard real-time certification even though EDF achieves higher theoretical CPU utilization.

Q: Does priority inheritance fully solve priority inversion, or just bound it?

It bounds it, rather than eliminating the underlying possibility — a high-priority task can still be delayed by the time it takes the (temporarily-boosted) lower-priority task to finish its critical section, but that delay is now bounded and predictable instead of open-ended (since medium-priority tasks can no longer cut in). A stronger, more restrictive alternative is the priority ceiling protocol, which pre-assigns a lock's "ceiling" priority to prevent inversion chains from nesting, at the cost of more conservative scheduling.

Q: Concretely, why can't a container fully replace a VM for multi-tenant untrusted workloads?

Because containers on the same host share one kernel — a kernel vulnerability (a bug in a syscall, a namespace implementation flaw) can be exploited by any container to potentially read/write host memory or escape into other containers/the host. A VM's isolation boundary is enforced by the hypervisor and hardware virtualization extensions, a fundamentally smaller and more scrutinized attack surface than an entire general-purpose kernel's syscall interface — which is why cloud providers running untrusted multi-tenant workloads either use full VMs or lightweight-VM hybrids like gVisor/Kata Containers/Firecracker that wrap containers in a minimal VM boundary.

Practical Coding

Common OS-flavored coding interview exercises, worked in full C++.

Thread-Safe LRU Cache in C++ Advanced

A classic systems-coding question combining a data-structure design (doubly linked list + hash map for O(1) get/put) with correct locking. The linked list tracks recency order (most-recently-used at the front); the hash map gives O(1) lookup from key to the corresponding list node.

C++
#include <unordered_map>
#include <mutex>
#include <list>
#include <optional>

template <typename K, typename V>
class LRUCache {
public:
    explicit LRUCache(size_t capacity) : capacity_(capacity) {}

    std::optional<V> get(const K& key) {
        std::lock_guard<std::mutex> lock(mtx_);
        auto it = index_.find(key);
        if (it == index_.end()) return std::nullopt;

        // Move the accessed node to the front (most recently used)
        items_.splice(items_.begin(), items_, it->second);
        return it->second->second;
    }

    void put(const K& key, const V& value) {
        std::lock_guard<std::mutex> lock(mtx_);
        auto it = index_.find(key);
        if (it != index_.end()) {
            it->second->second = value;
            items_.splice(items_.begin(), items_, it->second);
            return;
        }

        if (items_.size() >= capacity_) {
            auto& lru = items_.back();       // least recently used
            index_.erase(lru.first);
            items_.pop_back();
        }

        items_.emplace_front(key, value);
        index_[key] = items_.begin();
    }

private:
    using ListType = std::list<std::pair<K, V>>;
    size_t capacity_;
    ListType items_;                                     // front = MRU, back = LRU
    std::unordered_map<K, typename ListType::iterator> index_;
    std::mutex mtx_;
};
💡 Interview Tip

Be ready to discuss the locking granularity tradeoff: a single mutex around the whole cache (shown above) is simple and correct but serializes all access, even reads. For higher read throughput, interviewers may want you to discuss sharding the cache into N independent LRU segments (each with its own lock, keyed by hash(key) % N) to reduce contention, at the cost of the global capacity/eviction policy no longer being perfectly precise.

Producer-Consumer with std::mutex + std::condition_variable Intermediate

C++
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>

template <typename T>
class BoundedQueue {
public:
    explicit BoundedQueue(size_t capacity) : capacity_(capacity) {}

    void push(T item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_full_.wait(lock, [this] { return queue_.size() < capacity_ || done_; });
        if (done_) return;
        queue_.push(std::move(item));
        lock.unlock();
        not_empty_.notify_one();
    }

    bool pop(T& out) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_empty_.wait(lock, [this] { return !queue_.empty() || done_; });
        if (queue_.empty()) return false;   // done_ and drained
        out = std::move(queue_.front());
        queue_.pop();
        lock.unlock();
        not_full_.notify_one();
        return true;
    }

    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            done_ = true;
        }
        not_empty_.notify_all();
        not_full_.notify_all();
    }

private:
    std::queue<T> queue_;
    size_t capacity_;
    bool done_ = false;
    std::mutex mtx_;
    std::condition_variable not_full_;
    std::condition_variable not_empty_;
};

int main() {
    BoundedQueue<int> q(5);

    std::thread producer([&] {
        for (int i = 0; i < 20; ++i) q.push(i);
        q.shutdown();
    });

    std::thread consumer([&] {
        int value;
        while (q.pop(value)) {
            std::cout << "consumed: " << value << "\n";
        }
    });

    producer.join();
    consumer.join();
}
⚠️ Common Pitfall

Always call wait() with a predicate (the lambda form shown above), never a bare cv.wait(lock). Condition variables can suffer spurious wakeups (the wait can return even though no one called notify), and without re-checking the actual condition in a loop, you'll proceed on a false signal. The predicate-based overload of wait() already loops internally: while (!pred()) wait(lock); — always prefer it over the bare form.

Implementing a Counting Semaphore

C++20 added std::counting_semaphore directly, but interviewers often want you to build one from a mutex + condition variable to prove you understand what a semaphore actually is underneath.

C++
#include <mutex>
#include <condition_variable>

class CountingSemaphore {
public:
    explicit CountingSemaphore(int initial_count) : count_(initial_count) {}

    // P() / wait() / acquire()
    void acquire() {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_.wait(lock, [this] { return count_ > 0; });
        --count_;
    }

    // V() / signal() / release()
    void release(int n = 1) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            count_ += n;
        }
        if (n == 1) cv_.notify_one();
        else        cv_.notify_all();
    }

    bool try_acquire() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (count_ > 0) { --count_; return true; }
        return false;
    }

private:
    std::mutex mtx_;
    std::condition_variable cv_;
    int count_;
};

// Usage: a binary semaphore (count_ starts at 1) behaves like a mutex,
// except any thread may call release() -- there's no ownership check,
// which is the key distinction discussed under Mutex vs Semaphore.
Q: In the LRU cache, why use std::list + unordered_map instead of just a vector?

A std::list gives O(1) removal/insertion at arbitrary positions given an iterator (needed to move an accessed item to the front without shifting anything), and the map stores those iterators for O(1) lookup by key. A vector would need O(n) shifting to move an accessed element to the front, making every get() O(n) instead of O(1).

Q: Why does release() choose between notify_one() and notify_all() based on n?

If only one permit was released, waking more than one blocked thread would be wasteful — only one of them can actually acquire it, and the rest would just re-check the predicate and go back to sleep. If multiple permits were released at once, multiple waiters could each successfully acquire one, so all of them need a chance to wake up and recheck.

Q: What's a realistic use case for a counting semaphore versus a mutex?

Limiting concurrent access to a pool of N interchangeable resources — e.g. capping concurrent outbound database connections to 10, or limiting how many worker threads may simultaneously call a rate-limited external API. A mutex only ever allows exactly one holder; a counting semaphore generalizes this to allow up to N simultaneous holders.

References & Further Reading