~learn.
?

Part 0533 minPreface+60 XP

Part 5: Operating System Concepts for System Design.

5 stages26 code

watch it first · newThe whole lesson in a 18-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

Every distributed system runs on operating systems. When you say "the server handles 10,000 requests per second," you're relying on OS concepts you might not consciously think about.

Your application code sits on top of
Layer (top to bottom) Includes
Your application
Runtime Python, JVM, Go, Node.js
System libraries libc
Operating system kernel Process Management · Memory Management · File Systems · I/O Subsystem · Networking Stack · Device Drivers
Hardware
Understanding OS concepts helps you
  • Tune your systems for performance
  • Debug production issues
  • Make informed architecture decisions
  • Answer interview questions confidently

This document covers everything about operating systems you need for system design interviews.

ExplainerThe whole lesson: operating system concepts

Processes isolate memory while threads share it, which brings race conditions and deadlocks. Virtual memory maps pages through a page table, page faults and the memory hierarchy set the speed, and cache lines reward sequential access. Event loops on epoll replace thread-per-connection, while the page cache, fsync, write-ahead logs, file descriptor limits and container cgroups decide what survives a crash and what scales.

Part I · Stage 1 of 5 · 8 min

Part I: Processes and Threads#

Chapter 1: Processes#

1.1 What Is a Process?#

Process = running instance of a program
Memory layout (high addresses at the top, low addresses at the bottom)
Segment Holds Notes
Stack Local variables, function calls Grows downward ↓
Heap Dynamic allocation (malloc/new) Grows upward ↑
BSS Uninitialized global variables
Data Initialized global variables
Text Program code (read-only)
A process also has
  • Process ID (PID)
  • Open file descriptors
  • Environment variables
  • CPU register state
  • Scheduling priority

1.2 Process States#

DiagramProcess lifecycle
%% title: Process lifecycle
stateDiagram-v2
  NEW: NEW (Created)
  READY: READY (Waiting for CPU)
  RUNNING: RUNNING (Executing)
  WAITING: WAITING (Blocked)
  TERMINATED: TERMINATED (Zombie)
  [*] --> NEW
  NEW --> READY: Admitted
  READY --> RUNNING: Scheduled
  RUNNING --> READY: Preempted
  RUNNING --> WAITING: I/O request
  WAITING --> READY: I/O complete
  RUNNING --> TERMINATED: Exit
  TERMINATED --> [*]
  class READY queue
  class RUNNING ok
  class WAITING core
  class TERMINATED bad

States explained:

  • NEW: Process being created
  • READY: Waiting for CPU time
  • RUNNING: Currently executing on CPU
  • WAITING: Blocked on I/O or event
  • TERMINATED: Finished, waiting for parent to read exit status

1.3 Process Creation#

DiagramFork and exec (Unix/Linux)
%% title: Fork and exec (Unix/Linux)
sequenceDiagram
  autonumber
  participant P as Parent Process
  participant C as Child Process
  P->>C: fork()
  Note right of C: Created as copy of parent<br/>Gets new PID<br/>Shares open file descriptors
  Note left of P: (continues)
  C->>C: exec("/bin/program")
  Note right of C: Replaces memory with new program
  P->>P: wait() ...
  Note right of C: (runs program)
  C-->>P: exit status · exit(0)

fork() return values:

  • In parent: Returns child's PID (positive number)
  • In child: Returns 0
  • On error: Returns -1

Copy-on-write (COW):

  • Child doesn't immediately copy parent's memory
  • Pages marked read-only, shared between parent/child
  • Only copied when either tries to write
  • Makes fork() very fast even for large processes

1.4 Inter-Process Communication (IPC)#

IPC mechanisms
1. Pipes (anonymous)

Process A → [write] → Pipe → [read] → Process B

  • Unidirectional
  • Parent-child communication
  • Example: shell pipes (ls | grep foo)
2. Named pipes (FIFOs)

Any Process → /tmp/myfifo → Any Process

  • Has filesystem name
  • Unrelated processes can communicate
3. Message queues

Process A → [msg] → Queue → [msg] → Process B

  • Structured messages with types
  • Persist until read
  • System V or POSIX variants
4. Shared memory

Process A → Shared Memory Region ← Process B

  • Fastest IPC (no kernel involvement after setup)
  • Requires synchronization (mutexes, semaphores)
  • Used by: databases (e.g. PostgreSQL shared buffers), browsers' multi-process IPC
5. Sockets

Process A ⇄ [TCP/UDP/Unix socket] ⇄ Process B

  • Network or local (Unix domain sockets)
  • Bidirectional
  • Most flexible, used everywhere
6. Signals

Process A → [SIGTERM] → Process B

  • Asynchronous notifications
  • Limited information (just signal number)
  • Examples: SIGKILL, SIGTERM, SIGHUP, SIGUSR1
System design relevance
  • Microservices typically use: HTTP/gRPC over sockets (most common); message queues (Kafka, RabbitMQ, SQS)
  • Same-machine optimization: Unix domain sockets (faster than TCP loopback); shared memory (databases, caches)

Chapter 2: Threads#

2.1 Threads vs Processes#

Process vs thread
Process Thread
Owns Memory space (Stack, Heap, Data, Text), File Descriptors, PID, Environment Its own Stack, Registers, Thread ID
Shares Isolated from others Shares Heap, Data, Text (Code), File Descriptors, PID with other threads
Create Heavier to create (~0.1-1 ms) Light to create (~10μs)
Context switch ~10-100 μs (incl. TLB/cache effects) ~1-10 μs
Key differences
Aspect Process Thread
Memory Isolated Shared
Creation cost Higher (~0.1-1 ms) Low (~10μs)
Context switch Expensive Cheap
Communication IPC needed Shared memory
Crash isolation One crash, one dead One crash, all dead
Scalability Model extends to multi-machine (message passing) Single machine

2.2 Thread Models#

Threading models

1. Kernel threads (1:1)

Each user thread = one kernel thread

  • User space: [T1] [T2] [T3]
  • Kernel space: [K1] [K2] [K3] (one each)

Pros: True parallelism, simple
Cons: Kernel overhead per thread
Used by: Linux pthreads, Java threads

2. User threads (N:1)

Many user threads = one kernel thread

  • User space: [T1] [T2] [T3] [T4]
  • Kernel space: [K1] (all four map to it)

Pros: Fast creation/switching
Cons: No parallelism, one block = all block
Used by: Old green threads

3. Hybrid (M:N)

Many user threads = some kernel threads

  • User space: [T1] [T2] [T3] → [K1]
  • User space: [T4] [T5] [T6] → [K2]

Pros: Parallelism + cheap user threads
Cons: Complex scheduling
Used by: Go goroutines, Erlang processes

2.3 Concurrency vs Parallelism#

Concurrency vs parallelism
Concurrency (dealing with many things at once)

Single core, multiple tasks, interleaved

Time →
Core 1 Task A Task B Task A Task C Task B Task A

Tasks make progress, but not simultaneously. Useful for I/O-bound work.

Parallelism (doing many things at once)

Multiple cores, tasks run simultaneously

Time →
Core 1 Task A Task A Task A Task A
Core 2 Task B Task B Task B Task B
Core 3 Task C Task C Task C Task C

