Chapter 2: Operating System Concepts for Distributed Systems

Modern distributed systems run on operating system primitives. Understanding processes, threads, I/O models, memory management, and scheduling is critical for building high-performance services. This chapter covers OS fundamentals that directly impact system design decisions.

2.1 Processes vs Threads

Memory Model

graph TB subgraph Process1["Process A"] subgraph AS1["Address Space"] Code1["Code (.text)"] Data1["Data (.data)"] Heap1["Heap"] Stack1["Stack"] end PT1["Page Table"] end subgraph Process2["Process B (Multi-threaded)"] subgraph Shared["Shared Memory"] Code2["Code (.text)"] Data2["Data (.data)"] Heap2["Heap"] end subgraph T1["Thread 1"] Stack2_1["Stack"] Reg2_1["Registers"] end subgraph T2["Thread 2"] Stack2_2["Stack"] Reg2_2["Registers"] end subgraph T3["Thread 3"] Stack2_3["Stack"] Reg2_3["Registers"] end PT2["Page Table (Shared)"] end AS1 --> PT1 Shared --> PT2 T1 --> PT2 T2 --> PT2 T3 --> PT2 PT1 --> PhysMem["Physical Memory"] PT2 --> PhysMem style Process1 fill:#1a1a2e style Process2 fill:#1a1a2e style Shared fill:#16213e style T1 fill:#0f3460 style T2 fill:#0f3460 style T3 fill:#0f3460

Context Switch Costs

Context Switch Type Typical Cost Why?
Thread (same process) ~1-2 μs Save/restore registers, stack pointer. TLB stays warm.
Process ~5-10 μs TLB flush, page table switch, cache pollution.
System call ~100-300 ns User mode → kernel mode transition.

IPC Mechanisms Comparison

Mechanism Throughput Latency Use Case Pros Cons
Shared Memory ~40 GB/s ~50 ns High-volume data transfer Fastest, zero-copy Requires synchronization (mutexes/semaphores)
Unix Domain Sockets ~10 GB/s ~5 μs General IPC, stream-oriented Simple API, supports credentials passing 2 copies (user→kernel→user)
Named Pipes (FIFOs) ~8 GB/s ~10 μs Producer-consumer pipelines Filesystem-based, simple Unidirectional, blocking by default
Message Queues (POSIX) ~1 GB/s ~20 μs Structured messages, prioritization Message boundaries, priority Size limits, persistence overhead
Signals N/A ~1 μs Notifications only Lightweight, async No data payload, non-portable behavior

When to Use What

Use Processes When

  • Isolation required: Security boundaries, untrusted code
  • Fault containment: One crash shouldn't kill everything
  • Language constraints: Python GIL, no shared memory
  • External programs: Need to fork()/exec() other binaries

Examples: Chrome tabs, Nginx worker processes, Postgres backends

Use Threads When

  • Shared state: Need fast access to common data structures
  • Low overhead: Spawning thousands of lightweight workers
  • Blocking I/O: Thread-per-request model (legacy)
  • CPU parallelism: Utilize multiple cores in single process

Examples: JVM applications, Go goroutines (M:N), database query executors

Interview Callout

Q: "Why does Chrome use separate processes for tabs instead of threads?"

A: Fault isolation and security. A crash in one tab's renderer process doesn't kill the browser. Each tab can run in a separate sandbox with restricted system access (principle of least privilege). Memory overhead is acceptable (~10MB per process) for better stability.

Real Numbers: Context Switch Benchmark

// Linux 5.15, Intel Xeon Gold 6248R @ 3.0GHz
// Measured with perf stat and lmbench

Process context switch:     ~6.2 μs
Thread context switch:      ~1.8 μs
Goroutine context switch:   ~200 ns (userspace, no syscall)
System call (getpid):       ~120 ns

2.2 I/O Models

Understanding I/O models is critical for building high-performance network services. The evolution from blocking I/O to async I/O solved the C10K problem.

1. Blocking I/O

sequenceDiagram participant App as Application participant Kernel as Kernel participant Device as Network Device App->>Kernel: read(socket) Note over App: ❌ BLOCKED
Cannot do other work Kernel->>Device: Wait for data Device-->>Kernel: Data ready Kernel->>Kernel: Copy data to user space Kernel-->>App: Return data Note over App: ✓ Unblocked

Cost: One thread per connection. 10,000 connections = 10,000 threads = ~10GB RAM (1MB stack each).

2. Non-blocking I/O

sequenceDiagram participant App as Application participant Kernel as Kernel loop Polling App->>Kernel: read(socket, O_NONBLOCK) Kernel-->>App: EAGAIN (no data yet) Note over App: Do other work App->>Kernel: read(socket, O_NONBLOCK) Kernel-->>App: EAGAIN App->>Kernel: read(socket, O_NONBLOCK) Kernel-->>App: Data ready (bytes) end

Problem: Busy-waiting wastes CPU cycles. Inefficient for thousands of sockets.

3. I/O Multiplexing (select/poll/epoll)

sequenceDiagram participant App as Application participant Kernel as Kernel (epoll) participant Sockets as 10,000 Sockets App->>Kernel: epoll_wait() Note over App: ❌ BLOCKED
But monitoring ALL sockets Sockets-->>Kernel: Socket 42 readable Sockets-->>Kernel: Socket 1337 writable Kernel-->>App: Events: [42:READ, 1337:WRITE] Note over App: ✓ Unblocked App->>Kernel: read(socket 42) Kernel-->>App: Data App->>Kernel: write(socket 1337) App->>Kernel: epoll_wait()

Key insight: One thread can monitor thousands of sockets. O(1) event retrieval with epoll.

4. Signal-Driven I/O

sequenceDiagram participant App as Application participant Kernel as Kernel participant Handler as Signal Handler App->>Kernel: fcntl(socket, F_SETOWN) App->>Kernel: fcntl(socket, O_ASYNC) Note over App: Continue working Kernel->>Handler: SIGIO signal Handler->>Kernel: read(socket) Kernel-->>Handler: Data

Rarely used: Signal handling is tricky, limited socket info in signal.

5. Asynchronous I/O (io_uring)

sequenceDiagram participant App as Application participant SQ as Submission Queue participant Kernel as Kernel (io_uring) participant CQ as Completion Queue App->>SQ: submit read(socket, buffer) App->>SQ: submit write(socket2, data) Note over App: Continue working
Never blocks! App->>Kernel: io_uring_enter() [optional] Kernel->>Kernel: Process SQ entries Kernel-->>CQ: read complete (bytes) Kernel-->>CQ: write complete (bytes) App->>CQ: poll for completions CQ-->>App: Results

Future of Linux I/O: True async, zero-copy, poll-less mode. 30-40% faster than epoll for high loads.

Performance Comparison

Model Syscalls per Operation Max Connections CPU Efficiency Complexity Used By
Blocking I/O 1 (read/write) ~100-1K Low (many threads) Simple Legacy apps, Go (goroutines)
Non-blocking I/O Many (polling) ~1K Very Low (busy-wait) Simple Rarely used standalone
select() 1 + O(n) scan ~1K (FD_SETSIZE=1024) Low (O(n) scan) Medium Portable, legacy
poll() 1 + O(n) scan ~10K Low (O(n) scan) Medium Portable
epoll/kqueue 1 (O(1) events) ~100K-1M High Medium Nginx, Redis, Node.js
io_uring 0 (poll mode) or 1 ~1M+ Very High High Modern Linux (5.1+), Seastar

Interview Callout

Q: "How does epoll scale to 1 million connections while select() only handles 1024?"

A: select() has FD_SETSIZE limit (1024) and requires O(n) scan of all file descriptors on each call. epoll uses a red-black tree in the kernel to track interest list and only returns ready file descriptors (O(1) retrieval). Edge-triggered mode further reduces syscalls by only notifying on state changes.

Real Numbers: Benchmark (100K concurrent connections)

// Linux 5.15, wrk benchmark, echo server
// Metric: Requests/sec per CPU core

Blocking I/O (thread-per-conn):     50 req/s (OOM, thrashing)
epoll (Nginx):                      45,000 req/s
io_uring (poll mode):               62,000 req/s (+38%)

// Latency p99
epoll:                              12 ms
io_uring:                           8 ms

io_uring Advanced: Why It's a Game-Changer

Architecture: io_uring uses submission queue (SQ) and completion queue (CQ) in shared memory between kernel and userspace. In SQPOLL mode, the fast path requires ZERO syscalls—the kernel polls the submission queue.

Operations Supported

io_uring handles the entire I/O stack, not just network sockets:

  • File I/O: read, write, openat, statx, fallocate
  • Network I/O: send, recv, accept, connect, sendmsg, recvmsg
  • Advanced: splice, tee, sync_file_range, fsync

Performance Deep Dive

Benchmark: NVMe SSD (Intel Optane), 4K random reads

epoll (Nginx):              1.1M IOPS
io_uring (standard):        1.3M IOPS (+18%)
io_uring (SQPOLL mode):     1.5M IOPS (+36%)

Why 30-40% faster than epoll for high-throughput workloads:

  • Eliminates syscall overhead entirely in poll mode (SQPOLL)
  • Batches multiple operations in single submission
  • Zero-copy with registered buffers (IORING_REGISTER_BUFFERS)
  • Kernel-side command chaining (linked operations)

Key Flags & Features

Flag/Feature Description Impact
IORING_SETUP_SQPOLL Kernel polls SQ, no submit syscall needed Eliminates syscall latency (~120ns each)
IORING_FEAT_FAST_POLL Fast path for ready operations Reduces latency by 20-30%
IORING_REGISTER_BUFFERS Pre-register fixed buffers Avoids map/unmap on every I/O, zero-copy
IORING_OP_READ/WRITE Basic file I/O operations Same as pread/pwrite but async

Real-World Adoption

  • RocksDB: Uses io_uring for async I/O, 25% improvement in write throughput
  • Tokio (Rust): Async runtime with io_uring backend for Linux
  • Seastar framework: C++ async framework (used by ScyllaDB) leverages io_uring
  • liburing: User-space library for easy io_uring integration

Interview Callout

Q: "What's the fastest I/O model on Linux today?"

