Operating Systems · Processes and Threads

The Anatomy of a Context Switch

Which registers are saved, where they go, why the switch costs more than the save, and how address-space tags avoid flushing the translation cache.

A context switch is often described as saving one thread’s registers and loading another’s. That description is accurate about a small fraction of the work and misleading about the rest. The register save is a handful of stores to memory that the processor already has in cache. The expensive parts are the things that happen because the address space changed, and the things that happen because the kernel has to decide, on every switch, whether it is allowed to skip them.

What the Processor State Contains

The state that must be preserved is the set of registers a running thread can observe. On a 64-bit x86 processor that includes the general-purpose registers, the instruction pointer, the flags word, the stack pointer, and the vector register file. The vector file is the large part: in 64-bit mode there are 16 architectural vector registers, extended to 32 by the AVX-512 encodings, and each holds between 128 and 512 bits depending on the extension in use. Saving it eagerly on every switch would dominate the cost, so the kernel leaves it in the processor and saves it lazily: a task that has used vector state is marked, and the state is written out at a later point that the architecture defines — the next use of the unit by another task, or a context switch scheduled from a place where the state can be examined.

The general-purpose set is small enough to save unconditionally. The kernel’s switch routine stores the registers to the task’s kernel stack and loads the incoming task’s values, which is close to what a function call does at a language level, except that a function call saves only the callee-saved subset and relies on the calling convention for the rest. This is why the lowest layer of the switch is written as an ordinary function call that happens to return in a different context: the calling convention does the work.

Two registers are special. The stack pointer must be swapped so that the current kernel stack belongs to the incoming task, because the kernel stack holds the return addresses and local state of whatever system call or fault the task was executing. The instruction pointer is not saved explicitly; it is on the kernel stack as the return address of the switch routine.

The Kernel Stack as the Saved Context

A blocked thread’s context is therefore not a separate structure. It is the contents of its kernel stack, at the point where it called into the scheduler. The saved register set sits at a known offset from the stack pointer, and the stack pointer itself is stored in the task descriptor. Resuming the thread means loading that stack pointer and executing a return, which pops the saved registers and continues in the function that blocked.

This design has a consequence for how blocking is written. A thread cannot be descheduled at an arbitrary instruction; it is descheduled where it calls the scheduler, which is inside a function with a well-defined frame. Cooperative kernel code depends on this: a kernel path that must not be interrupted by a switch does not call the scheduler and does not hit a preemption point. The boundary between preemptible and non-preemptible kernel code is the set of places where such a call can occur.

The user-level registers of a blocked thread are already on its kernel stack, saved by the trap that entered the kernel. A thread blocked in a system call has its user register state at the base of its kernel stack, in a frame the return path will restore. A thread descheduled by preemption, having been interrupted mid-execution in user space, has the same shape. In both cases, the switch saves the kernel-mode continuation and the user-mode state comes back later as part of the return path.

The Translation Cache Problem

Changing the address space is where the cost lives. The processor caches virtual-to-physical translations in a structure that is indexed by virtual address and, on the older architectures that lack address-space tags, has no way to know which address space an entry belongs to. On such an architecture the only correct action when the page-table base register changes is to invalidate the entries the new space could otherwise match, and on older implementations the only instruction available invalidated all of them.

That invalidation is expensive in a way that is not proportional to anything the program did. After a full flush, every access that was previously cached must be translated again by walking the page table, and each walk may itself miss in the data caches. A program that had been running with warm translations pays a burst of misses on its next several thousand memory accesses. The effect is large enough that operating systems go to considerable length to avoid unnecessary address-space switches.

Two mechanisms reduce the damage. The first is tagging: the translation entries carry an address-space identifier, and a switch to a different space changes the identifier rather than flushing. Entries for the old space remain valid and are found again when it is resumed. The identifier space is finite, so the kernel must recycle identifiers, and recycling requires flushing the entries that carry the recycled value. The second is a global-page designation: mappings that are identical in every address space are marked so that they survive the invalidation that a page-table base change performs on x86. On a system where the kernel is mapped into every process, this keeps a substantial part of the working set resident across switches.

There is also a special case worth naming: a switch from one task to another that shares the same address space requires no change to the page-table base register at all. This is the thread case, and it is why threads in one process switch more cheaply than processes. The kernel detects the sharing and skips the switch of the base register, which skips the flush. On a machine with tagged translations the saving is smaller, because the flush was already avoided; the two optimizations overlap, and a workload benefits from the one its hardware does not provide. Which resources are shared to make this case possible is the subject of What Threads Share and What They Own.