True simultaneous execution. Useful for CPU-bound work.

You can have
  • Concurrency without parallelism (single core, multiple tasks)
  • Parallelism without concurrency (multiple cores, one task per core)
  • Both (multiple cores, many tasks)

2.4 Thread Safety and Synchronization#

Thread safety problems
1. Race condition

Two threads access shared data, at least one writes. Result depends on timing (non-deterministic).

Thread A Thread B
read counter (0)
read counter (0)
counter = 0 + 1
counter = 0 + 1
write counter (1)
write counter (1)

Expected: 2 · Actual: 1 ← BUG!

2. Deadlock

Two threads each hold a lock the other needs

Thread A Thread B
lock(A) lock(B)
waiting for B waiting for A

Both stuck forever!

Prevention:

  • Lock ordering (always acquire in same order)
  • Lock timeout
  • Deadlock detection
3. Livelock

Threads keep responding to each other but make no progress. Like two people in hallway both stepping aside.

4. Starvation

One thread never gets resources (always preempted)

Your turn · checkpoint 1not answered

50 threads, 10 database connections

A service has 50 worker threads but only 10 database connections. You want at most 10 threads talking to the database at once, and the rest should wait their turn. Is a mutex the right tool? If not, what is?

Think it through, or jot it down. Then compare.

One strong answer

  • A mutex isn't enough. It lets exactly one thread in at a time, so 9 connections would sit idle.
  • Use a semaphore with a count of 10. Each thread calls acquire() before taking a connection, and the count drops. The 11th thread blocks until someone calls release().
  • Mutexes protect shared data, one thread at a time. Semaphores count a limited resource, N at a time.

2.5 Synchronization Primitives#

Synchronization primitives
1. Mutex (Mutual Exclusion)

Only one thread can hold at a time

text3 lines
mutex.lock()
  // Critical section - only one thread here
mutex.unlock()

Use for: Protecting shared data

2. Semaphore

Counter that allows N threads

text4 lines
sem = Semaphore(3)  // Allow 3 concurrent
sem.acquire()       // Decrement (block if 0)
  // Up to 3 threads here
sem.release()       // Increment

Use for: Connection pools, rate limiting

3. Read-write lock (RWLock)

Multiple readers OR single writer

text7 lines
rwlock.read_lock()    // Many can hold simultaneously
  // Reading shared data
rwlock.read_unlock()

rwlock.write_lock()   // Exclusive access
  // Modifying shared data
rwlock.write_unlock()

Use for: Read-heavy workloads

4. Condition variable

Wait for condition, wake when signaled

text6 lines
Producer:                     Consumer:
  mutex.lock()                  mutex.lock()
  queue.push(item)              while queue.empty():
  cond.signal()                   cond.wait(mutex)
  mutex.unlock()                item = queue.pop()
                                mutex.unlock()

Use for: Producer-consumer, thread pools

5. Spinlock

Busy-wait instead of sleeping

text4 lines
while (!lock.try_acquire()):
  // spin (waste CPU)
// critical section
lock.release()

Use for: Very short critical sections (kernel code)
Avoid for: User-space, long waits

6. Atomic operations

Hardware-supported indivisible operations

text3 lines
atomic_int counter = 0;
counter.fetch_add(1);  // Atomic increment
counter.compare_exchange(expected, desired);  // CAS

Use for: Counters, lock-free data structures

2.6 Lock-Free Programming#

Compare-and-swap (CAS)

Atomic operation that:

  1. Compares value to expected
  2. If equal, swaps with new value
  3. Returns whether swap happened
text6 lines
bool CAS(address, expected, new_value):
    // This is atomic (hardware instruction)
    if *address == expected:
        *address = new_value
        return true
    return false
Lock-free counter example
pythonLockFreeCounter · 11 lines
class LockFreeCounter:
    def __init__(self):
        self.value = AtomicInt(0)
    
    def increment(self):
        while True:
            old = self.value.get()
            new = old + 1
            if self.value.compare_and_swap(old, new):
                return new
            # CAS failed, another thread changed it, retry
Why lock-free?
  • No deadlocks
  • No priority inversion
  • Progress guarantee (someone always makes progress)
Downsides
  • Complex to implement correctly
  • Can have contention (many retries)
  • Hard to reason about

Chapter 3: Context Switching#

3.1 What Happens in a Context Switch#

Context switch process

When OS switches from Process A to Process B:

  1. Save state of Process A
    • All CPU registers (general purpose, program counter, stack pointer)
    • Memory management info (page table pointer)
    • Floating point state
    • → Saved to Process A's Process Control Block (PCB)
  2. Update scheduler
    • Mark Process A as READY (or WAITING)
    • Select Process B to run
    • Mark Process B as RUNNING
  3. Restore state of Process B
    • Load registers from Process B's PCB
    • Load page table pointer (TLB flush!)
    • Jump to Process B's program counter
Time costs

Direct costs:

  • Register save/restore: ~100-1000 cycles
  • Page table switch: ~1000+ cycles

Indirect costs (often bigger!):

  • TLB flush: Cache misses for a while
  • CPU cache pollution: Process B's data not in L1/L2
  • Pipeline flush: Branch prediction invalid

Total: 1-10 μs for threads, 10-100 μs for processes

Your turn · checkpoint 2not answered

2,000 threads on 8 cores

A server gives each request its own thread: 2,000 threads on an 8-core machine, mostly waiting on I/O. The CPU is busy, but much of that time isn't spent on application code. Where is the time going, and what would you change?

Think it through, or jot it down. Then compare.

One strong answer

  • Context switches. Only 8 threads can run at once, so the OS keeps swapping threads in and out. Each switch costs ~1–10 μs directly, plus cold caches afterwards.
  • What to change: don't block a thread per request. An event loop handles many connections on one thread, and a thread pool sized to the cores handles CPU work.
  • The trade-off: fewer switches, but async code is harder to write than plain blocking calls.

3.2 Minimizing Context Switches#

Strategies to reduce context switches
1. Use async I/O (event loop)

One thread handles many connections. No blocking = no forced context switch.

Examples: Node.js, nginx, Redis

2. Thread pools

Fixed number of threads, reused. Avoid thread creation/destruction overhead.

text2 lines
pool = ThreadPool(num_cores * 2)
pool.submit(task)
3. CPU affinity

Pin threads to specific CPU cores. Better cache utilization.

bash1 line
taskset -c 0,1 ./myprogram
4. Batch processing

Process multiple items per wake-up. Amortize context switch cost.

text2 lines
while items = queue.get_batch(100):
    process(items)
5. Cooperative scheduling

Coroutines yield voluntarily. No preemptive switches.

Examples: Python asyncio, JavaScript async/await (Go goroutines are mostly cooperative but preemptible since Go 1.14)

Quick check4 questions · +5 XP each

