Schedulingcontext switchregistersaddress spacetlb flushasid

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.

ConceptualLinux
▶ InteractiveInterview question
Progress

The problem

Process A is on the core with its registers, its stack and its page tables loaded. The scheduler has decided B should run. What exactly has to happen for B to continue where it left off, what does it cost, and why does that cost put a ceiling on how many threads you can usefully have?

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.

Process A → process B on one core
  1. A is running in user moderegisters, RIP, RSP, page tables of A loaded
  2. Timer interrupt / blocking syscall / wakeupCPU enters kernel mode on A’s kernel stack
  3. Scheduler picks Bpick-next on the run queue: O(log n)
  4. Save A’s contextcallee-saved registers, RSP, FPU/vector state onto A’s kernel stack / task struct
  5. Switch address spaceload B’s page-table base (CR3 / TTBR); TLB entries tagged or flushed
  6. Load B’s contextB’s kernel stack pointer, then B’s registers
  7. Return to user modeB continues at the instruction after its last trap, on cold caches

Switching the address space

Conceptual

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

Linux

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.

Linux: vmstat on a host with 600 runnable threads — cs is context switches per second
$ 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’s cs and sy columns 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

Context switch, register by register
What the kernel must do so that one CPU can pretend to be two.
SimulatedLinux
PCB(A)running
RAX —RBX —RCX —RDX —RSP —RIP —RFLAGS —PT base 0x1000fds 0,1,2,3
CPUuser mode
RAX 0x0000002aRBX 0x00401f10RCX 0x00000004RDX 0x7ffd1a20RSP 0x7ffd19e0RIP 0x00401a3cRFLAGS 0x00000246CR3 0x1000TLB warm
PCB(B)ready
RAX 0x00000000RBX 0x0055c3d0RCX 0x00000010RDX 0x7ffe4c90RSP 0x7ffe4c40RIP 0x0055b7e8RFLAGS 0x00000202PT base 0x2000fds 0,1,2
Process A is running. User mode. The CPU registers hold A's live state; CR3 points at A's page tables.
Direct cost (save/restore, scheduler)
~3 µs per switch
Indirect cost (cold cache / TLB misses)
~12 µs per switch
CPU lost to switching15%

Illustrative: rate × (direct + indirect) ÷ 1 s. Real numbers depend on CPU, kernel and working-set size.

1/7 · Process A is running

How it fails

What the failure looks like from inside real software.

  • A thread-per-request server under load: cs in the hundreds of thousands per second, sy at 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 futex and schedule, 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.