Deciding Whether to Switch at All

The scheduler’s decision is not “which task runs next” but “should the current task keep running”. On a voluntary switch the answer is forced: the current task called the scheduler because it cannot proceed, having set its state as described in Process States and the Transitions the Kernel Actually Tracks. On an involuntary switch, the answer comes from a comparison of the current task’s remaining timeslice against the cost of switching. A switch that occurs too often spends a large fraction of the processor on save, restore, and cache refill rather than on the workload.

The cost of a switch is therefore partly a property of the workload. A task with a large hot working set pays more per switch than one with a small one, because more of its translations and data must be re-fetched. A pair of tasks that communicate frequently through shared memory pay more than a pair that does not, because each switch separates the producer from the consumer. The scheduler cannot measure this directly and uses proxies: a timeslice long enough to amortize the switch, and a preference for keeping a task on the same processor so that its caches stay warm.

Migration is a third case, distinct from a switch. Moving a task to another processor requires no register save on the old processor beyond what the switch already does, but it gives the task nothing of the old processor’s caches: the new one starts cold, with only what it happens to hold from an earlier visit. The scheduler’s load balancer weighs the benefit of an idle processor against this cold start, and the usual compromise is to migrate only when the imbalance is large enough to pay for the refill.

Voluntary Yielding and Its Abuse

A thread can ask to be descheduled even when it could run. The yield call marks the task as wanting to give up the processor and invokes the scheduler; whether it moves to the tail of its run queue depends on the scheduling class, since a fair-scheduling task is placed by its virtual runtime rather than by a simple rotation. On a machine with more runnable tasks than processors, this is a way to be polite about a spin-wait or to give a lower-priority task a chance. On a machine with fewer, it is pure cost: the task gives up its remaining timeslice, the scheduler may well pick it again, and the only effect is a switch and the cache damage that comes with it.

The pattern that causes trouble is a loop that yields while polling a condition that another thread will set. Each iteration pays for a switch in each direction, and the polling thread consumes a full timeslice’s worth of switches while making no progress. The standard remedy is a blocking wait on the condition rather than a yield, which removes the switches entirely: the thread sleeps until the event occurs, and the waker performs one wakeup instead of the poller performing thousands of wake-and-yield cycles.

The same reasoning applies to a thread that sleeps for a short interval in a loop. A short sleep cannot be honoured precisely: the timer resolution and the scheduler’s own wakeup latency set a floor, and a sleep asked to last less than that floor expires later than requested. A loop that sleeps a fixed interval between iterations therefore drifts, because the interval it measures from is the end of the work rather than a fixed grid, and each iteration adds its own overshoot. Timing loops written this way are unreliable under load, and the fix is to compute the next deadline from the start time rather than to sleep for a constant.

What the Switch Does Not Do

A switch does not flush the instruction cache, and it does not flush the data cache. Those caches are indexed by physical address or tagged by virtual address, and the entries for the outgoing task remain until they are evicted by capacity. This is why a task that is descheduled and immediately rescheduled retains most of its warmth, and why a second pass over the same data is faster than the first even when the two passes are separated by a switch.

A switch does not save the floating-point state unless the outgoing task used it, and it does not save the debug registers unless they were programmed. The lazy approach means the switch cost depends on what the task was doing, which makes any single number quoted for “the cost of a context switch” a statement about a particular workload rather than a property of the kernel.

Finally, a switch does not by itself provide any memory-ordering guarantee between the two tasks. The kernel’s own locking provides the barriers that make a handoff correct, and a pair of threads that communicate through shared memory without synchronization will observe each other’s writes in an order the hardware permits, not the order the programmer intended. The switch is a scheduling event, not a synchronization event, and the mechanisms that do provide ordering belong to a different subject.

References

  1. Computer Organization and Design: The Hardware/Software Interface — Chapter 2, Instructions — Patterson and Hennessy; Morgan Kaufmann
  2. Linux Kernel Development, 3rd edition — Chapter 4, Process Scheduling — Robert Love; Addison-Wesley
  3. Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 3A — Paging and caching of translation information — Intel Corporation
  4. ARM Architecture Reference Manual — translation table descriptors and address space identifiers — Arm Limited