Processes, threads and context switching

  1. Q1/4 Two threads each run counter += 1 1,000 times on a shared counter. The final value is often below 2,000. Why?

    Answer & explanation

    Answer: B

    Both threads can read the same old value and both write old + 1, losing one increment: a race condition. Use an atomic increment or a mutex. A deadlock would hang the threads, not lose updates.

  2. Q2/4 Thread A locks accounts then audit; thread B locks audit then accounts. The service hangs now and then. What is the standard fix?

    Answer & explanation

    Answer: A

    Each thread holds the lock the other needs: a deadlock. A single lock order makes that cycle impossible. Spinlocks still deadlock, just while burning CPU.

  3. Q3/4 You must run untrusted plugin code that sometimes crashes. Why run it as separate processes rather than threads?

    Answer & explanation

    Answer: C

    Threads share one address space, so one fatal fault kills every thread. Processes are isolated. Processes cost more to create and switch between; you pay that for the isolation.

  4. Q4/4 Why is a switch between two processes more expensive than one between two threads of the same process?

    Answer & explanation

    Answer: C

    Threads share an address space, so the page tables and TLB entries stay valid. A process switch loads new page tables, and the indirect cost of lost translations and cache lines is often the bigger part. Both kinds of switch save and restore registers.

Part II · Stage 2 of 5 · 6 min

Part II: Memory Management#

Chapter 4: Virtual Memory#

4.1 Why Virtual Memory?#

Virtual memory benefits
  1. Isolation — Each process thinks it has entire address space. Can't accidentally (or maliciously) access other process's memory
  2. Simplification — Programmers use simple linear addresses. OS handles mapping to physical memory
  3. Overcommit — Can allocate more virtual memory than physical RAM. Pages stored on disk when not in use (swap)
  4. Shared memory — Multiple processes can map same physical page. Used for: shared libraries, IPC
Virtual address space (64-bit Linux)
Address Region Notes
0xFFFFFFFFFFFFFFFF – 0xFFFF800000000000 Kernel space (not accessible from user mode) Top 128 TB
0xFFFF800000000000 – 0x00007FFFFFFFFFFF (huge gap) Non-canonical addresses
0x00007FFFFFFFFFFF – 0x0000000000000000 User space: Stack (grows ↓) · ... · Heap (grows ↑) · Data · Text Bottom 128 TB
Your turn · checkpoint 3not answered

Estimate it: page table entries for 4 GB

A process maps 4 GB of memory. How many page translations does that take with normal 4 KB pages? How many with 2 MB huge pages? Why would a database care?

Think it through, or jot it down. Then compare.

One strong answer

  • 4 KB pages: 4 GB ÷ 4 KB ≈ 1 million translations.
  • 2 MB huge pages: 4 GB ÷ 2 MB = 2,048 translations.
  • The TLB holds only tens to a few thousand entries. With huge pages, far more of a database's memory is covered by cached translations, so fewer accesses pay for a slow page table walk.

4.2 Paging#

Paging: virtual to physical address translation
Virtual address Physical address
Page Number (VPN) · Offset Frame Number (PFN) · Offset

Translation: VPN → Page Table → PFN

Page table
VPN Maps to
VPN 0 PFN 3
VPN 1 PFN 7
VPN 2 [disk] ← Page fault!
VPN 3 PFN 1
...
Page size
  • Typical: 4 KB
  • Huge pages: 2 MB or 1 GB (for databases, VMs)

4 KB pages for 4 GB RAM = 1 million page table entries! Solution: Multi-level page tables

Multi-level page table (x86-64)

Virtual Address (48 bits used):

PML4 PDPT PD PT Offset
9 bits 9 bits 9 bits 9 bits 12 bits
→ Level 4 → Level 3 → Level 2 → Level 1 → Physical Frame

Sparse tables: Only allocate entries that are used

4.3 Translation Lookaside Buffer (TLB)#

Page table lookups are slow (multiple memory accesses). The TLB caches recent translations.

DiagramTLB · page table cache
%% title: TLB · page table cache
flowchart TD
  CPU["CPU"]:::client --> VA["Virtual address"]:::service
  VA --> TLB{"TLB<br/>(Cache)"}:::cache
  TLB ==>|"HIT"| PA["Physical address"]:::ok
  PA --> MEM[("Memory")]:::store
  TLB -->|"MISS"| PTW["Page table walk<br/>(slow: multiple memory reads)"]:::bad
  PTW --> UPD["Update TLB, return physical address"]:::service

TLB characteristics:

  • Size: ~64 (L1) to a few thousand (L2) entries (tiny!)
  • Hit rate: 99%+ for well-behaved programs
  • Miss penalty: 10-100 cycles

TLB flush:

  • Happens on process switch (page table changes)
  • Expensive! All cached translations lost
  • PCID (Process Context ID) can help avoid full flush

4.4 Page Faults#

Page fault types
Type What happens Causes Resolution Cost
1. Minor page fault (Soft) Page is in memory but not mapped in page table First access to newly allocated memory; Copy-on-write after fork() Update page table, no disk I/O ~1-10 μs
2. Major page fault (Hard) Page is not in memory, must read from disk Page was swapped out; Memory-mapped file access Read page from disk ~1-10 ms on HDD, ~0.1 ms on SSD (~1000x slower than minor on HDD!)
3. Invalid page fault Access to unmapped or protected memory Null pointer dereference; Stack overflow; Writing to read-only memory SIGSEGV (crash)

4.5 Memory Allocation#

Memory allocation strategies
User space (malloc/free) — the heap
Used Free Used Free Used Free Unallocated
32B 64B 128B 32B 256B 128B

Allocators: glibc malloc, jemalloc, tcmalloc

Allocation strategies
  1. First Fit: Use first free block that fits
  2. Best Fit: Use smallest free block that fits
  3. Buddy System: Powers of 2, split/merge
Fragmentation

External: Free memory scattered in small pieces

Used Free Used Free Used Free Used Free
64B 32B 64B 32B

Total free: 192B — but can't allocate 100B!

Internal: Allocated block larger than requested

  • Requested: 100 bytes
  • Allocated: 128 bytes (next power of 2)
  • Wasted: 28 bytes

Chapter 5: Caching and Memory Hierarchy#

5.1 Memory Hierarchy#

Memory hierarchy (latency & size)
Level (fastest first) Latency Size
Registers < 0.5 ns bytes
L1 Cache ~0.5-1 ns 32-64 KB (per core)
L2 Cache ~4-7 ns 256 KB - 1 MB (per core)
L3 Cache ~10-20 ns 8-64 MB (shared)
RAM ~100 ns 16-512 GB
SSD ~100-150 μs 256 GB - 8 TB
HDD ~10 ms 1-20 TB
Latency reference (approximate)

Same figures as Part 3's latency table (Jeff Dean's numbers). Newer CPUs and NVMe SSDs sit at the faster end of the ranges above.

Operation Latency
L1 cache reference 0.5 ns
L2 cache reference 7 ns
L3 cache reference 20 ns
RAM reference 100 ns
SSD random read 150,000 ns (150 μs)
HDD seek 10,000,000 ns (10 ms)
  • L1 is ~200x faster than RAM
  • RAM is ~1,000x faster than SSD (1,500x with these figures)
  • SSD is ~70x faster than HDD for random reads

5.2 CPU Cache Behavior#

CPU cache concepts
Cache line
  • Unit of data transfer between cache levels
  • Typically 64 bytes
  • Even if you read 1 byte, 64 bytes are fetched

Memory: [........][........][........][........] — each block is 64 bytes

