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
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
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
Problem: Busy-waiting wastes CPU cycles. Inefficient for thousands of sockets.
3. I/O Multiplexing (select/poll/epoll)
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
Rarely used: Signal handling is tricky, limited socket info in signal.
5. Asynchronous I/O (io_uring)
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)
Proactor Pattern (io_uring/IOCP)
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
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
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)
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)
(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.
(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)
(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:
- Pin threads to CPUs (
taskset) to avoid migration - Isolate CPUs from kernel tasks (
isolcpus) - Use real-time scheduling (
SCHED_FIFO) for request handlers - Reduce thread count (thread-per-core model like Seastar)
- Pin threads to CPUs (
- 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
(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.
[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
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) |
Q: "Your database has high write throughput (100K writes/sec). It's on ext4. How do you optimize disk I/O?"
A:
- Switch to XFS: Better parallelism (allocation groups), less fragmentation for large files
- Use Direct I/O: Databases like PostgreSQL, MySQL use O_DIRECT to bypass page cache and control their own caching
- Disable atime: Mount with
noatimeto avoid updating access time on every read (one less write per read) - Tune I/O scheduler: Use
noneormq-deadlinefor SSDs (no need for seek optimization) - Increase journal size:
mkfs.xfs -l size=512mreduces journal wrap-around overhead - 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
(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.
(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).
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:
- Enable RSS: Spread packet processing across cores. Check NIC supports multi-queue:
ethtool -l eth0 - Tune NAPI: Increase polling budget:
echo 600 > /proc/sys/net/core/netdev_budget(default 300) - Interrupt coalescing: Batch interrupts:
ethtool -C eth0 rx-usecs 50(delay 50 μs) - IRQ affinity: Pin IRQs to specific CPUs, pin application to different CPUs (avoid context switch)
- 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
(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
(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
(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 |
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.
// 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
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
Q: "Your multi-threaded service spends 40% of CPU time in lock contention (pthread_mutex_lock). How do you reduce it?"
A:
- Identify hot locks: Use profiler (perf, gdb) to find most contended mutex
- Reduce critical section: Move non-critical work outside lock
- Lock striping: Split one lock into N locks (e.g., hash table with per-bucket locks)
- RWLock for read-heavy: If 90%+ reads, use shared_mutex (multiple readers)
- Lock-free for simple cases: If just a counter, use std::atomic
- 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)
(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)
(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)
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)
(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)
Why single-threaded?
- Eliminates locks: No synchronization overhead, no race conditions
- CPU is not the bottleneck: Redis is I/O-bound (network, disk). One core can handle 100K+ ops/sec
- Simple mental model: Easier to reason about, fewer bugs
- 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
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:
- Offload to worker threads: Use Node.js Worker Threads to run CPU-heavy tasks in separate threads
- Separate service: Move image processing to dedicated Go/Rust service (better for CPU-bound)
- Async queue: Push tasks to queue (RabbitMQ, Redis), process by separate workers
- 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
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).
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)
(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;
}
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:
- 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
- Diagnosis:
- Scheduler preemption: Kernel migrates thread across CPUs (cache cold)
- Diagnosis:
perf record -e sched:sched_switch - Fix: CPU pinning (
taskset),isolcpuskernel param
- Diagnosis:
- 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
- Diagnosis:
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)