A: io_uring. It eliminates syscall overhead entirely in SQPOLL mode by using shared memory queues between userspace and kernel. The kernel polls the submission queue and writes completions to the completion queue—zero syscalls in the fast path. For high-throughput workloads like web servers handling 100K+ connections, io_uring delivers 30-40% better performance than epoll with lower latency.

2.3 The C10K and C10M Problems

The Problem: Thread-per-Connection Math

Why 10,000 threads fails:

10,000 connections × 1 MB stack per thread = 10 GB RAM
Context switches: 10,000 threads → ~60,000 μs total switch time per round-robin
Kernel scheduler overhead: O(n) or O(log n) depending on algorithm
Cache thrashing: Each thread evicts others' data from L1/L2/L3 caches

Solution: Event-Driven Architecture

Thread-per-Connection (Old)

  • 1 thread per client
  • Blocking I/O (simple code)
  • Kernel schedules threads
  • Scales to ~1K connections

Event-Driven (Modern)

  • 1 thread per CPU core
  • Non-blocking I/O + event loop
  • Userspace schedules tasks
  • Scales to 1M+ connections

Reactor Pattern (epoll/kqueue)

graph TB subgraph EventLoop["Event Loop (Single Thread)"] Accept["Accept Connections"] Wait["epoll_wait()"] Dispatch["Dispatch Events"] Handler1["Handler: READ"] Handler2["Handler: WRITE"] Handler3["Handler: ACCEPT"] end Client1["Client 1"] --> Accept Client2["Client 2"] --> Accept ClientN["Client 10,000"] --> Accept Accept --> Register["Register with epoll"] Register --> Wait Wait --> Dispatch Dispatch --> Handler1 Dispatch --> Handler2 Dispatch --> Handler3 Handler1 --> |"Non-blocking read()"| Process["Process Data"] Handler2 --> |"Non-blocking write()"| Send["Send Response"] Handler3 --> Accept Process --> Wait Send --> Wait style EventLoop fill:#1a1a2e style Wait fill:#e94560 style Dispatch fill:#16213e

Proactor Pattern (io_uring/IOCP)

graph TB subgraph App["Application Thread"] Submit["Submit I/O Requests"] Work["Do Other Work"] Check["Check Completions"] Process["Process Results"] end subgraph Kernel["Kernel (io_uring)"] SQ["Submission Queue"] Workers["Kernel Workers"] CQ["Completion Queue"] end Client1["Client 1"] --> Submit Client2["Client 2"] --> Submit ClientN["Client 10,000"] --> Submit Submit --> SQ SQ --> Workers Workers --> |"async read"| IO1["I/O Operation 1"] Workers --> |"async write"| IO2["I/O Operation 2"] Workers --> |"async accept"| IO3["I/O Operation 3"] IO1 --> CQ IO2 --> CQ IO3 --> CQ Work -.->|"No blocking!"| Check Check --> CQ CQ --> Process Process --> Submit style App fill:#1a1a2e style Kernel fill:#16213e style CQ fill:#e94560

Key Differences

Aspect Reactor (epoll) Proactor (io_uring)
Notification When socket is ready for I/O When I/O operation completes
Who does I/O? Application (read/write syscalls) Kernel (async workers)
Syscalls per op 2 (epoll_wait + read/write) 0 (poll mode) or 1 (batched)
Zero-copy No (manual copy) Yes (kernel buffers)
Complexity Medium High

C10M Problem (10 Million Connections)

Achieving 10M concurrent connections requires:

  • Kernel bypass: DPDK, XDP (process packets in userspace)
  • Zero-copy networking: io_uring, sendfile()
  • CPU pinning: Avoid cross-CPU migrations
  • Huge pages: Reduce TLB misses (2MB pages vs 4KB)
  • Lock-free data structures: Per-core queues, RCU
  • Memory: ~64GB (10M × 6KB per connection state)

Interview Callout

Q: "How does Nginx handle 1 million concurrent connections on a single machine?"

A: Nginx uses a multi-process, event-driven architecture. Each worker process (one per CPU core) runs an epoll event loop handling ~250K connections. Non-blocking I/O avoids thread overhead. Shared memory for stats/cache. Total memory: ~6KB per idle connection = 6GB for 1M connections. Key tunables: worker_connections, worker_processes, epoll edge-triggered mode.

Real Numbers: WhatsApp (2012)

// 50 engineers, 450 million users, 2 million connections per server
// Stack: Erlang/OTP, FreeBSD, kqueue

Connections per server:     2,000,000
Servers:                    ~500
CPU usage:                  ~60% (mostly crypto)
Memory per connection:      ~4 KB
Network I/O model:          kqueue (edge-triggered)

Key: Erlang lightweight processes (300 bytes overhead) + kqueue

2.4 epoll vs kqueue vs IOCP

Deep Comparison

Feature epoll (Linux) kqueue (BSD/macOS) IOCP (Windows)
Pattern Reactor Reactor Proactor
API Calls epoll_create, epoll_ctl, epoll_wait kqueue, kevent CreateIoCompletionPort, GetQueuedCompletionStatus
Notification FD ready for I/O FD ready for I/O I/O operation completed
Edge-Triggered Yes (EPOLLET) Yes (default) N/A (completion-based)
Level-Triggered Yes (default) Yes (NOTE_LOWAT) N/A
Monitors Sockets, pipes, files (limited) Sockets, files, signals, timers, processes Sockets, files, pipes
Performance (1M conns) ~45K req/s ~42K req/s ~50K req/s
Scalability O(1) event retrieval O(1) event retrieval O(1) event retrieval
User Data Pointer Yes (epoll_data_t) Yes (udata) Yes (OVERLAPPED)
Multiple Events per FD No (must OR flags) Yes (separate kevents) N/A

Edge-Triggered vs Level-Triggered

Level-Triggered (default epoll, select, poll)

Behavior: Notifies as long as condition is true

// If 1KB available in socket:
epoll_wait() → socket readable
read(500 bytes)
epoll_wait() → socket readable (still 500 bytes!)
read(500 bytes)
epoll_wait() → no event (buffer empty)

Pros: Easier to use, can't miss data

Cons: More epoll_wait() calls, potential busy-loop

Edge-Triggered (EPOLLET, kqueue default)

Behavior: Notifies only on state change

// If 1KB available in socket:
epoll_wait() → socket readable
read(500 bytes)
epoll_wait() → no event (no NEW data)
// Must read() until EAGAIN!

read(500 bytes)
read() → EAGAIN (now fully drained)

Pros: Fewer syscalls, faster

Cons: Must read until EAGAIN or lose events

Code-Level Differences

graph TB subgraph Linux["Linux (epoll)"] L1["epoll_create1(0)"] L2["epoll_ctl(ADD, socket)"] L3["epoll_wait(events, ...)"] L4["read(socket)"] end subgraph BSD["BSD/macOS (kqueue)"] B1["kqueue()"] B2["kevent(socket, EVFILT_READ, EV_ADD)"] B3["kevent(NULL, events, ...)"] B4["read(socket)"] end subgraph Windows["Windows (IOCP)"] W1["CreateIoCompletionPort()"] W2["ReadFile(socket, OVERLAPPED)"] W3["GetQueuedCompletionStatus()"] W4["Process completed data"] end L1 --> L2 --> L3 --> L4 --> L3 B1 --> B2 --> B3 --> B4 --> B3 W1 --> W2 --> W3 --> W4 --> W2 style Linux fill:#1a1a2e style BSD fill:#16213e style Windows fill:#0f3460

Why Nginx Uses epoll

Reason Details
Linux dominance 95% of web servers run Linux (epoll is Linux-native)
Edge-triggered mode EPOLLET reduces syscalls by 50-70% under high load
EPOLLONESHOT Auto-disables FD after event (prevents thundering herd in multi-threaded mode)
File descriptor reuse epoll_ctl(EPOLL_CTL_MOD) allows changing interest mask without re-adding
Performance Optimized for idle connections (WHY_NOT_NOTIFY if no data)

Abstraction Layers

Cross-platform libraries abstract these differences:

Library Language Backend
libuv C epoll, kqueue, IOCP, io_uring
libevent C epoll, kqueue, select, poll
mio Rust epoll, kqueue, IOCP
Tokio Rust mio + async runtime
asyncio Python epoll, kqueue, IOCP, select

Interview Callout

Q: "What's the difference between epoll and select()?"

A:

  • FD limit: select() limited to 1024 (FD_SETSIZE), epoll unlimited
  • Performance: select() is O(n) scan of all FDs, epoll is O(1) retrieval of ready FDs
  • API: select() modifies FD sets (must rebuild), epoll maintains interest list in kernel
  • Edge-triggered: select() is level-triggered only, epoll supports both

Result: epoll scales to 100K+ connections, select() struggles beyond 1K.

Real Numbers: Benchmark Comparison

// Same hardware: Linux 5.15 on Intel Xeon, 100K connections
// Workload: Echo server, 1KB messages

epoll (level-triggered):    38,000 req/s, 15ms p99 latency
epoll (edge-triggered):     45,000 req/s, 12ms p99 latency
io_uring (poll mode):       62,000 req/s, 8ms p99 latency

// FreeBSD 13 on same hardware
kqueue:                     42,000 req/s, 13ms p99 latency

// Windows Server 2022
IOCP:                       50,000 req/s, 10ms p99 latency

2.5 Memory Management for High-Performance Systems

Virtual Memory Architecture