Cache associativity
Type How it maps Trade-off
Direct Mapped (1-way) Each memory address maps to exactly one cache location Fast but conflicts
N-way Set Associative Address can be in N different locations Better hit rate, slightly slower
Fully Associative Can be anywhere in cache Best hit rate, slowest lookup
Cache coherence (multi-core)

Problem: Multiple cores have copies of same data

  • Core 1 cache: [x = 5]
  • Core 2 cache: [x = 5]
  • Core 1: x = 10 (updates its cache)
  • Core 2: reads x (sees stale value 5!)

Solution: MESI Protocol

State Meaning
Modified Only this cache has it, dirty
Exclusive Only this cache has it, clean
Shared Multiple caches have it, clean
Invalid Cache line is invalid

5.3 Cache-Friendly Code#

Cache-friendly programming
1. Sequential access (Good)
c4 lines
// Array stored: [0][1][2][3][4][5][6][7]

for (int i = 0; i < N; i++)
    sum += array[i];  // Sequential, cache-friendly

Each cache line (64B) loads ~16 integers. Next iteration hits cache (spatial locality).

2. Strided access (Bad)
c11 lines
// 2D array stored row-major: [0,0][0,1][0,2]...[1,0][1,1]...

// BAD: Column-major traversal
for (int j = 0; j < cols; j++)
    for (int i = 0; i < rows; i++)
        sum += matrix[i][j];  // Jumping around, cache misses!

// GOOD: Row-major traversal
for (int i = 0; i < rows; i++)
    for (int j = 0; j < cols; j++)
        sum += matrix[i][j];  // Sequential, cache-friendly
3. Structure of Arrays (SoA) vs Array of Structures (AoS)
cEntity · 16 lines
// AoS (Array of Structures) - common but often slower
struct Entity { float x, y, z; int health; bool active; };
Entity entities[1000];

// To sum all x values: loads entire struct but only uses x
for (i = 0..999) sum += entities[i].x;  // Cache pollution!

// SoA (Structure of Arrays) - cache-friendly
struct Entities {
    float x[1000], y[1000], z[1000];
    int health[1000];
    bool active[1000];
};

// To sum all x values: only loads x array
for (i = 0..999) sum += entities.x[i];  // Cache-friendly!

5.4 NUMA (Non-Uniform Memory Access)#

In multi-socket systems, memory access time depends on location:

DiagramNUMA architecture
%% title: NUMA architecture
flowchart TD
  subgraph S1["Socket 1"]
    C2@{ icon: "sd:cpu", label: "CPU 2" } --> MC1["Memory Controller"]:::service
    C3@{ icon: "sd:cpu", label: "CPU 3" } --> MC1
    MC1 --> R1[("RAM Node 1<br/>64 GB")]:::store
  end
  subgraph S0["Socket 0"]
    C0@{ icon: "sd:cpu", label: "CPU 0" } --> MC0["Memory Controller"]:::service
    C1@{ icon: "sd:cpu", label: "CPU 1" } --> MC0
    MC0 --> R0[("RAM Node 0<br/>64 GB")]:::store
  end
  MC0 <-->|"QPI/UPI (slow)"| MC1
  class C0,C1,C2,C3 core
  • Local access (Node 0 → RAM 0): ~100 ns
  • Remote access (Node 0 → RAM 1): ~150-200 ns (1.5-2x slower!)

NUMA-aware programming:

  1. Allocate memory on local node — numactl --membind=0 ./myprogram
  2. Bind threads to cores on same node as their data — numactl --cpunodebind=0 ./myprogram
  3. Database optimization
    • Partition data by NUMA node
    • Pin worker threads to nodes
Quick check2 questions · +5 XP each

Virtual memory and the memory hierarchy

  1. Q1/2 A service's p99 jumps from 2ms to 40ms once its memory use exceeds physical RAM. What is the most likely cause?

    Answer & explanation

    Answer: B

    A major fault means disk I/O, about 1-10ms each, roughly 1000x a minor fault. Swapping turns memory accesses into disk reads. Minor faults only update the page table (microseconds), so they cannot explain jumps of tens of milliseconds.

  2. Q2/2 Summing a large row-major 2D array column by column is several times slower than row by row. Why?

    Answer & explanation

    Answer: A

    A 64-byte cache line brings in about 16 adjacent ints. Row order uses them all (spatial locality); column order uses one and moves on. The arithmetic is identical; the gap is memory access pattern.

Part III · Stage 3 of 5 · 7 min

Part III: I/O Models#

Chapter 6: I/O Fundamentals#

6.1 Blocking vs Non-Blocking I/O#

DiagramBlocking vs non-blocking I/O
%% title: Blocking vs non-blocking I/O
sequenceDiagram
  participant T as Thread
  participant K as Kernel
  Note over T,K: BLOCKING I/O
  T->>K: read(fd, buf, size)
  Note left of T: Thread blocked/sleeping<br/>Cannot do other work
  Note right of K: Waiting for data<br/>from disk/network
  K-->>T: Data returned
  Note left of T: Thread continues
  Note over T,K: Problem: Thread is idle while waiting<br/>Solution: More threads (but threads are expensive)
  Note over T,K: NON-BLOCKING I/O
  T->>K: read(fd, buf, size)
  K-->>T: EAGAIN (would block)
  Note right of K: No data yet
  Note left of T: Do other work
  T->>K: read(fd, buf, size)
  K-->>T: EAGAIN
  Note right of K: Still no data
  Note left of T: Do other work
  T->>K: read(fd, buf, size)
  K-->>T: Data
  Note right of K: Data ready!
  Note over T,K: Problem: Busy polling wastes CPU<br/>Solution: Event notification (select/poll/epoll)
Your turn · checkpoint 4not answered

10,000 sockets, one of them ready

A server watches 10,000 open sockets, and at any moment only a few have data. Picture a call that hands the kernel the full list every time and checks every socket. Why does that get slower as connections grow? What would you want instead?

Think it through, or jot it down. Then compare.

One strong answer

  • Each call is O(n): the kernel scans all 10,000 sockets to find the one that's ready, and the list is copied and rebuilt every time. Double the connections and you double the wasted work.
  • What you want: register each socket once and have the kernel return only the ready ones.
  • That's the move from select/poll to epoll (kqueue on BSD/macOS), and it's why one event loop can handle tens of thousands of connections.

6.2 I/O Multiplexing#

I/O multiplexing: monitoring many file descriptors

Instead of: One thread per connection — Use: One thread monitors many connections

I/O multiplexing evolution

1. select (1983)

c1 line
ready = select(fds, read_set, write_set, error_set, timeout)

Limitations:

  • Max 1024 file descriptors (FD_SETSIZE)
  • O(n) kernel scan of all fds
  • Must rebuild fd sets after each call

2. poll (1986)

c1 line
ready = poll(fds[], nfds, timeout)

Improvements over select:

  • No fd limit
  • Separate input/output events

Still has:

  • O(n) kernel scan
  • Copy entire array to/from kernel

3. epoll (Linux 2002)

c3 lines
epfd = epoll_create()
epoll_ctl(epfd, ADD, fd, events)  // Register once
ready = epoll_wait(epfd, events, max, timeout)

Advantages:

  • O(1) for monitoring (kernel maintains state)
  • Only returns ready fds (not all fds)
  • Edge-triggered mode for high performance

