Context Switching
A context switch saves one task’s registers and stack pointer, swaps the address space, and restores another’s — a few microseconds of direct work whose real cost is the cold caches and TLB the new task inherits.
The problem
What must be saved
A running task is defined by the contents of the CPU: the general-purpose registers, the instruction pointer (where it is in its code), the stack pointer (where its current frame is), the flags register, and on most workloads the floating-point and vector registers (x86 SSE/AVX state can be a kilobyte or more; kernels save it lazily where they can). If those values are written to memory, the task can be resumed later on any core by loading them back. That memory is the task’s kernel stack plus a per-task control block (task_struct on Linux, KTHREAD/ETHREAD on Windows).
The switch is always entered from kernel mode, because only the kernel can touch another task’s state. So the sequence begins with whatever brought the CPU into the kernel: a timer interrupt (slice expired), a system call that blocks (read() with no data, futex wait, epoll_wait), or a wakeup of a higher-priority task delivered by an interrupt. From there the scheduler picks B, and the switch itself is a short piece of assembly (__switch_to and the surrounding code on Linux) that swaps stack pointers and lets the return path restore B’s registers.
- A is running in user moderegisters, RIP, RSP, page tables of A loaded↓
- Timer interrupt / blocking syscall / wakeupCPU enters kernel mode on A’s kernel stack↓
- Scheduler picks Bpick-next on the run queue: O(log n)↓
- Save A’s contextcallee-saved registers, RSP, FPU/vector state onto A’s kernel stack / task struct↓
- Switch address spaceload B’s page-table base (CR3 / TTBR); TLB entries tagged or flushed↓
- Load B’s contextB’s kernel stack pointer, then B’s registers↓
- Return to user modeB continues at the instruction after its last trap, on cold caches
Switching the address space
If A and B are different processes, they have different page tables, and the CPU has to be told: on x86-64 by writing B’s top-level page-table address into CR3, on ARM64 into TTBR0. That single register write is cheap; its consequence is not. The The TLB caches virtual-to-physical translations, and every entry it holds belongs to A’s address space. Historically the CR3 write flushed the entire TLB, so B started with zero cached translations and paid a page-table walk on its first touch of every page — hundreds of walks before its working set was translated again.
Modern CPUs tag TLB entries with an address-space identifier — ASID on ARM, PCID on x86 (used by Linux since 4.14) — so A’s entries can stay in the TLB while B runs, and switching back to A finds them still valid. The kernel tracks which ASIDs are live per core and issues targeted flushes only when a page table actually changes. This is why the indirect cost of a process switch fell substantially in the last decade, and also why the Meltdown mitigation (kernel page-table isolation, 2018) hurt so much: it added an address-space switch on every syscall, and only PCID kept it survivable.
A thread switch within the same process skips this step entirely: the page tables are the same, CR3 is not written, and the TLB is untouched. That is the single biggest reason a thread switch is cheaper than a process switch — not the register save, which is the same in both cases.
What it costs
The direct cost — kernel entry, scheduler pick, register save/restore, return — is on the order of 1–5 µs on a modern server core for a process switch, and often under 1 µs for a thread switch within a process. That number is easy to measure with two processes ping-ponging on a pipe, and easy to dismiss: at 1,000 switches per second per core it is well under 1% of the core.
The indirect cost is where the damage is. When A ran, it filled the core’s L1 (32–48 kB), L2 (1–2 MB) and its share of L3 with its data, and the TLB with its translations. B’s working set is elsewhere; B’s first few hundred microseconds run at a fraction of full speed while it misses its way back to a warm cache. If the scheduler moved B to a different core, even L3 is cold. Measurements of this effect routinely show the indirect cost at 10–100× the direct cost, and it is proportional to working-set size — a big process pays more to be switched away from than a small one.
Interactive and I/O-bound workloads switch tens of thousands of times per second per core and mostly get away with it because their working sets are small. Compute kernels that share a core with anything at all lose measurably, which is why latency-sensitive services pin threads to isolated cores (isolcpus, taskset, cset) and why the scheduler prefers to leave a task on the core it last ran on.
- Direct: ~1–5 µs process switch, often < 1 µs thread switch (order-of-magnitude figures; measure on your hardware).
- Indirect: cold L1/L2/TLB, cold L3 if migrated — 10–100× the direct cost, proportional to working set.
- A kernel entry alone (syscall, no switch) is ~100 ns–1 µs; the switch adds the pick and the state swap on top.
Too many threads: thrashing the scheduler
Threads look free because creating one is cheap (~10–20 µs and a few kB of kernel state; the 8 MB stack is reserved, not touched — see Stack Overflow and What Happens When I Allocate Memory?). The cost is in running them. With 8 cores and 8 CPU-bound threads, each thread gets a core and runs to completion with warm caches. With 800 CPU-bound threads, each core has 100 runnable threads, the slice hits the minimum granularity, and each thread gets a slice every ~75 ms — during which it first refills the caches the previous 99 threads evicted. Throughput is lower than with 8 threads, and latency is 100× worse.
This is scheduler thrashing, the CPU-side cousin of memory thrashing in Memory Pressure, Swap and the OOM Killer: the system spends its time on the overhead of sharing instead of on the work. The tell is vmstat showing hundreds of thousands of context switches per second (cs), high system time (sy), and user work (us) that does not scale with the thread count. The fix is always the same — fewer runnable threads than cores for CPU-bound work (a pool of nproc threads), and for I/O-bound work an event loop or async I/O so that waiting does not need a thread at all (The Event Loop, I/O Multiplexing: select, poll, epoll, kqueue, IOCP, The Thread Pool Server).
Lock contention produces the same signature: a thread that fails to acquire a mutex blocks (a switch), and the holder’s release wakes it (another switch). A hot lock among 50 threads can generate more switches than the useful work between them; see Mutexes and Atomic Operations.
$ vmstat 1 procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 612 0 0 5.1G 0.3G 11.2G 0 0 0 8 4190 318442 61 38 1 0 0 598 0 0 5.1G 0.3G 11.2G 0 0 0 0 4211 322017 60 39 1 0 0 # r ≈ 600 runnable on 8 cores; sy ≈ 39% is switch + scheduler overhead, not application work
Key points
- A context switch saves registers, instruction pointer, stack pointer and FPU/vector state, and restores another task’s — always from kernel mode.
- A process switch also changes the page-table base register; without ASIDs/PCIDs that flushes the TLB, which is the expensive part.
- A thread switch within one process skips the address-space switch, which is why it is cheaper — the register save is the same.
- Direct cost is ~1–5 µs; the indirect cost of cold caches and TLB is 10–100× larger and proportional to working-set size.
- Too many runnable threads means every slice starts on cold caches: throughput falls while every core reads 100% busy.
- Lock contention manifests as context switches;
vmstat’scsandsycolumns are the first place to look.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why must the switch happen in kernel mode?
Because only the kernel can write another task’s saved state, load a different page table, and return to a different user-mode context. A user program cannot even see the registers of another process, let alone install them.
▸Why is a thread switch cheaper than a process switch?
Threads share page tables. Skipping the CR3/TTBR write keeps the TLB and the cache mapping valid; the register save/restore is identical. With PCIDs the gap has narrowed, but it has not closed.
▸Why do more threads make a CPU-bound job slower?
Every thread beyond the core count adds switches and evicts the caches of the thread it displaces. The work is the same; the overhead and cache refills are extra.
▸Why do event loops exist?
Because waiting on 10,000 sockets with 10,000 threads means 10,000 stacks and a switch for every wakeup, while one thread in epoll_wait handles the same wakeups with zero switches between them.
Context switch
Illustrative: rate × (direct + indirect) ÷ 1 s. Real numbers depend on CPU, kernel and working-set size.
How it fails
What the failure looks like from inside real software.
- A thread-per-request server under load:
csin the hundreds of thousands per second,syat 30–40%, throughput flat as load rises — the thread count is the bug. - A hot mutex among many threads: every acquire/release pair becomes a switch pair; profile shows time in
futexandschedule, not in the application. - After the Meltdown/KPTI mitigations a syscall-heavy service lost 10–30% throughput on CPUs without PCID; the symptom looked like a kernel regression.
- A latency-sensitive thread migrated between cores by the load balancer pays L2/L3 refills every migration; pinning it fixes tail latency that no code change could.
- Sizing a CPU-bound thread pool by "requests in flight" instead of core count: adding threads makes each request slower and none faster.