Linux
Paging & Context Switching
Every process on Linux believes it owns a private, contiguous chunk of memory starting near address zero, and believes it has the CPU to itself. Neither is true — paging is the illusion machinery for the first belief, and context switching is the illusion machinery for the second. Both are core kernel mechanisms with real, measurable costs.
- What it is
- How Linux gives every process its own private virtual address space (paging), and how the CPU is shared between processes and threads over time (context switching).
- Mental model
- Virtual memory is a lookup table sitting on top of real RAM; a context switch is the kernel hitting pause on one program and resume on another, saving and restoring just enough state that neither program can tell it happened.
- Key numbers
- A typical Linux timer tick fires every 1–4 ms (
CONFIG_HZ250–1000); switching between threads in the same process skips the TLB flush a process-to-process switch has to pay. - Reach for it when
- Diagnosing a high context-switch count, an unexplained first-touch latency spike, or reasoning about real-time scheduling behavior.
Why virtual memory exists
Without virtual memory, every process would address physical RAM
directly — one stray pointer write could corrupt the kernel or another
program, and two programs both compiled to assume they own address
0x400000 couldn't coexist. Virtual memory solves both
problems by giving every process its own virtual address
space: an address a process uses is not a physical RAM
address at all, but an index into a per-process translation the MMU
(memory management unit) resolves on every access. This buys three
things at once: isolation (a process literally cannot
form a pointer into another process's memory), the
illusion of contiguity (a process's memory looks flat
and contiguous even though the physical frames backing it are
scattered across RAM), and overcommit (a page of
virtual address space can exist before any physical RAM is actually
set aside for it — see page faults below).
Page tables and the TLB
Memory is managed in fixed-size chunks called pages
(4 KiB is the common default on both x86 and ARM). A virtual address
splits into a page number (which page) and an
offset (where within that page). The
page table is the per-process data structure mapping
each virtual page number to the physical frame currently backing it —
on real hardware this is a multi-level radix tree (4 levels on both
x86-64 and ARM64) rather than one flat array, so a full address
doesn't require one giant table per process. Each process has its own
page table, and the CPU register pointing at the currently-active
one (CR3 on x86, TTBR0/1 on ARM) is part of
what makes a process a process, from the MMU's point of view.
Walking four levels of page table on every single memory access would be ruinously slow, so the CPU caches recent virtual→physical translations in the TLB (translation lookaside buffer). Most memory accesses hit the TLB and skip the table walk entirely; a TLB miss falls back to walking the page table in hardware (x86, ARM) to refill it.
Page faults
A page fault is the MMU telling the kernel "this
virtual address has no valid translation right now — you handle it."
That's not always an error. A minor fault means the
page exists but isn't mapped into this process's page table yet — the
common case for a freshly mmap()ed region, handled purely
in memory with no disk I/O. A major fault means the
data actually has to come from disk or swap, which is orders of
magnitude slower. This is what makes demand paging
possible: mmap() can reserve gigabytes of address space
instantly because no physical frame is allocated until the process
actually touches a given page.
void *p = mmap(NULL, 4096 * 1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// No physical page exists yet for any of this — the call only
// reserves virtual address space. Touching page 0 for the first
// time triggers a minor page fault; the kernel handles it by
// backing that one page with a real frame, then execution resumes
// as if the fault never happened.
p[0] = 1;
// Per-process fault counts, straight from the kernel:
// $ cat /proc/$$/stat | awk '{print "minflt="$10, "majflt="$12}'
What a context switch actually does
A context switch is the kernel suspending one thread of execution and resuming another on the same CPU core. To make the interruption invisible, it has to save exactly the state that execution depends on — general-purpose registers, the program counter, and the stack pointer — into that thread's kernel-side control block, then load the same set back in for whatever runs next. Switching between two threads of the same process stops there. Switching between two processes additionally means pointing the CPU's page-table base register at the new process's page table — which is also why a process switch is inherently more expensive than a thread switch (see cost below).
What triggers a switch
- Timer interrupt — the scheduler tick, firing every
1/CONFIG_HZseconds, gives the kernel a chance to preempt the running task if a higher-priority one is waiting. - Blocking syscall — a
read()waiting on disk or network I/O voluntarily gives up the CPU rather than spin — this is the most common trigger in I/O-heavy code, and happens well before the next timer tick. - Higher-priority task becomes runnable — an interrupt handler wakes a thread waiting on it; if that thread outranks whatever's currently running, the scheduler preempts immediately.
- Explicit yield —
sched_yield()voluntarily hands the CPU to another runnable task of the same priority.
Why context switches cost what they cost
The direct cost — saving and restoring registers — is genuinely cheap, on the order of tens to low hundreds of nanoseconds. The expensive part is indirect: switching page tables invalidates the TLB entries tied to the old process's address space (an ARM/x86 core generally can't tell which cached translations still apply to the new process, so it has to assume none do), and every subsequent memory access pays a TLB-miss walk until the cache repopulates. The CPU's data and instruction caches suffer the same fate for the same reason — the new process's working set has to evict the old one's cache lines one access at a time. This is exactly why switching between threads of the same process is cheaper than switching between processes: threads share one page table, so there's no TLB flush and far less cache churn to pay for.
# system-wide context switch rate — the "cs" column
vmstat 1
# per-process breakdown: voluntary (blocked on I/O) vs.
# involuntary (preempted by the scheduler)
pidstat -w -p <pid> 1
# cswch/s = voluntary
# nvcswch/s = involuntary
# count context switches for one command's whole run
perf stat -e context-switches ./my_program
Real-world applications
- Real-time scheduling — an RTOS tuning its tick rate is directly trading responsiveness against context-switch overhead; too fast a tick spends more time switching than doing useful work.
- Thread pools over process pools — a server choosing worker threads instead of worker processes for tightly-coupled request handling is choosing to keep switches cheap by avoiding page-table changes — the same processes-vs-threads tradeoff covered in IPC.
- Real-time loop warm-up — latency-sensitive embedded Linux code often pre-faults and locks its working set (
mlock()) at startup specifically to move page faults out of the timing-critical path. - Performance debugging — an unexplained latency spike that doesn't show up in CPU time often shows up in
vmstat's context-switch column orperf's TLB-miss counters instead.
Gotchas
- Spawning far more threads than CPU cores doesn't add parallelism past a point — it adds context-switch thrashing, and total throughput can actually drop.
- A freshly
mmap()ed buffer looks instantly allocated but isn't backed by real memory yet — the first touch of each page pays a page-fault tax, which shows up as a mysterious slow "first iteration" in latency-sensitive code. - The TLB-flush and cache-pollution cost of a context switch doesn't show up in wall-clock time you can eyeball — it only becomes visible with
perfcounters or a risingvmstat cscount, not by staring at CPU usage. - Not every context switch happens on the scheduler tick — a blocking syscall can trigger one at any point between ticks, so "my tick is 4ms" is not an upper bound on how often switches occur.