4. kqueue (BSD/macOS) — Similar to epoll, used on BSD/macOS

5. IOCP (Windows) — I/O Completion Ports. True async I/O (completion-based, not readiness-based)

6.3 Event-Driven Architecture#

Event loop (single-threaded async)
python13 lines
while (running):
    events = epoll_wait(epfd, timeout)   # ◀── Block until ready

    for event in events:
        if event.type == READABLE:
            data = read(event.fd)
            process(data)

        if event.type == WRITABLE:
            write(event.fd, pending_data)

    run_timers()
    run_callbacks()
DiagramReactor pattern
%% title: Reactor pattern
flowchart TD
  subgraph CL["Clients"]
    c1@{ icon: "sd:user", label: "Client 1" }
    c2@{ icon: "sd:user", label: "Client 2" }
    c3@{ icon: "sd:user", label: "Client 3" }
  end
  c1 & c2 & c3 --> R["Reactor (Event Loop)<br/>Demultiplexer (epoll)"]:::core
  R --> H1@{ icon: "sd:api", label: "HTTP Handler" }
  R --> H2@{ icon: "tech:websocket", label: "WS Handler" }
  R --> H3@{ icon: "sd:file", label: "File Handler" }
  subgraph HD["Handlers"]
    H1
    H2
    H3
  end
  class c1,c2,c3 client
  class H1,H2,H3 service

Used by: Node.js (libuv) · nginx · Redis · Python asyncio

6.4 Async I/O Patterns#

I/O patterns comparison
1. Thread-per-connection

Main Thread: accept() → spawn new thread

  • Thread 1: handle(client1) [blocking I/O]
  • Thread 2: handle(client2) [blocking I/O]
  • Thread 3: handle(client3) [blocking I/O]
  • ...

Pros: Simple programming model
Cons: Threads expensive, doesn't scale past ~10K connections
Used: Traditional web servers (Apache worker MPM; prefork uses a process per connection)

2. Thread pool

Main Thread: accept() → queue task. Worker Pool: threads pull from queue.

Acceptor → Queue → Workers (Thread 1, Thread 2, Thread 3)

Pros: Bounded threads, task queuing
Cons: Still blocking I/O, limited concurrency
Used: Java servlet containers

3. Event-driven (single thread)

Single Thread: epoll_wait → handle ready events → repeat

Pros: Massive concurrency, no thread overhead
Cons: Can't use multiple cores, callback hell
Used: Node.js, Redis

4. Hybrid (event loop + thread pool)
  • Event Loop: handles I/O, delegates CPU work
  • Thread Pool: handles CPU-intensive tasks

Event Loop (I/O bound) ⇄ Thread Pool (CPU bound)

Pros: Best of both worlds
Cons: More complex
Used: Node.js (libuv workers), nginx

5. Multi-threaded event loops

Multiple event loops, one per core

Core Event loop Handles connections
Core 0 Event Loop 0 0, 4, 8, ...
Core 1 Event Loop 1 1, 5, 9, ...
Core 2 Event Loop 2 2, 6, 10, ...
Core 3 Event Loop 3 3, 7, 11, ...

Pros: Scales to multiple cores
Cons: Cross-thread communication needed
Used: nginx (worker processes), Go runtime

ExplainerThread per connection vs an epoll event loop

With a thread per connection, each thread blocks in read() and sits idle until its data arrives, which doesn't scale past about 10K connections. An event loop registers every connection with epoll once; one thread blocks in epoll_wait, gets back only the ready connections, handles them in turn and loops.

6.5 io_uring (Linux)#

Traditional async I/O problems:

  • epoll: Readiness-based (still need syscall to read)
  • aio (Linux native): Limited (effectively O_DIRECT files only, not network)

io_uring (Linux 5.1+):

  • True async I/O for files AND network
  • Submission and completion queues in shared memory
  • Batching: Submit many operations with one syscall
Diagramio_uring · next-generation Linux async I/O
%% title: io_uring · next-generation Linux async I/O
flowchart TD
  subgraph US["User space"]
    SQ[["Submission Queue (SQ)<br/>[op1][op2][op3]"]]:::queue
    CQ[["Completion Queue (CQ)<br/>[result1][res2]<br/>User polls for completions<br/>No syscall needed!"]]:::queue
  end
  subgraph KS["Kernel space"]
    KP["Kernel Processing"]:::core
  end
  SQ <-->|"Shared Memory"| KP

Benefits:

  • Batch submissions (one syscall for many ops)
  • Zero-copy where possible
  • Poll mode: no syscalls at all
  • Supports: read, write, accept, connect, send, recv, etc.

Chapter 7: File Systems#

7.1 File System Basics#

DiagramFile system structure
%% title: File system structure
flowchart TD
  subgraph USR["User space"]
    APP@{ icon: "sd:code", label: "Application: open(#quot;/data/file.txt#quot;)" }
  end
  subgraph KRN["Kernel space"]
    VFS["Virtual File System (VFS)<br/>Common interface for all FS"]:::core
    E4@{ icon: "sd:folder", label: "ext4" }
    XFS@{ icon: "sd:folder", label: "XFS" }
    NFS@{ icon: "sd:network", label: "NFS" }
    BL["Block Layer<br/>(I/O scheduling, cache)"]:::cache
    VFS --> E4 & XFS & NFS
    E4 & XFS & NFS --> BL
  end
  subgraph HWG["Hardware"]
    HW@{ icon: "sd:database", label: "HDD, SSD, NVMe" }
  end
  APP -->|"System Call"| VFS
  BL --> HW
  class APP client
  class E4,XFS,NFS service
  class HW store

7.2 Inodes and Directory Structure#

Inode: file metadata
  • inode number: 12345
    • File type — regular, directory, symlink, etc.
    • Permissions — rwxr-xr-x
    • Owner — UID
    • Group — GID
    • Size — bytes
    • Timestamps — atime, mtime, ctime
    • Link count
    • Data block pointers
      • Direct blocks (12; ext2/3 layout, ext4 uses extents) — point directly to data
      • Indirect block — points to block of pointers
      • Double indirect — pointer → pointers → pointers → data
      • Triple indirect — one more level

Note: Filename is NOT in inode! Directory maps filename → inode number.

Directory structure — directory "mydir" (inode 100)
Name Inode
. 100 (self)
.. 50 (parent)
file.txt 12345
subdir 200
link.txt 12345 (hardlink)

7.3 Page Cache (Buffer Cache)#

Page cache: OS-level file caching

Application → read(fd, buf, 4096) → Page cache (RAM)

Page 0 Page 1 Page 2 Page 3 ... Page N
(file1) (file1) (file2) (file3)
  • Cache HIT: Return from RAM (~1 μs incl. syscall + copy)
  • Cache MISS: Read from disk, cache, return (~10ms for HDD)
Write behavior
  1. Write-back (default): Write to cache → return immediately → flush later. Fast but data loss risk on crash
  2. Write-through (O_SYNC/O_DSYNC): Write to cache AND disk → return. Slower but safer
  3. O_DIRECT: Bypass cache entirely. Used by databases that manage own cache
  4. fsync(): Force flush to disk. Ensures durability