graph TB subgraph Process["Process Virtual Address Space (64-bit)"] Kernel["Kernel Space
0xFFFF8000 00000000 - 0xFFFFFFFF FFFFFFFF
(Higher Half)"] UserStack["Stack
0x00007FFF FFFFE000
↓ Grows Down"] MMap["Memory Mapped Files
mmap() region"] Heap["Heap
↑ Grows Up
malloc()/new"] BSS["BSS (Uninitialized Data)"] Data["Data (Initialized Globals)"] Text["Text (Code)
Read-Only
0x0000000000400000"] end subgraph PageTables["Multi-Level Page Table"] PML4["PML4 (Level 4)
512 entries"] PDPT["PDPT (Level 3)
512 entries"] PD["PD (Level 2)
512 entries"] PT["PT (Level 1)
512 entries"] end subgraph Physical["Physical Memory"] RAM["Physical Frames
(4KB pages)"] TLB["TLB Cache
(Translation Lookaside Buffer)
~64-1024 entries"] end Text --> PML4 Heap --> PML4 UserStack --> PML4 PML4 --> PDPT PDPT --> PD PD --> PT PT --> RAM TLB -.->|"Cache hit
(~1 cycle)"| RAM PT -.->|"TLB miss
(~100 cycles)"| TLB style Kernel fill:#e94560 style TLB fill:#16213e style RAM fill:#0f3460

Page Table Lookup Cost

Scenario Cycles Nanoseconds (3GHz CPU)
TLB hit ~1 0.3 ns
TLB miss (4-level walk) ~100-200 30-60 ns
Page fault (major) ~10M 3-5 ms (disk I/O)
Page fault (minor) ~3000 1 μs (already in RAM)

Huge Pages (2MB vs 4KB)

Standard 4KB Pages

  • TLB coverage: 1024 entries × 4KB = 4MB
  • For 10GB dataset: 2.5M page table entries
  • TLB miss rate: High (99.96% misses)
  • Use case: General purpose, fine-grained

Huge 2MB Pages

  • TLB coverage: 1024 entries × 2MB = 2GB
  • For 10GB dataset: 5,000 page table entries
  • TLB miss rate: Low (~80% hit rate)
  • Use case: Databases, Redis, large heaps

mmap() Use Cases

Use Case Flags Benefit Example
Memory-mapped files MAP_SHARED Zero-copy file access, kernel caching Nginx serving static files, databases
Shared memory IPC MAP_SHARED | MAP_ANONYMOUS Fastest IPC (~50ns latency) Chrome GPU process, Postgres shared buffers
Large allocations MAP_PRIVATE | MAP_ANONYMOUS Direct allocation, no malloc overhead malloc() internally uses mmap for >128KB
Huge pages MAP_HUGETLB Reduce TLB misses by 500x Redis, Postgres, JVM heaps

Copy-on-Write (COW)

sequenceDiagram participant Parent as Parent Process participant Kernel as Kernel participant Child as Child Process (fork) Parent->>Kernel: fork() Note over Kernel: Mark all pages
READ-ONLY
Set COW flags Kernel->>Child: New process (shares pages) Note over Parent,Child: Both processes share
same physical pages
(Zero copy!) Child->>Kernel: Write to page X Note over Kernel: Page fault!
Copy page X Kernel->>Child: New physical page Note over Parent: Still uses original
page X Note over Child: Uses copied page X

NUMA (Non-Uniform Memory Access)

graph TB subgraph Node0["NUMA Node 0"] CPU0["CPU 0-7
(8 cores)"] Mem0["Local RAM
64 GB
~80ns latency"] end subgraph Node1["NUMA Node 1"] CPU1["CPU 8-15
(8 cores)"] Mem1["Local RAM
64 GB
~80ns latency"] end CPU0 <-->|"Local access
80ns"| Mem0 CPU1 <-->|"Local access
80ns"| Mem1 CPU0 <-.->|"Remote access
140ns (+75%)"| Mem1 CPU1 <-.->|"Remote access
140ns (+75%)"| Mem0 Node0 <-->|"QPI/UPI
Interconnect"| Node1 style Node0 fill:#1a1a2e style Node1 fill:#16213e style Mem0 fill:#0f3460 style Mem1 fill:#0f3460

NUMA Best Practices

Strategy Command/API Impact
Pin process to NUMA node numactl --cpunodebind=0 --membind=0 Avoid remote memory access, 1.75x faster
Interleave memory numactl --interleave=all Balanced load, good for multi-threaded apps
Check allocation numastat -p PID Identify remote memory access hotspots
Programmatic pinning set_mempolicy(MPOL_BIND) Per-thread memory policy

Interview Callout

Q: "Your service's p99 latency suddenly increased by 50% after a hardware upgrade. CPU and disk are not saturated. What could cause this?"

A: NUMA architecture! The new server has multiple NUMA nodes. If the application allocates memory on one node but runs threads on another node, every memory access incurs remote memory latency (~140ns vs 80ns local). Solution: Pin threads to NUMA nodes with numactl, or enable interleaving. Check with numastat -p PID to see remote vs local access counts.

Real Numbers: Memory Performance

// Intel Xeon Gold 6248R @ 3.0GHz, 128GB RAM, Linux 5.15

// Latency (sequential access)
L1 cache hit:               ~1 ns (4 cycles)
L2 cache hit:               ~3 ns (10 cycles)
L3 cache hit:               ~12 ns (40 cycles)
Local RAM (NUMA):           ~80 ns
Remote RAM (NUMA):          ~140 ns (+75%)
TLB miss (page table walk): ~60 ns

// Huge pages impact (Redis benchmark, 10GB dataset)
4KB pages:                  180,000 ops/sec, 12% TLB miss rate
2MB huge pages:             245,000 ops/sec (+36%), 0.8% TLB miss rate

// Copy-on-Write (fork benchmark)
fork() without COW:         50ms (copy 1GB)
fork() with COW:            0.3ms (just page tables)

2.6 CPU Scheduling and Tail Latency

Completely Fair Scheduler (CFS)

Linux default scheduler since 2.6.23 (2007). Uses red-black tree to track runnable tasks.

graph TB subgraph CFS["CFS Scheduler (per-CPU run queue)"] RBTree["Red-Black Tree
(ordered by vruntime)"] Min["Leftmost node
(min vruntime)
→ Next to run"] end subgraph Tasks["Runnable Tasks"] T1["Task A
vruntime: 1000"] T2["Task B
vruntime: 1200"] T3["Task C
vruntime: 1500"] T4["Task D
vruntime: 1800"] end T1 --> RBTree T2 --> RBTree T3 --> RBTree T4 --> RBTree RBTree --> Min Min --> |"Schedule"| T1 T1 --> |"Runs for timeslice
vruntime += exec_time"| Update["Update vruntime
Re-insert into tree"] Update --> RBTree style CFS fill:#1a1a2e style Min fill:#e94560 style T1 fill:#16213e

Virtual Runtime (vruntime)

CFS Formula

vruntime += (actual_runtime * nice_0_weight) / task_weight

// nice_0_weight = 1024 (baseline)
// task_weight depends on nice value (-20 to +19)

Example:
- Task A (nice 0): vruntime += 10ms * 1024 / 1024 = 10ms
- Task B (nice 10): vruntime += 10ms * 1024 / 335 = 30ms
→ Task B gets less CPU share (lower priority)

Scheduling Policies

Policy Priority Use Case Latency
SCHED_NORMAL (CFS) Dynamic (nice -20 to +19) General-purpose applications 5-20ms timeslice
SCHED_BATCH Low (non-interactive) Background jobs, batch processing Long timeslices, yielding
SCHED_IDLE Lowest Idle tasks (only when CPU free) Runs only if nothing else runnable
SCHED_FIFO Real-time (1-99) Hard real-time, preempts CFS Runs until blocks or yields
SCHED_RR Real-time (1-99) Real-time with round-robin Fixed timeslice (~100ms)
SCHED_DEADLINE Highest (deadline) EDF (Earliest Deadline First) Guaranteed CPU by deadline

CPU Affinity (Pinning)

Problem: Context switch across CPUs invalidates L1/L2 caches and TLB.

No Affinity (Default)

// Task migrates between CPUs
T0: CPU 0 (warm cache)
T1: CPU 2 (cold cache, 50% slower)
T2: CPU 5 (cold cache again)
T3: CPU 0 (cache refill)

Cost: ~10-20% performance loss from cache thrashing

CPU Pinning

// Task always on CPU 0
taskset -c 0 ./app
// or programmatically:
pthread_setaffinity_np(cpu_set);

Benefit: Cache-hot execution, predictable latency

Context Switch Costs

Scenario Direct Cost Indirect Cost Total
Same-CPU thread switch ~1.8 μs L1/L2 pollution ~2-5 μs
Cross-CPU thread switch ~6 μs L1/L2/TLB cold, NUMA remote ~10-30 μs
Process switch ~6 μs Full TLB flush, L3 pollution ~20-50 μs

Impact on Tail Latency

Scenario: Latency-sensitive service (target p99 < 10ms)

sequenceDiagram participant Client participant Server participant Scheduler Client->>Server: Request arrives Note over Server: Processing starts
(0ms) Server->>Server: Work (2ms) Note over Scheduler: Context switch!
Preempted by other task Server->>Scheduler: Descheduled Note over Scheduler: Wait in run queue
(5ms) Scheduler->>Server: Rescheduled Note over Server: Cache cold
+2ms penalty Server->>Server: Resume work (3ms) Server->>Client: Response Note over Client: Total latency:
2 + 5 + 2 + 3 = 12ms
Missed p99 target!

Mitigation Strategies

Strategy Implementation Benefit
CPU pinning taskset -c 0-7 ./server Avoid cross-CPU migrations, cache-hot
Isolate CPUs isolcpus=8-15 (kernel param) Reserve CPUs for critical tasks only
Real-time priority chrt -f 50 ./server Preempt normal tasks, bounded latency
Disable CPU frequency scaling cpupower frequency-set -g performance Avoid turbo boost delays (~1ms)
Disable interrupts on CPU echo 1 > /proc/irq/IRQ/smp_affinity Prevent IRQ preemption

Interview Callout

Q: "Your service's p99 latency is 15ms, but p50 is 2ms. The service is CPU-bound. How do you reduce tail latency?"

A:

  • Root cause: Likely scheduler preemption. Requests get descheduled mid-processing.
  • Diagnosis: Check context switch rate (vmstat 1, cs column). If high (>10K/sec per core), scheduler thrashing.
  • Solutions:
    1. Pin threads to CPUs (taskset) to avoid migration
    2. Isolate CPUs from kernel tasks (isolcpus)
    3. Use real-time scheduling (SCHED_FIFO) for request handlers
    4. Reduce thread count (thread-per-core model like Seastar)
  • Result: p99 drops from 15ms to 3-5ms by eliminating scheduler latency spikes.

Real Numbers: Scheduling Impact

// Benchmark: 10K req/s load test, 1ms work per request
// Setup: 16-core Xeon, Linux 5.15

Default CFS (no tuning):
p50: 2ms, p99: 18ms, p99.9: 45ms
Context switches: 25K/sec per core

CPU pinning (taskset -c 0-7):
p50: 2ms, p99: 8ms, p99.9: 15ms (-67% tail)
Context switches: 8K/sec per core

CPU isolation (isolcpus=0-7):
p50: 2ms, p99: 4ms, p99.9: 7ms (-84% tail)
Context switches: 1K/sec per core

SCHED_FIFO (real-time):
p50: 2ms, p99: 3ms, p99.9: 4ms (-91% tail)
Context switches: 200/sec per core
Risk: Misconfiguration can freeze system!

2.7 File Systems for High-Performance Storage

Modern File Systems Comparison

File System Max File Size Max Volume Size Journaling CoW Use Case
ext4 16 TB 1 EB Yes (metadata only) No General purpose, Linux default
XFS 8 EB 8 EB Yes (metadata + data) No Large files, high parallelism
Btrfs 16 EB 16 EB No (CoW instead) Yes Snapshots, compression, RAID
ZFS 16 EB 256 ZB No (CoW instead) Yes Data integrity, enterprise storage
F2FS 3.94 TB 16 TB Yes (log-structured) No Flash/SSD optimized

Write Path: Application to Disk

graph TB subgraph UserSpace["User Space"] App["Application"] LibC["glibc (stdio)"] end subgraph KernelSpace["Kernel Space"] VFS["VFS (Virtual File System)"] PageCache["Page Cache
(RAM buffer)"] Journal["Journal
(Write-Ahead Log)"] BlockLayer["Block Layer
(I/O Scheduler)"] Driver["Device Driver"] end subgraph Hardware["Hardware"] DiskCache["Disk Controller Cache
(64-256 MB)"] Platter["Physical Disk
(persistent)"] end App -->|"write()"| LibC LibC -->|"syscall"| VFS VFS -->|"1. Write to cache"| PageCache PageCache -->|"2. Async flush
(dirty pages)"| Journal Journal -->|"3. WAL commit"| BlockLayer BlockLayer -->|"4. I/O request"| Driver Driver -->|"5. DMA transfer"| DiskCache DiskCache -->|"6. Write to platter
(barrier)"| Platter App -.->|"fsync()
(force flush)"| VFS VFS -.->|"Bypass cache"| Journal style PageCache fill:#e94560 style Journal fill:#16213e style DiskCache fill:#0f3460

Buffered I/O vs Direct I/O

Buffered I/O (Default)

int fd = open("file.txt", O_RDWR);
write(fd, buf, 4096);
// Returns immediately!
// Data in page cache

Pros:

  • Fast writes (RAM speed, ~1 GB/s)
  • Read caching (avoid disk I/O)
  • Write coalescing (batch small writes)

Cons:

  • Data loss on crash (dirty pages not flushed)
  • Double caching (kernel + app)
  • Unpredictable latency (flush spikes)

Direct I/O (O_DIRECT)

int fd = open("file.txt", O_RDWR | O_DIRECT);
// Requires aligned buffers
posix_memalign(&buf, 4096, 4096);
write(fd, buf, 4096);
// Blocks until disk confirms!

Pros:

  • Predictable latency (no flush spikes)
  • No double caching (saves RAM)
  • App controls caching (databases)

Cons:

  • Slower writes (disk speed, ~200 MB/s HDD)
  • Requires alignment (4KB boundaries)
  • No read-ahead or write coalescing

Write-Ahead Logging (WAL) and Journaling

Problem: Crash during multi-step operation leaves file system inconsistent.

sequenceDiagram participant App as Application participant Journal as Journal participant FS as File System participant Disk as Disk Note over App,Disk: Transaction: Append to file App->>Journal: 1. Write to journal
[Intent: update inode + data block] Journal->>Disk: 2. Commit to journal
(sequential write, fast) Note over Journal: Journal now durable App->>FS: 3. Update in-place
(inode + data block) FS->>Disk: 4. Write updates
(random writes, slow) Note over Disk: ✓ Data durable App->>Journal: 5. Mark transaction complete Journal->>Journal: 6. Delete journal entry Note over App,Disk: If crash before step 4:
Replay journal on boot

ext4 Journaling Modes

Mode What's Journaled Durability Performance Use Case
journal Metadata + Data Strongest Slowest (2x writes) Critical data (financial)
ordered (default) Metadata only, data written first Strong Fast General purpose
writeback Metadata only Weak (data loss possible) Fastest Logs, temp files

fsync() Semantics and The PostgreSQL Bug

The PostgreSQL fsync Bug (2018)

Scenario: PostgreSQL calls fsync() to ensure WAL durability. On Linux, if fsync() encounters an I/O error (e.g., disk full), it returns error BUT ALSO clears dirty pages from cache.

Problem: PostgreSQL retried fsync() after error. Kernel said "success" (no dirty pages left), but data was never written to disk. Silent data loss.

Impact: Affected all databases using fsync() on Linux kernels before 4.13. PostgreSQL, MySQL, SQLite all vulnerable.

Fix: Linux kernel 4.13+ guarantees dirty pages stay dirty until successfully flushed. PostgreSQL now panics on fsync() error instead of retrying.

io_uring for File I/O

Modern async file I/O on Linux (5.1+), replacing aio.

// Traditional sync read
int fd = open("file.txt", O_RDONLY);
read(fd, buf, 4096); // BLOCKS

// io_uring async read
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_submit(&ring); // Non-blocking!

// Do other work...

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // Wait for completion
ssize_t bytes_read = cqe->res;

XFS vs ext4 for Databases

Aspect ext4 XFS
Allocation Groups No (single lock for allocation) Yes (parallel allocation, scales to 100+ cores)
Delayed Allocation Yes Yes (more aggressive)
Online Resize Grow only Grow only
Fragmentation Higher (small files) Lower (extent-based)
Large File Performance Good (<1 TB) Excellent (>1 TB)
Metadata Performance Good (B-tree) Excellent (B+tree)
Recovery Time Fast (metadata journal) Fast (metadata journal)
Interview Signal

Q: "Your database has high write throughput (100K writes/sec). It's on ext4. How do you optimize disk I/O?"

A:

  1. Switch to XFS: Better parallelism (allocation groups), less fragmentation for large files
  2. Use Direct I/O: Databases like PostgreSQL, MySQL use O_DIRECT to bypass page cache and control their own caching
  3. Disable atime: Mount with noatime to avoid updating access time on every read (one less write per read)
  4. Tune I/O scheduler: Use none or mq-deadline for SSDs (no need for seek optimization)
  5. Increase journal size: mkfs.xfs -l size=512m reduces journal wrap-around overhead
  6. Consider NVMe + io_uring: 30-40% faster than traditional pwrite() for async I/O

Real Numbers: File System Performance

// Benchmark: fio sequential write, 16GB file, Intel Optane SSD
// Setup: Linux 5.15, NVMe drive (7 GB/s capable)

ext4 (buffered I/O):        3.2 GB/s write, 4.5 GB/s read
ext4 (O_DIRECT):            2.8 GB/s write, 4.2 GB/s read
XFS (buffered I/O):         3.5 GB/s write, 4.6 GB/s read
XFS (O_DIRECT):             3.1 GB/s write (+11% vs ext4), 4.4 GB/s read
Btrfs (CoW overhead):       2.1 GB/s write, 4.0 GB/s read
io_uring (XFS):             4.2 GB/s write (+35% vs O_DIRECT), 5.1 GB/s read

// fsync() latency (ext4, ordered mode, SSD)
write() + fsync():          250 μs (metadata + data)
write() + fdatasync():      180 μs (data only, skip metadata)
write() only (no fsync):    2 μs (page cache)

// Page cache hit rate impact (PostgreSQL workload)
50% cache hit rate:         15K TPS
90% cache hit rate:         60K TPS (4x improvement)

2.8 Linux Network Stack

Full Packet Path: NIC to Application

graph TB subgraph Hardware["Hardware Layer"] Wire["Network Wire"] NIC["NIC (Network Interface Card)"] DMA["DMA Engine"] end subgraph Kernel["Kernel Space"] RingBuf["Ring Buffer
(rx_ring)"] Driver["Network Driver
(e1000e, ixgbe)"] subgraph NetStack["Network Stack"] L2["L2: Ethernet
(parse MAC)"] L3["L3: IP
(routing, NAT)"] L4["L4: TCP/UDP
(connection tracking)"] SocketBuf["Socket Buffer
(sk_buff)"] end Netfilter["Netfilter
(iptables)"] end subgraph UserSpace["User Space"] Socket["Socket FD"] App["Application"] end Wire -->|"1. Packet arrival"| NIC NIC -->|"2. IRQ (interrupt)"| Driver Driver -->|"3. DMA to RAM"| RingBuf Driver -->|"4. NAPI poll"| L2 L2 -->|"5. Ethernet processing"| L3 L3 -->|"6. IP routing"| Netfilter Netfilter -->|"7. iptables rules"| L4 L4 -->|"8. TCP state machine"| SocketBuf SocketBuf -->|"9. wake epoll"| Socket Socket -->|"10. read() syscall"| App style RingBuf fill:#e94560 style SocketBuf fill:#16213e style NAPI fill:#0f3460 NAPI["NAPI
(Interrupt → Poll)"] Driver -.->|"High load"| NAPI NAPI -.->|"Polling mode"| RingBuf

Receive Path Latency Breakdown

Step Component Latency Optimization
1. Wire to NIC Physical layer ~1 μs Use 10GbE/40GbE (lower serialization delay)
2. NIC DMA DMA transfer ~2 μs Larger ring buffers, RSS (Receive Side Scaling)
3. Interrupt Hardware IRQ ~3 μs NAPI polling (disable IRQ under load)
4. Driver processing Driver RX handler ~2 μs Minimize processing, offload to NIC
5. Network stack L2/L3/L4 processing ~5 μs GRO (Generic Receive Offload), XDP bypass
6. Socket copy sk_buff → user buffer ~3 μs Zero-copy (splice, MSG_ZEROCOPY)
7. Wake application epoll notification ~2 μs Busy-polling (SO_BUSY_POLL)

Total typical latency: ~18 μs (kernel) + ~5 μs (application) = 23 μs

Zero-Copy Techniques

Traditional Copy (2 copies)

// Read file, send over network
int fd = open("file.txt", O_RDONLY);
char buf[4096];
read(fd, buf, 4096);    // Copy 1: disk → user
write(sockfd, buf, 4096); // Copy 2: user → kernel

Copies: disk → page cache → user buffer → socket buffer → NIC

Total: 4 copies, 2 context switches

Zero-Copy (sendfile)

// Zero-copy transfer
int fd = open("file.txt", O_RDONLY);
sendfile(sockfd, fd, NULL, 4096);
// Kernel-only operation!

Copies: disk → page cache → NIC (DMA)

Total: 0 copies to user space, 1 syscall

Speedup: 2-3x faster for large files (>1MB)

Zero-Copy APIs

API Use Case Kernel Support Limitation
sendfile() File → socket (HTTP file serving) Linux 2.2+ Cannot modify data in transit
splice() Pipe-based transfer (file ↔ socket) Linux 2.6.17+ Requires pipe intermediate
MSG_ZEROCOPY send() without copy (large buffers) Linux 4.14+ Async completion notification required
mmap() + write() Memory-mapped file I/O All UNIX Page fault overhead, complex error handling
io_uring (IORING_OP_SENDMSG) Async zero-copy send Linux 5.1+ Complex API

Kernel Bypass: DPDK and XDP

Problem: Kernel network stack adds ~20 μs latency. For ultra-low latency (<5 μs), bypass kernel entirely.

DPDK (Data Plane Development Kit)

Architecture: User-space driver polls NIC ring buffer directly

// Pseudo-code
while (1) {
    packets = rte_eth_rx_burst(port, queue, pkts, 32);
    for (i = 0; i < packets; i++) {
        process_packet(pkts[i]);
    }
}
// No interrupts, no context switches!

Pros:

  • ~2-5 μs latency (vs 20 μs kernel)
  • 10M+ packets/sec per core
  • Full control over packet processing

Cons:

  • Dedicates CPU to polling (burns cycles)
  • No kernel features (iptables, routing)
  • Complex programming model

Used by: F5, Fortinet, OpenVSwitch, VPP

XDP (eXpress Data Path)

Architecture: eBPF programs run in driver, before kernel stack

// eBPF XDP program
SEC("xdp")
int xdp_drop_tcp_syn(struct xdp_md *ctx) {
    // Parse packet, drop SYN floods
    if (is_tcp_syn(ctx)) {
        return XDP_DROP; // No kernel processing!
    }
    return XDP_PASS; // Continue to kernel
}

Pros:

  • ~8-12 μs latency (hybrid approach)
  • 24M+ packets/sec per core
  • Can selectively bypass kernel
  • Safer than DPDK (eBPF verified)

Cons:

  • eBPF language limitations
  • Limited packet modification

Used by: Cloudflare DDoS protection, Facebook Katran (L4 LB)

Receive Side Scaling (RSS)

Distribute incoming packets across multiple CPU cores to avoid single-core bottleneck.

graph TB NIC["NIC (Multi-queue)"] subgraph RX["RX Queues"] Q0["Queue 0"] Q1["Queue 1"] Q2["Queue 2"] Q3["Queue 3"] end subgraph CPUs["CPU Cores"] CPU0["CPU 0
(epoll)"] CPU1["CPU 1
(epoll)"] CPU2["CPU 2
(epoll)"] CPU3["CPU 3
(epoll)"] end NIC -->|"Hash(src_ip, dst_ip, src_port, dst_port)"| Q0 NIC -->|"Hash"| Q1 NIC -->|"Hash"| Q2 NIC -->|"Hash"| Q3 Q0 -->|"IRQ affinity"| CPU0 Q1 -->|"IRQ affinity"| CPU1 Q2 -->|"IRQ affinity"| CPU2 Q3 -->|"IRQ affinity"| CPU3 style NIC fill:#e94560 style Q0 fill:#16213e style Q1 fill:#16213e style Q2 fill:#16213e style Q3 fill:#16213e

NAPI (New API) Interrupt Mitigation

Problem: At high packet rates (100K+ pps), interrupts overwhelm CPU (IRQ storm).

sequenceDiagram participant NIC participant Driver participant NAPI participant Kernel Note over NIC,Kernel: Low Load (interrupt mode) NIC->>Driver: IRQ (packet arrived) Driver->>Kernel: Process packet Kernel-->>Driver: Done Note over NIC,Kernel: High Load (polling mode) NIC->>Driver: IRQ (first packet) Driver->>NAPI: Disable IRQ, enable polling loop Polling NAPI->>Driver: poll() call Driver->>NIC: Check ring buffer (no IRQ!) NIC-->>Driver: 32 packets Driver->>Kernel: Process batch end NAPI->>Driver: No more packets Driver->>NIC: Re-enable IRQ
Interview Signal

Q: "Your service receives 500K packets/sec. CPU is at 100%, but most time is spent in IRQ context (si% high in top). How do you optimize?"

A:

  1. Enable RSS: Spread packet processing across cores. Check NIC supports multi-queue: ethtool -l eth0
  2. Tune NAPI: Increase polling budget: echo 600 > /proc/sys/net/core/netdev_budget (default 300)
  3. Interrupt coalescing: Batch interrupts: ethtool -C eth0 rx-usecs 50 (delay 50 μs)
  4. IRQ affinity: Pin IRQs to specific CPUs, pin application to different CPUs (avoid context switch)
  5. Consider XDP: For packet filtering (e.g., DDoS protection), drop packets before kernel stack

Result: Reduce IRQ overhead from 60% to 10%, increase throughput 3-5x.

Real Numbers: Network Stack Performance

// Benchmark: UDP echo server, 64-byte packets
// Setup: Linux 5.15, Intel X710 10GbE NIC, single core

Kernel stack (no tuning):       400K pps, ~25 μs latency
Kernel stack (RSS + NAPI):      1.2M pps, ~20 μs latency
XDP (eBPF filter):              12M pps, ~8 μs latency
DPDK (user-space polling):      14M pps, ~3 μs latency

// Zero-copy benchmark (1GB file transfer over localhost)
Traditional read()/write():     3.2 GB/s, 320 ms
sendfile():                     8.5 GB/s (+165%), 120 ms
io_uring (zero-copy):           11.2 GB/s (+250%), 90 ms

// RSS impact (TCP connections, 10GbE)
Single queue (1 CPU):           800K conn/s, CPU bottleneck
RSS (8 queues, 8 CPUs):         4.5M conn/s, linear scaling

2.9 Containers and Linux Namespaces

Linux Namespaces

Namespaces provide process isolation by virtualizing system resources. Each namespace type isolates a specific aspect of the system.

Namespace Isolates Use Case Example
PID Process IDs Process tree isolation Container sees PID 1 as init, host sees real PID
NET Network stack (interfaces, routes, firewall) Network isolation Each container has own IP, port range
MNT Mount points File system isolation Container has own root filesystem
UTS Hostname and domain name Host identity Each container has unique hostname
IPC Inter-process communication (message queues, semaphores) IPC isolation Prevent cross-container shared memory
USER User and group IDs Security isolation Root in container = unprivileged user on host
CGROUP Cgroup root directory Resource view isolation Container can't see host cgroup hierarchy

How Docker Uses Namespaces

graph TB subgraph Host["Host System"] Kernel["Linux Kernel"] subgraph HostNS["Host Namespaces"] HostPID["PID NS
(systemd = PID 1)"] HostNet["NET NS
(eth0, iptables)"] HostMnt["MNT NS
(root /)"] end end subgraph Container1["Container A"] subgraph C1NS["Container Namespaces"] C1PID["PID NS
(nginx = PID 1)"] C1Net["NET NS
(veth0, 172.17.0.2)"] C1Mnt["MNT NS
(overlay FS)"] end C1Proc["nginx process
(PID 1 in container,
PID 12345 on host)"] end subgraph Container2["Container B"] subgraph C2NS["Container Namespaces"] C2PID["PID NS
(postgres = PID 1)"] C2Net["NET NS
(veth1, 172.17.0.3)"] C2Mnt["MNT NS
(overlay FS)"] end C2Proc["postgres process
(PID 1 in container,
PID 12400 on host)"] end Kernel --> HostNS Kernel --> C1NS Kernel --> C2NS C1NS --> C1Proc C2NS --> C2Proc style Kernel fill:#e94560 style C1NS fill:#16213e style C2NS fill:#0f3460

cgroups v2 (Control Groups)

cgroups limit and account for resource usage. Critical for multi-tenant systems.

Controller What It Limits Key Parameters Example
cpu CPU time cpu.max (quota/period) Limit to 50% of one core: 50000 100000
memory RAM usage memory.max, memory.high (soft limit) Hard limit 1GB: echo 1G > memory.max
io Disk I/O bandwidth and IOPS io.max (rbps, wbps, riops, wiops) Limit writes to 100 MB/s: 8:0 wbps=104857600
pids Number of processes pids.max Max 100 processes: echo 100 > pids.max
cpuset CPU cores and NUMA nodes cpuset.cpus, cpuset.mems Pin to cores 0-3: echo 0-3 > cpuset.cpus

cgroup Enforcement Example

# Create cgroup for container
mkdir /sys/fs/cgroup/container_a
echo $PID > /sys/fs/cgroup/container_a/cgroup.procs

# Limit to 512MB RAM
echo 512M > /sys/fs/cgroup/container_a/memory.max

# Limit to 50% of 2 CPUs (100,000 μs period)
echo "100000 100000" > /sys/fs/cgroup/container_a/cpu.max

# Limit disk writes to 50 MB/s (device 8:0 = /dev/sda)
echo "8:0 wbps=52428800" > /sys/fs/cgroup/container_a/io.max

# What happens on violation?
# - Memory: OOM killer terminates processes in cgroup
# - CPU: Throttled (process sleeps until next period)
# - I/O: Requests queued, throughput capped

OverlayFS: Container Layered File System

graph TB subgraph ContainerView["Container View (Unified)"] Merged["Merged FS
(what container sees)
/bin, /lib, /app, /tmp"] end subgraph Layers["Actual Layers (Read-Only)"] Upper["Upper (Read-Write)
Container-specific changes
/tmp/session.log"] Work["Work Dir
(scratch space)"] Lower1["Layer 3: App Layer
/app/server.js"] Lower2["Layer 2: npm Dependencies
/node_modules"] Lower3["Layer 1: Base OS
/bin, /lib, /etc"] end Merged -->|"OverlayFS mount"| Upper Merged -.->|"Read-through"| Lower1 Merged -.->|"Read-through"| Lower2 Merged -.->|"Read-through"| Lower3 Upper -->|"Copy-on-Write"| Work Note1["Writes go to Upper
(only layer writable)"] Note2["Reads check Upper first,
then Lower layers"] style Merged fill:#e94560 style Upper fill:#16213e style Lower3 fill:#0f3460

Copy-on-Write (CoW) in OverlayFS

sequenceDiagram participant App as Container App participant Upper as Upper Layer (RW) participant Lower as Lower Layer (RO) Note over App,Lower: Read operation App->>Upper: Read /etc/config.json Upper-->>App: Not found App->>Lower: Read /etc/config.json Lower-->>App: Return file Note over App,Lower: Modify operation App->>Upper: Write to /etc/config.json Note over Upper: File not in Upper yet Upper->>Lower: Copy file to Upper Upper->>Upper: Modify copied file Upper-->>App: Write success Note over Lower: Original file unchanged
(shared by all containers)

Container vs VM Comparison

Aspect Container Virtual Machine
Isolation Process-level (namespaces) Hardware-level (hypervisor)
Kernel Shared host kernel Separate guest kernel
Boot Time <1 second 10-60 seconds
Memory Overhead ~5-10 MB per container ~512 MB - 2 GB per VM
Density 100s-1000s per host 10-100 per host
Performance Native (no virtualization overhead) ~5-10% overhead (hypervisor)
Security Weaker (shared kernel, kernel exploits affect all) Stronger (full isolation, hardware-assisted)
OS Support Same OS as host (Linux containers on Linux) Any OS (Windows VM on Linux host)
Use Case Microservices, CI/CD, PaaS Full OS isolation, legacy apps, untrusted workloads
Interview Signal

Q: "How does Docker achieve isolation without the overhead of VMs?"

A:

  • Namespaces: Provide process, network, and filesystem isolation. Container sees its own PID 1, IP address, and root FS.
  • cgroups: Limit resources (CPU, memory, I/O). Prevents one container from starving others.
  • OverlayFS: Layers read-only base images with read-write container layer. Enables fast startup (no full FS copy) and efficient storage (shared base layers).
  • Key difference from VMs: Containers share the host kernel, so no hypervisor overhead. Boot time <1s (vs 10-60s for VM). Memory overhead ~10MB (vs ~1GB for VM).
  • Trade-off: Weaker isolation than VMs. Kernel exploits can escape container. For untrusted workloads, use VMs or sandboxed runtimes (gVisor, Firecracker).

Real Numbers: Container Performance

// Docker benchmark: Redis throughput
// Setup: Linux 5.15, 16-core Xeon, 64GB RAM

Bare metal:             180K req/s, 0.5ms p99 latency
Docker (default):       175K req/s (-3%), 0.6ms p99 latency
Docker (--net=host):    178K req/s (-1%), 0.5ms p99 latency
VM (KVM):               150K req/s (-17%), 1.2ms p99 latency

// Boot time comparison (hello-world app)
Container (Docker):     80 ms
VM (KVM):               12 seconds
VM (Firecracker):       125 ms (micro-VM)

// Memory overhead (idle)
Container:              8 MB
VM (KVM):               512 MB (minimal guest)
VM (Firecracker):       5 MB (micro-VM)

// Density (16-core host, 64GB RAM)
Containers:             500+ (limited by memory, not overhead)
VMs:                    30-50 (limited by guest OS overhead)

2.10 Concurrency Primitives

Primitives Comparison

Primitive Scope Overhead Contention Behavior Best For
Mutex Single resource ~25 ns (uncontended), ~1-5 μs (contended) Sleep (blocks thread) Protecting shared data structures
Semaphore N resources ~30 ns (uncontended), ~1-5 μs (contended) Sleep (blocks thread) Bounded queues, resource pools
Spinlock Single resource ~5 ns (uncontended), burns CPU (contended) Spin (busy-wait) Very short critical sections (<100 ns)
RWLock Read-heavy workloads ~35 ns (read), ~50 ns (write) Sleep (blocks thread) Many readers, rare writers
Atomic (CAS) Single variable ~5 ns (uncontended), ~20 ns (contended) Retry loop Counters, lock-free data structures

Mutex (Mutual Exclusion)

Guarantees: Only one thread can hold the lock at a time.

// C++ std::mutex example
std::mutex mtx;
std::vector<int> shared_data;

void worker() {
    mtx.lock();
    // Critical section: only one thread at a time
    shared_data.push_back(42);
    mtx.unlock();
}

// RAII style (preferred)
void worker_safe() {
    std::lock_guard<std::mutex> lock(mtx);
    shared_data.push_back(42);
    // Automatic unlock on scope exit
}

Semaphore

Use case: Limit concurrent access to N resources (e.g., connection pool of size 10).

// POSIX semaphore example
sem_t pool_semaphore;
sem_init(&pool_semaphore, 0, 10); // 10 available slots

void worker() {
    sem_wait(&pool_semaphore);  // Decrement, block if 0
    // Use resource (e.g., database connection)
    query_database();
    sem_post(&pool_semaphore);  // Increment, wake waiter
}

Spinlock

When to use: Critical section is shorter than context switch cost (~1 μs).

// Spinlock example (Linux kernel style)
spinlock_t lock;
spin_lock_init(&lock);

void critical_function() {
    spin_lock(&lock);
    // Very short critical section (<100 ns)
    counter++;
    spin_unlock(&lock);
}

// WARNING: Holding spinlock too long burns CPU!
// Rule of thumb: Use spinlock if critical section < 100 ns
//                Use mutex if critical section > 1 μs

RWLock (Read-Write Lock)

Optimization: Multiple readers can hold lock simultaneously, but writers get exclusive access.

sequenceDiagram participant R1 as Reader 1 participant R2 as Reader 2 participant W as Writer participant Lock as RWLock R1->>Lock: read_lock() Lock-->>R1: Granted (count = 1) R2->>Lock: read_lock() Lock-->>R2: Granted (count = 2) Note over R1,R2: Both read simultaneously W->>Lock: write_lock() Note over W: BLOCKED (readers active) R1->>Lock: read_unlock() (count = 1) R2->>Lock: read_unlock() (count = 0) Lock-->>W: Granted (exclusive) Note over W: Writes (no readers/writers) W->>Lock: write_unlock()
// C++ shared_mutex (RWLock)
std::shared_mutex rw_lock;
std::map<std::string, int> cache;

// Many readers
void read_cache(const std::string& key) {
    std::shared_lock<std::shared_mutex> lock(rw_lock);
    auto value = cache[key]; // Safe to read
}

// Rare writer
void update_cache(const std::string& key, int value) {
    std::unique_lock<std::shared_mutex> lock(rw_lock);
    cache[key] = value; // Exclusive access
}

Atomics and Compare-and-Swap (CAS)

Lock-free synchronization: Use CPU atomic instructions instead of locks.

// C++ std::atomic example
std::atomic<int> counter(0);

void increment() {
    counter.fetch_add(1, std::memory_order_relaxed);
    // Single CPU instruction (LOCK XADD on x86)
}

// Compare-and-Swap (CAS)
std::atomic<int> value(100);

bool try_decrement() {
    int expected = value.load();
    while (expected > 0) {
        // Atomically: if (value == expected) { value = expected - 1; return true; }
        if (value.compare_exchange_weak(expected, expected - 1)) {
            return true; // Success
        }
        // expected updated to current value, retry
    }
    return false; // Value is 0
}

Memory Ordering (Acquire/Release)

Memory Order Guarantees Overhead Use Case
relaxed No ordering guarantees Lowest (single instruction) Counters, statistics (order doesn't matter)
acquire Prevents reads/writes from moving before this operation Low (memory barrier) Lock acquisition, reading shared data
release Prevents reads/writes from moving after this operation Low (memory barrier) Lock release, publishing shared data
acq_rel Both acquire and release Medium Fetch-and-add, compare-and-swap
seq_cst Sequentially consistent (total order) Highest (full fence) When unsure, or strict ordering needed

Lock-Free Queue Example

graph LR subgraph Queue["Lock-Free Queue (SPSC)"] Head["Head (atomic)
Consumer reads here"] Slots["Circular Buffer
[0][1][2][3]"] Tail["Tail (atomic)
Producer writes here"] end Producer["Producer Thread"] -->|"CAS on Tail"| Tail Tail -->|"write to buffer[tail]"| Slots Slots -->|"read from buffer[head]"| Head Head -->|"CAS on Head"| Consumer["Consumer Thread"] style Head fill:#e94560 style Tail fill:#16213e

ABA Problem

Problem: CAS can't distinguish between "unchanged" and "changed back to original value".

// ABA Problem Example
// Thread 1: Reads value A from stack top
// Thread 2: Pops A, pops B, pushes A back (stack looks unchanged!)
// Thread 1: CAS succeeds (thinks nothing changed), but stack is corrupted

// Solution 1: Use version counter (ABA → ABA₁ → ABA₂)
struct Node {
    int value;
    uint64_t version; // Incremented on every change
};

// Solution 2: Use hazard pointers (mark nodes in use)
// Solution 3: Use epoch-based reclamation (defer deletion)

When to Use What

Use Mutex When

  • Critical section > 1 μs
  • Need to protect complex state (multiple variables)
  • Contention is moderate (<50%)
  • Blocking is acceptable

Example: Protecting a shared cache, database connection pool

Use Spinlock When

  • Critical section < 100 ns
  • Very low contention
  • Running on dedicated cores (no thread oversubscription)
  • Cannot afford context switch

Example: Kernel code, real-time systems, incrementing a counter

Use RWLock When

  • Read-heavy workload (90%+ reads)
  • Critical section is long
  • Many concurrent readers

Example: Configuration cache, routing table

Use Atomics When

  • Single variable (counter, flag, pointer)
  • Need lock-free guarantees
  • High contention (CAS loop faster than mutex)
  • Building lock-free data structures

Example: Reference counting, statistics, lock-free queues

Interview Signal

Q: "Your multi-threaded service spends 40% of CPU time in lock contention (pthread_mutex_lock). How do you reduce it?"

A:

  1. Identify hot locks: Use profiler (perf, gdb) to find most contended mutex
  2. Reduce critical section: Move non-critical work outside lock
  3. Lock striping: Split one lock into N locks (e.g., hash table with per-bucket locks)
  4. RWLock for read-heavy: If 90%+ reads, use shared_mutex (multiple readers)
  5. Lock-free for simple cases: If just a counter, use std::atomic
  6. Per-thread data: Eliminate synchronization by giving each thread its own data (aggregate later)

Example: Redis uses single-threaded model to avoid locks entirely. For multi-threaded, Memcached uses per-thread hash table partitions (lock striping).

Real Numbers: Primitive Performance

// Benchmark: Intel Xeon Gold 6248R @ 3.0GHz, Linux 5.15
// Increment counter 100M times, varying contention

Uncontended (single thread):
  Mutex:                    ~25 ns per lock/unlock
  Spinlock:                 ~5 ns per lock/unlock
  Atomic (relaxed):         ~2 ns per fetch_add
  Atomic (seq_cst):         ~5 ns per fetch_add

High contention (16 threads):
  Mutex:                    ~1.2 μs per lock/unlock (context switch)
  Spinlock:                 ~400 ns per lock/unlock (spin time)
  Atomic CAS (retry loop):  ~50 ns per successful CAS
  RWLock (90% reads):       ~80 ns per read, ~1.5 μs per write

Lock-free queue (SPSC):     80M ops/sec
Lock-free queue (MPMC):     25M ops/sec (more CAS retries)
Mutex-based queue:          5M ops/sec (lock overhead)

2.11 Event Loop Architectures

1. Single-Threaded Event Loop (Node.js)

graph TB subgraph EventLoop["Single Event Loop Thread"] Queue["Event Queue"] Poll["epoll_wait()"] Dispatcher["Event Dispatcher"] subgraph Handlers["Event Handlers"] H1["HTTP Request Handler"] H2["Database Callback"] H3["Timer Callback"] end end subgraph ThreadPool["Worker Thread Pool (libuv)"] W1["Worker 1
(File I/O)"] W2["Worker 2
(DNS)"] W3["Worker 3
(Crypto)"] W4["Worker 4
(Compression)"] end Client["Clients"] -->|"Requests"| Queue Queue --> Poll Poll --> Dispatcher Dispatcher --> H1 Dispatcher --> H2 Dispatcher --> H3 H1 -.->|"Async file read"| W1 W1 -.->|"Callback"| Queue H2 -.->|"Async DNS"| W2 W2 -.->|"Callback"| Queue style EventLoop fill:#1a1a2e style Poll fill:#e94560 style ThreadPool fill:#16213e

Characteristics:

  • Pros: No thread synchronization overhead, simple mental model, efficient for I/O-bound workloads
  • Cons: One CPU-heavy task blocks all requests, cannot utilize multiple cores (without clustering)
  • Used by: Node.js, Redis, Nginx (per worker)

2. Multi-Reactor Pattern (Netty, Nginx)

graph TB subgraph MainReactor["Main Reactor Thread"] Acceptor["Accept Connections"] LoadBalancer["Load Balancer
(round-robin)"] end subgraph Worker1["Worker Reactor 1"] EL1["Event Loop (epoll)"] H1["Request Handlers"] end subgraph Worker2["Worker Reactor 2"] EL2["Event Loop (epoll)"] H2["Request Handlers"] end subgraph Worker3["Worker Reactor 3"] EL3["Event Loop (epoll)"] H3["Request Handlers"] end subgraph Worker4["Worker Reactor 4"] EL4["Event Loop (epoll)"] H4["Request Handlers"] end Clients["Clients"] --> Acceptor Acceptor --> LoadBalancer LoadBalancer -->|"Connection 1"| EL1 LoadBalancer -->|"Connection 2"| EL2 LoadBalancer -->|"Connection 3"| EL3 LoadBalancer -->|"Connection 4"| EL4 EL1 --> H1 EL2 --> H2 EL3 --> H3 EL4 --> H4 style MainReactor fill:#e94560 style Worker1 fill:#16213e style Worker2 fill:#16213e style Worker3 fill:#16213e style Worker4 fill:#16213e

Characteristics:

  • Pros: Scales to multiple cores, load balanced, each worker independent
  • Cons: Inter-worker communication requires synchronization, uneven load distribution possible
  • Used by: Nginx (multi-process), Netty, HAProxy

3. Work-Stealing (Go Runtime, Tokio)

graph TB subgraph Scheduler["Go Runtime Scheduler"] GMP["G-M-P Model"] end subgraph P1["Processor 1 (CPU Core 0)"] LQ1["Local Queue"] M1["OS Thread M1"] G1["Goroutine G1"] G2["Goroutine G2"] end subgraph P2["Processor 2 (CPU Core 1)"] LQ2["Local Queue"] M2["OS Thread M2"] G3["Goroutine G3"] end subgraph P3["Processor 3 (CPU Core 2)"] LQ3["Local Queue (Empty)"] M3["OS Thread M3"] end GlobalQueue["Global Run Queue"] GMP --> P1 GMP --> P2 GMP --> P3 LQ1 --> G1 LQ1 --> G2 G1 --> M1 LQ2 --> G3 G3 --> M2 GlobalQueue -.->|"Steal"| LQ3 LQ1 -.->|"P3 steals from P1"| LQ3 Note1["When P3 runs out of work,
it steals from P1's queue"] style P1 fill:#16213e style P2 fill:#16213e style P3 fill:#0f3460 style GMP fill:#e94560

Characteristics:

  • Pros: Automatic load balancing, efficient CPU utilization, handles CPU-bound and I/O-bound tasks
  • Cons: More complex scheduler, potential for work-stealing overhead
  • Used by: Go runtime, Tokio (Rust), Rayon (Rust)

4. Actor Model (Erlang, Akka)

graph TB subgraph System["Actor System"] Scheduler["Scheduler (BEAM VM)"] subgraph A1["Actor 1
(Mailbox)"] M1["Message Queue"] State1["Private State"] Process1["Process Logic"] end subgraph A2["Actor 2
(Mailbox)"] M2["Message Queue"] State2["Private State"] Process2["Process Logic"] end subgraph A3["Actor 3
(Mailbox)"] M3["Message Queue"] State3["Private State"] Process3["Process Logic"] end end Scheduler -->|"Schedule"| A1 Scheduler -->|"Schedule"| A2 Scheduler -->|"Schedule"| A3 A1 -->|"Send message"| M2 A2 -->|"Send message"| M3 A3 -->|"Send message"| M1 M1 --> Process1 Process1 --> State1 M2 --> Process2 M3 --> Process3 Note1["No shared state!
Only message passing"] style A1 fill:#16213e style A2 fill:#16213e style A3 fill:#16213e style Scheduler fill:#e94560

Characteristics:

  • Pros: No shared state (no locks!), fault isolation (one actor crash doesn't kill others), natural distributed model
  • Cons: Message passing overhead, potential mailbox overflow, harder to reason about data flow
  • Used by: Erlang/Elixir (BEAM VM), Akka (JVM), Orleans (.NET)

Model Comparison

Model Concurrency State Sharing CPU Utilization Best For
Single Event Loop Single thread No synchronization needed 1 core I/O-bound, low latency, simple logic
Multi-Reactor N threads (1 per core) Minimal (mostly isolated) N cores High throughput, network services
Work-Stealing M:N (M tasks, N threads) Requires synchronization N cores (dynamic balance) Mixed workloads (I/O + CPU)
Actor Model M:N (M actors, N threads) None (message passing only) N cores Distributed systems, fault tolerance

Why Redis is Single-Threaded (Yet Fast)

Redis Design Decision

Why single-threaded?

  1. Eliminates locks: No synchronization overhead, no race conditions
  2. CPU is not the bottleneck: Redis is I/O-bound (network, disk). One core can handle 100K+ ops/sec
  3. Simple mental model: Easier to reason about, fewer bugs
  4. Predictable latency: No lock contention, no context switches

How it scales:

  • Single-threaded for command execution
  • Multi-threaded for background tasks (AOF rewrite, RDB snapshots)
  • Multi-threaded I/O (Redis 6.0+) for socket read/write
  • Cluster mode for horizontal scaling

Result: 80K-100K req/sec per instance, p99 latency <1ms

When to Use Each Model

Single Event Loop

  • Workload is I/O-bound
  • Tasks are short (<10ms)
  • Simplicity is priority
  • Single core sufficient

Examples: Redis, Node.js API server, NGINX worker

Multi-Reactor

  • Need to use all CPU cores
  • Workload is I/O-bound
  • Workers mostly independent
  • High connection count

Examples: Nginx, HAProxy, Netty server

Work-Stealing

  • Mixed I/O and CPU tasks
  • Variable task duration
  • Want automatic load balancing
  • Can tolerate some overhead

Examples: Go web services, Tokio async runtime

Actor Model

  • Building distributed system
  • Need fault isolation
  • State machines per entity
  • Natural message-passing flow

Examples: WhatsApp (Erlang), real-time systems, game servers

Interview Signal

Q: "Your Node.js API server handles 10K req/sec well, but performance degrades when you add CPU-heavy image processing. Why, and how do you fix it?"

A:

Root cause: Node.js uses single-threaded event loop. CPU-heavy tasks block the event loop, preventing it from handling other requests. While image processing runs, all other requests wait.

Solutions:

  1. Offload to worker threads: Use Node.js Worker Threads to run CPU-heavy tasks in separate threads
  2. Separate service: Move image processing to dedicated Go/Rust service (better for CPU-bound)
  3. Async queue: Push tasks to queue (RabbitMQ, Redis), process by separate workers
  4. Streaming: Use child_process to run native code (e.g., ImageMagick) without blocking event loop

Key insight: Single event loop works great for I/O-bound, but CPU-bound tasks require multi-threading or separate processes.

Real Numbers: Event Loop Performance

// Benchmark: HTTP echo server, 100K concurrent connections
// Setup: Linux 5.15, 16-core Xeon

Node.js (single thread):        45K req/s (1 core saturated)
Node.js (cluster mode, 16):     520K req/s (linear scaling)

Nginx (multi-reactor, 16):      680K req/s, 8ms p99 latency
Go (work-stealing, 16):         620K req/s, 10ms p99 latency

Erlang (actor model, 16):       450K req/s, 15ms p99 latency
  (Higher latency due to message passing overhead)

// CPU-bound task impact (1ms compute per request)
Node.js (single thread):        900 req/s (blocked!)
Go (goroutines):                14K req/s (utilizes all cores)
Rust (Tokio):                   15K req/s

// Context: Why multi-reactor beats work-stealing for I/O
Multi-reactor: Minimal contention (per-core event loops)
Work-stealing: Some contention (shared run queue, stealing overhead)
Result: ~10% faster for pure I/O workloads

2.12 Signals and Interrupts

Hardware Interrupts vs Software Interrupts

Type Source Examples Latency Priority
Hardware Interrupt (IRQ) Hardware device Network packet arrival, disk I/O complete, timer tick ~1-3 μs Highest (preempts everything)
Software Interrupt (Signal) Kernel or process SIGTERM, SIGSEGV, SIGALRM ~1 μs Lower (delivered asynchronously)
Exception (Trap) CPU instruction Page fault, divide by zero, syscall ~100-300 ns Synchronous (immediate)

Interrupt Handling Flow

sequenceDiagram participant App as Application participant CPU as CPU participant NIC as Network Card participant Kernel as Kernel IRQ Handler participant Driver as Driver Bottom Half Note over App: Running user code NIC->>CPU: Hardware IRQ (packet arrived) Note over CPU: Save registers,
switch to kernel mode CPU->>Kernel: Jump to IRQ handler Note over Kernel: Top Half (fast!)
~1-2 μs Kernel->>Kernel: Acknowledge IRQ
Schedule bottom half
Re-enable interrupts Kernel->>CPU: Return from interrupt Note over CPU: Restore registers CPU->>App: Resume user code Note over App: Continue running Note over Driver: Bottom Half (deferred)
Run in softirq context Driver->>Driver: Process packet
Update network stack
Wake application

Interrupt Coalescing (Adaptive Batching)

Problem: At high packet rates (1M+ pps), interrupt overhead dominates CPU time.

Without Coalescing

// 1M packets/sec = 1 IRQ per μs
Packet 1 → IRQ → Process (3 μs)
Packet 2 → IRQ → Process (3 μs)
Packet 3 → IRQ → Process (3 μs)
...

Total CPU time: 3M μs = 3 CPU cores

Problem: 100% CPU spent on IRQ handling!

With Coalescing (50 μs delay)

// Batch 50 packets per IRQ
Packets 1-50 → Wait 50 μs → IRQ → Process batch (10 μs)
Packets 51-100 → Wait 50 μs → IRQ → Process batch (10 μs)
...

Total IRQs: 20K/sec (vs 1M/sec)
Total CPU time: 200K μs = 0.2 CPU cores

Trade-off: +50 μs latency, but 15x lower CPU usage

Interrupt Coalescing Tuning

# View current settings
ethtool -c eth0

# Tune interrupt coalescing
# rx-usecs: Delay before IRQ (μs)
# rx-frames: Max packets before IRQ (even if delay not elapsed)

# Low latency (default)
ethtool -C eth0 rx-usecs 0 rx-frames 1
# Result: IRQ per packet, 1-3 μs latency, high CPU

# Balanced (good for most workloads)
ethtool -C eth0 rx-usecs 50 rx-frames 32
# Result: ~50 μs latency, moderate CPU

# High throughput (batch processing)
ethtool -C eth0 rx-usecs 200 rx-frames 128
# Result: ~200 μs latency, low CPU, high throughput

# Adaptive (driver adjusts based on load)
ethtool -C eth0 adaptive-rx on
# Result: Low latency under low load, batching under high load

NAPI: Interrupt to Polling Transition

NAPI (New API) automatically switches between interrupt mode (low load) and polling mode (high load).

graph TB subgraph LowLoad["Low Load (<10K pps)"] L1["Interrupt Mode"] L2["NIC sends IRQ per packet"] L3["Driver processes packet"] L4["Re-enable IRQ"] end subgraph HighLoad["High Load (>50K pps)"] H1["Polling Mode"] H2["Disable IRQ"] H3["Driver polls NIC ring buffer"] H4["Process batch (32-64 packets)"] H5["Repeat until empty"] H6["Re-enable IRQ"] end L1 --> L2 --> L3 --> L4 L4 -.->|"High packet rate
detected"| H1 H1 --> H2 --> H3 --> H4 --> H5 H5 --> H3 H5 -.->|"No more packets"| H6 H6 -.->|"Low packet rate"| L1 style L1 fill:#16213e style H1 fill:#e94560

IRQ Affinity for CPU Pinning

Goal: Pin IRQ handlers to specific CPUs to improve cache locality.

# Find IRQ number for eth0
cat /proc/interrupts | grep eth0
# Output: 42: eth0-TxRx-0

# Pin IRQ 42 to CPU 0 (bitmask: 0x01)
echo 1 > /proc/irq/42/smp_affinity

# Pin IRQ 43 to CPU 1 (bitmask: 0x02)
echo 2 > /proc/irq/43/smp_affinity

# Pin IRQ 44 to CPUs 2-3 (bitmask: 0x0C = 1100 binary)
echo c > /proc/irq/44/smp_affinity

# Automated: Use irqbalance daemon
systemctl start irqbalance
# irqbalance dynamically distributes IRQs across CPUs

How Interrupts Affect Tail Latency

Scenario: Latency-sensitive application (target p99 < 5ms)

sequenceDiagram participant App as Application participant CPU as CPU Core 0 participant NIC as Network Card participant IRQ as IRQ Handler Note over App: Processing request
(3ms so far) NIC->>CPU: Hardware IRQ Note over CPU: PREEMPT!
Save app context CPU->>IRQ: Handle interrupt Note over IRQ: Process packet
(500 μs) IRQ->>CPU: Return from IRQ Note over CPU: Restore app context CPU->>App: Resume processing Note over App: Finish request
(+2ms) Note over App: Total latency:
3ms + 0.5ms (IRQ) + 2ms
= 5.5ms
Missed p99 target!

Mitigating Interrupt Latency

Strategy Implementation Impact
IRQ affinity pinning Pin IRQs to CPU 0-7, run app on CPU 8-15 Eliminates IRQ preemption of app threads
Isolate CPUs isolcpus=8-15 kernel param Reserve CPUs for app, no IRQs or kernel tasks
Disable IRQBalance systemctl stop irqbalance Prevent dynamic IRQ migration (cache-hot)
Increase NAPI budget echo 600 > /proc/sys/net/core/netdev_budget Process more packets per softirq (fewer interrupts)
Interrupt coalescing ethtool -C eth0 rx-usecs 50 Batch interrupts, reduce IRQ frequency
Use DPDK/XDP Bypass kernel, poll NIC directly Zero interrupts, predictable latency (<5 μs)

Linux Signals

Asynchronous notifications sent to processes. Common signals:

Signal Number Default Action Use Case Can Catch?
SIGTERM 15 Terminate gracefully Graceful shutdown (close connections, flush logs) Yes
SIGKILL 9 Terminate immediately Force kill (cannot be caught) No
SIGINT 2 Interrupt (Ctrl+C) User interrupt Yes
SIGHUP 1 Hangup (terminal closed) Reload config (Nginx, Apache) Yes
SIGSEGV 11 Segmentation fault Invalid memory access Yes (to log stack trace)
SIGUSR1/SIGUSR2 10/12 User-defined Custom actions (e.g., dump stats) Yes

Signal Handling Best Practices

// C++ signal handler example
#include <signal.h>
#include <unistd.h>
#include <atomic>

std::atomic<bool> shutdown_requested(false);

void signal_handler(int signum) {
    // IMPORTANT: Signal handlers must be async-signal-safe!
    // Safe: write(), _exit(), atomic ops
    // Unsafe: printf(), malloc(), most library functions

    if (signum == SIGTERM || signum == SIGINT) {
        shutdown_requested.store(true);
        // Write to pipe to wake event loop
        write(shutdown_pipe, "X", 1);
    }
}

int main() {
    // Install signal handlers
    signal(SIGTERM, signal_handler);
    signal(SIGINT, signal_handler);

    // Event loop
    while (!shutdown_requested.load()) {
        // Process requests
        handle_request();
    }

    // Graceful shutdown
    close_connections();
    flush_logs();
    return 0;
}
Interview Signal

Q: "Your service's p99 latency is 10ms, but occasionally spikes to 50ms. Profiling shows no application bottleneck. What could cause this?"

A:

Root cause candidates:

  1. Interrupt storm: High network traffic on same machine causes IRQs to preempt application threads
    • Diagnosis: cat /proc/interrupts, check IRQ rate
    • Fix: Pin IRQs to separate CPUs (smp_affinity), use NAPI polling
  2. Scheduler preemption: Kernel migrates thread across CPUs (cache cold)
    • Diagnosis: perf record -e sched:sched_switch
    • Fix: CPU pinning (taskset), isolcpus kernel param
  3. Page faults: Memory pressure causes pages to be swapped out
    • Diagnosis: perf stat -e page-faults
    • Fix: Increase memory, use mlock() to pin critical pages

Solution: Isolate application CPUs, pin IRQs to separate cores, disable CPU frequency scaling. Result: p99 drops to 5-8ms by eliminating interrupt preemption.

Real Numbers: Interrupt Performance

// Benchmark: 10GbE network, UDP flood test
// Setup: Linux 5.15, Intel X710 NIC, 16-core Xeon

No tuning (interrupt per packet):
  Packet rate:              600K pps
  CPU usage (si%):          65% (IRQ overhead)
  Application throughput:   Low (starved by IRQs)

Interrupt coalescing (rx-usecs=50):
  Packet rate:              1.2M pps (+100%)
  CPU usage (si%):          15% (batched IRQs)
  Latency impact:           +50 μs (acceptable)

NAPI polling (netdev_budget=600):
  Packet rate:              2.5M pps (+4x)
  CPU usage (si%):          8% (mostly polling)
  Latency:                  ~20 μs

IRQ affinity (pin to cores 0-7):
  Application p99 latency:  4ms (vs 12ms without pinning)
  Context switches:         -80% (no IRQ preemption)

DPDK (kernel bypass):
  Packet rate:              14M pps (+23x)
  CPU usage:                100% (dedicated polling)
  Latency:                  ~3 μs (no interrupts)