How the OS runs your backend: processes, scheduling, and memory
Context switches, CFS scheduling, virtual memory, file descriptors, and why epoll replaced thread-per-connection.
Most backend performance puzzles come from the operating system. A p99 spike, an OOM kill, a server that stops accepting connections at exactly 1024 clients. These are rarely application bugs. The OS did what it was configured to do. This post covers the four OS ideas that explain most backend behavior. They are processes and threads, CPU scheduling, virtual memory, and file descriptors with event-driven I/O.
1. Processes and threads
A process is an address space plus resources: page tables, file descriptors, signal handlers, and one or more threads. A thread is a schedulable execution context. It owns a stack, registers, and a program counter, and it shares the address space with its siblings.
On Linux the distinction is thinner than textbooks suggest. The kernel creates both with clone and different sharing flags:
// fork(): child gets a copy of the address space (via copy-on-write)
pid_t pid = fork();
// pthread_create(): new thread shares address space, FDs, signal handlers
pthread_create(&t, NULL, worker, arg);
fork looks expensive but costs little at first because of copy-on-write, or CoW. Parent and child share physical pages marked read-only. The kernel copies a page only when one side writes to it. Spawning is fast. The cost lands later, in proportion to how much memory the child dirties.
Practical consequences:
- Threads share everything, so one wild pointer corrupts all of them. Processes isolate memory, so one crashing worker leaves the rest running. That isolation is why Nginx and Postgres use multi-process designs.
- Threads cost less to create and switch between than processes, but each thread still needs a stack. The default is 8 MB of virtual address space per thread on Linux. Ten thousand threads reserve 80 GB of virtual space plus real kernel bookkeeping. That reservation is what stops thread-per-connection servers from scaling.
- Thread switch
- ~1-2 µs
- Process switch
- ~3-5 µs
- Syscall round-trip
- ~100-300 ns
2. Context switches cost cache as well as time
A context switch saves one thread's registers and stack pointer, switches address spaces if needed, and restores the next thread. The address space switch touches the TLB. There are two costs.
Direct cost is the microseconds of kernel code that performs the switch. Indirect cost is the cache and TLB state the switch throws away. The incoming thread finds cold L1 and L2 caches and pays a burst of cache misses while it warms up. The indirect cost dominates, and profilers never attribute it to the switch.
Every read, write, send, or recv on a socket also crosses into the kernel through a syscall. The CPU changes privilege rings, validates arguments, copies data, and returns. One syscall costs 100 to 300 nanoseconds, which is nothing on its own. Ten thousand connections, each with a parked thread doing blocking syscalls, keep the scheduler switching instead of working.
How to see it:
# voluntary vs involuntary context switches per process
pidstat -w -p <PID> 1
# system-wide switch rate: sustained counts above 100k per second deserve investigation
vmstat 1
# watch the `cs` column
# which syscalls does it make?
strace -c -p <PID>
3. Scheduling, vruntime, and load average
Linux's Completely Fair Scheduler, or CFS, assigns no fixed timeslices. It tracks each task's vruntime, which is runtime normalized by weight from niceness, and always runs the task with the smallest vruntime next. Every runnable task converges on a fair share of CPU. A lower nice value earns a proportionally larger share.
What this means for backends:
- CPU-bound threads split the CPU. I/O-bound threads barely register. A thread blocked in
epoll_waitaccrues no vruntime, so the scheduler runs it first when its event arrives. - Load average is not CPU percent. A load of 8 on a 4-core box means 4 cores busy plus about 4 tasks waiting, averaged over the window. Load also counts uninterruptible sleep, which is mostly disk I/O wait, so high load with idle CPU points at storage, not compute.
- In containers, throttling looks like scheduling noise. A cgroup quota of 500m, half a core, lets the kernel hard-throttle the container every 100 ms period once it spends its quota. Latency histograms show fast requests alternating with stalled ones. Check
cpu.statnr_throttledbefore blaming application code.
# load averages plus runnable and existing thread counts
uptime
cat /proc/loadavg
# per-thread CPU: find the hot thread in a pool
top -H -p <PID>
# container throttling counters
cat /sys/fs/cgroup/cpu.stat | grep throttled
4. Virtual memory, page faults, and the OOM killer
Every process sees a flat private address space. The kernel maps virtual pages to physical frames through page tables, and the TLB caches hot translations. Nothing sits in RAM until first touch. malloc only reserves address space. The kernel allocates the physical page on the first write, and that allocation triggers a minor page fault.
Fault types matter:
| Type | What happens | Cost |
|---|---|---|
| Minor fault | The kernel maps a zero page or a CoW page, and everything stays in RAM | Microseconds |
| Major fault | The kernel reads the page from disk or swap | Milliseconds |
| Swap storm | The working set exceeds RAM, and pages churn in and out | Requests stall until the churn stops |
Two numbers matter. VSZ is reserved address space, mostly fiction. RSS is physical pages actually held. A Java process with a 4 GB heap shows far larger VSZ than RSS at startup.
When the box runs out, the OOM killer picks a victim by badness score, roughly the biggest RSS and tunable through oom_score_adj, and kills it without asking. In Kubernetes this appears as exit code 137. The choice is not random. Check dmesg for the victim line, then check whether limits cover heap plus off-heap plus page cache.
# RSS vs VSZ, sorted by resident
ps -eo pid,rss,vsz,comm --sort=-rss | head
# major vs minor faults per process
ps -eo pid,min_flt,maj_flt,comm -p <PID>
# did the OOM killer fire?
dmesg | grep -i "oom-killer\|killed process" | tail
5. File descriptors and epoll
On Unix, sockets, pipes, epoll instances, timers, and files are all file descriptors. Each one is a small integer into the process's open file table. Two limits set the connection ceiling: the per-process ulimit -n and the system-wide fs.file-max. The classic server that dies at 1024 connections hit the default FD limit.
The deeper question is how a process waits on thousands of FDs. select and poll scan the whole FD set on every call, which costs O(n) per wakeup. epoll inverts the work. The kernel tracks ready FDs and returns only those, about O(ready). One thread can watch tens of thousands of mostly idle connections:
int efd = epoll_create1(0);
epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &ev);
for (;;) {
// blocks until some FDs are ready, then returns only those
int n = epoll_wait(efd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++)
handle(events[i].data.fd);
}
This design underlies Node.js, the Nginx event loop, Netty, and the Go netpoller. Go keeps the model of blocking I/O in goroutines while the runtime multiplexes them onto epoll. The C10K problem ended when waiting became O(ready) instead of O(n).
# FD usage per process
ls /proc/<PID>/fd | wc -l
cat /proc/<PID>/limits | grep -i "open files"
# raise the limit in a systemd service
# LimitNOFILE=65536
Debugging cheat sheet
| Symptom | Likely OS cause | First command |
|---|---|---|
| High latency, low CPU | Lock contention, excess wakeups | pidstat -w, vmstat 1 |
| Sawtooth latency in a container | CPU quota throttling | cpu.stat nr_throttled |
| Exit 137 in Kubernetes | OOM kill, RSS over limit | dmesg, compare RSS against limit |
| Refused connections near 1024 | FD limit | ulimit -n, /proc/PID/limits |
| Slow after deploy, then recovers | Major faults while the page cache warms | maj_flt, sar -B |
Learn these five interfaces. They are clone, CFS vruntime, page faults, FDs, and epoll. Most backend incidents reduce to four questions. Who is on CPU, who is waiting, what is resident, and which thread woke which.