System design impact
Situation Response
Reading same file twice? → Second read from cache (fast)
Too many dirty pages / write stalls? → Tune vm.dirty_ratio, vm.dirty_background_ratio
Database doing own caching? → Use O_DIRECT to avoid double caching
Need durability? → fsync() after writes (databases do this)
Your turn · checkpoint 5not answered

What breaks if the power fails mid-update?

A database applies a commit by overwriting the changed pages in its data files. The power fails halfway through writing one of them. What state is that data in after reboot? How would you design commits so they survive this?

Think it through, or jot it down. Then compare.

One strong answer

  • The page is half old, half new: corrupted. Updating in place isn't atomic, and nothing records what the write was meant to be.
  • The fix: first append the change to a log and fsync() it, then update the data files. After a crash, replay the committed entries from the log.
  • Appending to the log is sequential, so it's fast enough to do on every commit. The slow random writes to the data files can happen later.

7.4 Write-Ahead Logging (WAL)#

Write-ahead logging (WAL)

Problem: Updating data in place is not atomic. Crash mid-write = corrupted data.

Solution: Write to log FIRST, then apply changes. On crash recovery, replay log.

Steps
  1. Write to WAL (append-only, sequential)
  2. fsync() WAL
  3. Apply changes to data files
  4. Eventually checkpoint and truncate WAL
Flows
  • Normal operation: Transaction → WAL → fsync → Data Files
  • Crash recovery: Read WAL → Replay committed transactions (discard/undo uncommitted ones) → Consistent state
Why sequential writes?
  • HDD sequential: ~100 MB/s
  • HDD random: ~1 MB/s
  • WAL is append-only → sequential → fast!
Used by
  • PostgreSQL (WAL)
  • MySQL InnoDB (redo log)
  • SQLite (WAL mode)
  • Kafka (append-only log)
  • LSM-tree databases (RocksDB, LevelDB)
Quick check4 questions · +5 XP each

I/O models and file systems

  1. Q1/4 A push-notification gateway must hold 50,000 mostly idle connections open. Which I/O model fits?

    Answer & explanation

    Answer: A

    epoll returns only ready sockets, so one thread can watch tens of thousands of idle connections cheaply. Thread-per-connection stops scaling around 10K connections, and a 50-thread blocking pool can only serve 50 sockets at a time.

  2. Q2/4 In a single-threaded Node.js server, one request handler runs a 2-second CPU-heavy computation. What happens to the other connections?

    Answer & explanation

    Answer: B

    The event loop only switches tasks when a handler yields. CPU work never yields, so everything waits; that is why the hybrid model offloads it. Event loops are great for I/O-bound work, not long CPU-bound work.

  3. Q3/4 write() returns success, then the machine loses power and the data is gone. What did the application skip?

    Answer & explanation

    Answer: C

    By default writes land in the page cache and return immediately; the flush to disk happens later. fsync() forces it. O_DIRECT is for avoiding double caching. The durability guarantee comes from fsync, which is why databases fsync the WAL.

  4. Q4/4 Why does a database append to a write-ahead log and fsync it, instead of fsyncing changed data pages in place on each commit?

    Answer & explanation

    Answer: C

    Sequential I/O is much faster than random, about 100 MB/s vs 1 MB/s on HDD. The log makes commits durable, and the data files catch up later. Compression is not the point; append-only access is.

Part IV · Stage 4 of 5 · 3 min

Part IV: System Limits and Tuning#

Chapter 8: File Descriptors#

8.1 Understanding File Descriptors#

File descriptors

Everything in Unix is a file (or file-like):

  • Regular files
  • Directories
  • Sockets (network connections)
  • Pipes
  • Devices

File Descriptor = integer handle to open file/resource

Process file descriptor table
fd Description Points to
0 stdin Terminal input
1 stdout Terminal output
2 stderr Terminal error
3 log file /var/log/app.log
4 socket TCP connection to database
5 socket TCP connection to client
6 socket TCP connection to client
...
Limits

Per-process limit:

bash2 lines
ulimit -n         # Show current limit (often 1024 default)
ulimit -n 65535   # Increase for this shell

System-wide limit:

bash1 line
cat /proc/sys/fs/file-max   # System maximum

For servers handling many connections: each TCP connection = 1 file descriptor, so 10,000 connections = need at least 10,000 fd limit

8.2 Common Limits to Tune#

System limits to tune
Limit Default Tuned value Why
File descriptors
ulimit -n (/etc/security/limits.conf) 1,024 65,535+ Many connections
Processes/threads
ulimit -u / kernel.pid_max 32,768 (pid_max; many distros now set 4,194,304) 100,000+ Thread pool size
TCP tuning
net.core.somaxconn 4,096 (128 before Linux 5.4) 65,535 Listen backlog
net.ipv4.tcp_max_syn_backlog 128+ (scales with RAM) 65,535 SYN queue size
net.ipv4.ip_local_port_range 32768-60999 1024-65535 More ports
net.ipv4.tcp_tw_reuse 2 (loopback only; 0 before Linux 4.19) 1 Reuse TIME_WAIT
Memory
vm.swappiness 60 10 Reduce swap usage
vm.dirty_ratio 20 60 Write-back tuning
vm.overcommit_memory 0 1 Redis requirement
Network buffers
net.core.rmem_max 212992 16777216 TCP receive buf
net.core.wmem_max 212992 16777216 TCP send buffer
Checking current values
bash2 lines
sysctl -a | grep <parameter>
cat /proc/sys/net/core/somaxconn
Setting values

Temporary (until reboot):

bash1 line
sysctl -w net.core.somaxconn=65535

Permanent:

bash2 lines
echo "net.core.somaxconn = 65535" >> /etc/sysctl.conf
sysctl -p
WorksheetOne server, 1 million connections (assumptions labelled)
Value
Concurrent connections (the interview question in Chapter 10) 1,000,000
Thread-per-connection: stack per thread (assumed ~1 MB default) 1,000,000 × 1 MB ≈ 1 TB of stacks
Event loops: state per connection (assumed under 1 KB) 1,000,000 × 1 KB ≈ 1 GB
Cores, one event loop each (assumed) 32 → ~31,000 connections per loop
File descriptors needed 1 per connection → 1,000,000, against a common default ulimit -n of 1,024

Conclusion: memory per connection decides the model. A thread per connection needs about a terabyte of stacks, and event loops need about a gigabyte. Either way, the default file descriptor limit stops the server at about 1,000 clients until it's raised.

Chapter 9: Containerization and Namespaces#

9.1 Linux Namespaces#

Namespaces: isolation for containers

Namespaces provide isolated views of system resources:

Namespace Isolates Example
PID Process IDs Container sees PID 1
NET Network stack Own IP, ports, routes
MNT Mount points Own filesystem view
UTS Hostname, domain Container hostname
IPC IPC resources Own message queues
USER User/group IDs Root in container
CGROUP Cgroup visibility Own cgroup root
Container = process with all namespaces isolated
Host Container
Processes PID 1: systemd · PID 2: ... · ... · PID 25463: nginx PID 1: nginx (actually 25463 on host) · PID 2: nginx worker
Network eth0 (10.0.0.5) eth0 (172.17.0.2)
Filesystem / / (overlay on host)

9.2 Control Groups (cgroups)#

Cgroups: resource limits for containers

Resource limits and accounting for process groups (cgroup v1 names below; v2 uses cpu.max, cpu.weight, memory.max, io.max):

CPU
Setting Meaning
cpu.shares Relative CPU weight
cpu.cfs_quota_us Hard CPU limit (μs per period)
cpu.cfs_period_us Period length (default 100ms)

Example: 50% CPU = quota 50000, period 100000

Memory
Setting Meaning
memory.limit_in_bytes Hard memory limit
memory.soft_limit_in_bytes Soft limit (reclaim target)
memory.oom_control OOM killer behavior

Container exceeds limit → OOM killed

I/O
Setting Meaning
blkio.throttle.read_bps_device Read bandwidth limit
blkio.throttle.write_bps_device Write bandwidth limit
Network

(Handled by tc/iptables, not cgroups directly)

Kubernetes resource requests/limits
yaml12 lines
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    resources:
      requests:           # Scheduling guarantee
        memory: "256Mi"
        cpu: "250m"       # 0.25 cores
      limits:             # Hard ceiling
        memory: "512Mi"   # OOM killed if exceeded
        cpu: "500m"       # Throttled if exceeded
Quick check2 questions · +5 XP each

System limits and containers

  1. Q1/2 A new server rejects connections with "Too many open files" at about 1,000 concurrent clients, though CPU and memory are fine. What should you check first?

    Answer & explanation

    Answer: A

    Every TCP connection is a file descriptor, and the default limit is often 1,024, so it runs out around 1,000 clients. Swappiness and CPU quotas affect performance, not the count of open sockets.

  2. Q2/2 A Kubernetes pod has limits of cpu: 500m and memory: 512Mi. What happens when it tries to exceed each?

    Answer & explanation

    Answer: B

    CPU is compressible, so the cgroup just gives you fewer time slices. Memory is not, so going over the hard limit triggers the OOM killer. That difference matters: a memory leak restarts pods, while a CPU spike just slows them down.

Part V · Stage 5 of 5 · 8 min

Part V: Interview Questions and Answers#

Chapter 10: Common Interview Questions#

10.1 Process and Thread Questions#

Process and thread interview questions
Q: "What's the difference between a process and a thread?"

Great answer:

"A process is an independent execution unit with its own memory space, file descriptors, and system resources. Processes are isolated from each other — one crashing doesn't affect others.

A thread is a lightweight execution unit within a process. Multiple threads share the same memory space, heap, and file descriptors, but each has its own stack and registers.

Key differences:

  • Memory: Processes isolated, threads share
  • Creation cost: Process ~0.1-1 ms, thread ~10μs
  • Communication: Processes need IPC, threads share memory directly
  • Crash impact: Process crash is isolated, thread crash kills whole process

I'd use multiple processes when I need isolation (like running untrusted code) or want to leverage multiple machines. I'd use threads when I need shared state and low-latency communication within a single machine.

Modern systems often combine both — like nginx using multiple worker processes, each with async I/O handling many connections."


Q: "How would you handle 1 million concurrent connections?"

Great answer:

"This is the C10K problem scaled up 100x (C10M is the 10-million version). The key is minimizing overhead per connection.

First, avoid thread-per-connection. At 1M connections, that's 1M threads, each using memory for stack (~1MB default). That's 1TB just for stacks!

Instead, I'd use:

  1. Event-driven architecture: epoll/kqueue with non-blocking I/O. One thread can handle thousands of connections by only processing when data is ready.

  2. Multiple event loops: One per CPU core. With 32 cores, each handles ~30K connections.

  3. System tuning:

    • File descriptor limits: at least 1M
    • Ephemeral port range expanded
    • TCP buffer sizes optimized
    • somaxconn increased for accept queue
  4. Memory efficiency:

    • Connection state < 1KB per connection
    • Zero-copy I/O where possible
    • Object pooling to avoid allocation overhead
  5. Consider io_uring on modern Linux for even better performance — it reduces syscall overhead significantly.

Companies like Discord and WhatsApp handle millions of connections using these techniques."

10.2 Memory Questions#

Memory interview questions
Q: "Explain virtual memory and why it's important."

Great answer:

"Virtual memory gives each process the illusion of having its own contiguous address space, even though physical memory is shared and fragmented.

How it works:

  • Each process has a page table mapping virtual to physical addresses
  • CPU's MMU translates addresses on every memory access
  • TLB caches recent translations for performance

Key benefits:

  1. Isolation: Process A can't access Process B's memory. Even if both use virtual address 0x1000, they map to different physical frames.

  2. Simplification: Programmer doesn't worry about where memory physically is. malloc() returns an address that just works.

  3. Overcommit: Can allocate more virtual memory than physical RAM. Pages not actively used can be swapped to disk.

  4. Shared memory: Same physical page can appear in multiple address spaces. Used for shared libraries (libc loaded once, shared by all).

  5. Memory-mapped files: Map files into address space, let OS handle I/O.

The cost is the page table walk on TLB miss (~100 cycles). That's why TLB hit rate matters, and why huge pages (2MB instead of 4KB) can help — fewer TLB entries needed to cover same memory."


Q: "What happens when a process accesses memory that's not in RAM?"

Great answer:

"That triggers a page fault. There are three types:

  1. Minor page fault: The page exists in memory but isn't mapped in the page table. Common after fork() with copy-on-write, or first access to newly allocated memory. OS just updates the page table. Cost: ~1μs.

  2. Major page fault: The page isn't in memory at all. Either it was swapped out, or it's a memory-mapped file being accessed. OS must read from disk. Cost: ~10ms for HDD, ~100μs for SSD.

  3. Invalid fault: The address isn't valid — null pointer, stack overflow, or permission violation. Results in SIGSEGV (segmentation fault).

For high-performance systems, you want to minimize major page faults:

  • Disable swap for databases (or use fast NVMe for swap)
  • Pre-fault memory on startup (touch all pages)
  • Use mlock() to prevent critical pages from swapping
  • Monitor page fault rates (vmstat, perf)

Latency-sensitive stores like Elasticsearch recommend disabling swap — an OOM is better than unpredictable latency from page faults."

10.3 I/O Questions#

I/O interview questions
Q: "Explain the different I/O models in Linux."

Great answer:

"There are five main I/O models, each with different tradeoffs:

  1. Blocking I/O: read() blocks until data available. Simple but wastes thread while waiting. One thread per connection doesn't scale.

  2. Non-blocking I/O: read() returns immediately with EAGAIN if no data. Application must poll repeatedly. Wastes CPU cycles polling.

  3. I/O Multiplexing (select/poll/epoll): One syscall monitors many file descriptors. epoll_wait() blocks until ANY descriptor is ready, then returns which ones. Efficient for many connections. This is what nginx, Node.js, Redis use.

  4. Signal-driven I/O: Kernel sends signal when I/O is ready. Application handles in signal handler. Rarely used — signals are tricky.

  5. Async I/O (aio, io_uring): Submit I/O operations, kernel completes them in background. io_uring on modern Linux is the most efficient — submissions and completions via shared memory rings, minimal syscalls.

For a high-performance server, I'd use epoll (or kqueue on BSD) for network I/O. For disk I/O where async matters (like databases), I'd consider io_uring on Linux 5.1+.

The key insight: match I/O model to workload. CPU-bound work needs threads. I/O-bound work with many connections needs event-driven."


Q: "How does epoll differ from select?"

Great answer:

"Both monitor multiple file descriptors, but epoll is far more efficient at scale.

select() problems:

  • Limited to 1024 fds (FD_SETSIZE)
  • O(n) kernel scan of ALL fds each call
  • Must rebuild fd_set after each call
  • Returns all fds, you check which are ready

epoll() solutions:

  • No fd limit (just system resources)
  • O(1) monitoring — kernel maintains interest list
  • Register once with epoll_ctl(), monitor forever
  • Only returns READY fds, not all fds

Performance comparison with 10,000 connections:

  • select: Scan 10,000 fds per call, even if only 1 is ready
  • epoll: Return immediately with just the 1 ready fd

epoll also supports edge-triggered mode: only notifies on state CHANGE, not while state IS ready. More efficient but requires draining the buffer completely each notification.

For any serious server handling more than a few hundred connections, epoll (or kqueue on macOS/BSD) is essential."

10.4 Concurrency Questions#

Concurrency interview questions
Q: "What is a deadlock and how do you prevent it?"

Great answer:

"A deadlock is when two or more threads are waiting for each other to release resources, creating a cycle where none can proceed.

Four conditions must ALL be true for deadlock:

  1. Mutual exclusion: Resources can't be shared
  2. Hold and wait: Thread holds one resource while waiting for another
  3. No preemption: Resources can't be forcibly taken
  4. Circular wait: A cycle exists in the wait graph

Prevention strategies break one condition:

  1. Lock ordering: Always acquire locks in the same order globally. If everyone acquires A before B, no cycle is possible.

  2. Lock timeout: Don't wait forever. Try to acquire, timeout, release all locks, retry with backoff. Breaks 'no preemption' (the thread gives up what it holds).

  3. Single lock: Use one coarse-grained lock instead of many fine-grained ones. Simpler but reduces parallelism.

  4. Lock-free algorithms: Use atomic operations (CAS) instead of locks. No locks = no deadlock. But complex to implement correctly.

  5. Deadlock detection: Build wait-for graph, detect cycles, abort one transaction. Used by databases.

In practice, I use a combination:

  • Lock ordering for related locks
  • Timeouts for external resources
  • Careful design to minimize lock scope"

Q: "What's the difference between a mutex and a semaphore?"

Great answer:

"Both are synchronization primitives, but with different semantics:

Mutex (Mutual Exclusion):

  • Binary: locked or unlocked
  • Ownership: Thread that locks must unlock
  • Purpose: Protect critical sections (one thread at a time)
  • Use case: Protecting shared data structure

Semaphore:

  • Counting: Can allow N threads (not just 1)
  • No ownership: Any thread can signal (increment)
  • Purpose: Control access to limited resources
  • Use case: Connection pool with max 10 connections

Example differences:

Mutex for protecting a map:

text3 lines
mutex.lock()
map[key] = value  // Only one thread here
mutex.unlock()

Semaphore for connection pool:

text6 lines
sem = Semaphore(10)     // Allow 10 concurrent
sem.acquire()           // Blocks if 10 are already held
conn = pool.get()
// use connection
pool.release(conn)
sem.release()           // Allow another thread in

Binary semaphore (count=1) looks like a mutex but lacks ownership — any thread can release it. This makes it suitable for signaling between threads (producer signals consumer) but a poorer fit for protecting critical sections (no ownership checks, no priority inheritance).

I prefer mutex for protection, semaphore for resource counting."

Summary#

OS concepts key takeaways
Processes & threads
  • Process: isolated memory, heavier, model extends to multiple machines
  • Thread: shared memory, light, single-machine only
  • Modern: hybrid (Go goroutines, event loops + thread pools)
Synchronization
  • Mutex: one thread at a time, has ownership
  • Semaphore: N threads, counting, no ownership
  • RWLock: many readers OR one writer
  • Atomic: lock-free, hardware-supported
Memory
  • Virtual memory: isolation, simplification, overcommit
  • Page fault: minor (fast) vs major (disk I/O, slow)
  • Cache hierarchy: L1 (0.5ns) → L2 (7ns) → L3 (20ns) → RAM (100ns)
  • Cache-friendly code: sequential access, SoA over AoS
I/O models
  • Blocking: simple but doesn't scale
  • Non-blocking + epoll: handles many connections efficiently
  • io_uring: next-gen async I/O on Linux
  • Event loop: single thread handles thousands of connections
File systems
  • Page cache: OS caches file data in RAM
  • fsync(): ensures durability (flush to disk)
  • WAL: write-ahead log for crash recovery
System tuning
  • File descriptors: increase ulimit for many connections
  • TCP: somaxconn, port range, buffer sizes
  • Memory: swappiness, dirty ratios
Containers
  • Namespaces: isolation (PID, NET, MNT, etc.)
  • Cgroups: resource limits (CPU, memory, I/O)

Further Reading#

Books#

  • Read

    Operating Systems: Three Easy Pieces (free online)

  • Book

    Linux Kernel Development by Robert Love

  • Book

    Understanding the Linux Kernel by Bovet & Cesati

  • Book

    Systems Performance by Brendan Gregg

Documentation#

Tools & Observability#

  • Read

    perf: Linux profiling

  • Read

    strace: System call tracing

  • Read

    vmstat: Virtual memory statistics

  • Read

    iostat: I/O statistics

  • Read

    htop/top: Process monitoring

  • Read

    bpftrace: Dynamic tracing

Blog Posts & Articles#

Videos#

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Part 05 · Part 5: Operating System Concepts for System Design
Finish Part 05Stages clear as you read them (0/5 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

NetflixBlog post · ~11 min

Linux Performance Analysis in 60,000 Milliseconds

Brendan Gregg · 2015
What
Netflix's checklist of ten standard Linux commands to run in the first minute on a slow server.
Key idea
uptime, vmstat, mpstat, iostat, free, sar and top together show CPU saturation, memory pressure, disk and network I/O at a glance.
Why here
Every command maps to a concept in this part (run queues, context switches, page cache, I/O wait), so it turns theory into a debugging routine.
taught here: Processes, threads and I/O
CloudflareBlog post · ~10 min

How to receive a million packets per second

Marek Majkowski · 2015
What
An experiment in receiving 1M UDP packets per second on Linux, step by step.
Key idea
Throughput is bounded by how the kernel spreads packets across RX queues, CPUs and sockets; SO_REUSEPORT and NUMA placement remove the bottleneck.
Why here
Shows sockets, file descriptors, CPU cores and kernel queues (all from this part) deciding real network throughput.
taught here: I/O fundamentals
DiscordBlog post · ~15 min

Why Discord is switching from Go to Rust

Jesse Howarth · 2020
What
Discord's account of rewriting its Read States service from Go to Rust.
Key idea
Go's garbage collector caused latency spikes every two minutes on a large LRU cache; Rust's ownership model frees memory without GC pauses.
Why here
A concrete case of memory management and runtime behaviour, from the OS concepts here, turning into user-visible tail latency.
taught here: Memory management

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…