Skip to content

Context Switching

A context switch is the act of saving a currently running task’s CPU state and restoring a different task’s previously saved state in its place, so that execution resumes on the new task exactly where it last left off, with no indication from the task’s own perspective that time passed or another task ran in between. Everything about multitasking that isn’t scheduling policy is, at bottom, the mechanics of doing this switch correctly and reasonably efficiently, since it happens frequently, commonly hundreds of times per second on a busy system.

A task’s execution state consists of everything that determines what it does next: the general-purpose registers, the instruction pointer, the flags register, and the stack pointer. Rather than saving each of these individually into a separate structure, the conventional approach saves them by pushing them onto the task’s own stack, meaning that “restoring” a task later is largely just restoring its saved stack pointer and popping the same values back off, symmetric with how they were saved.

; save the currently running task's state onto its own stack
push rax
push rbx
push rcx
; ... remaining general-purpose registers ...
push rbp
mov [current_task_rsp], rsp ; remember where we left off
mov rsp, [next_task_rsp] ; switch to the next task's saved stack
pop rbp
; ... remaining general-purpose registers, in reverse order ...
pop rcx
pop rbx
pop rax
ret ; returns to wherever the next task's saved
; return address on its own stack points

The final ret is what actually resumes the next task’s execution: because each task’s stack, at the moment it was last switched out, has a return address sitting where ret expects to find one, restoring that task’s stack pointer and executing ret transfers control to exactly where that task stopped: whether that is in the middle of ordinary code, or, for a task that has never run before, a designated entry point placed there deliberately when the task was first created.

If the two tasks belong to different processes, the switch additionally reloads CR3 with the new task’s page table root, which as a side effect flushes every non-global entry from the TLB, a real performance cost, since the next several memory accesses on the new task will all suffer TLB misses that would otherwise have been cache hits. Switching between two threads of the same process can skip this step entirely, since both threads already share an identical address space and reloading CR3 with the value it already holds would accomplish nothing beyond needlessly flushing the TLB, a detail worth checking for explicitly rather than reloading CR3 unconditionally on every switch.

Registers belonging to the floating-point unit and SIMD extensions (the XMM/YMM/ZMM registers, and the legacy x87 FPU state) are not included in the general-purpose save sequence above, and saving and restoring them unconditionally on every single context switch is comparatively expensive, since these register files are considerably larger than the general-purpose set, and a task that only performs integer and pointer arithmetic never touches them at all. Many kernels instead implement lazy FPU switching: floating-point access is left disabled (via CR0’s TS bit) for a newly switched-in task, the exact save/restore instructions and the #NM exception this triggers covered in full in FXSAVE/XSAVE and FPU/SIMD State, and only on the first instruction that actually attempts to use it does a fault occur, at which point the kernel saves whichever task previously owned the FPU state, restores the current task’s, and re-enables access, meaning the more expensive save/restore only happens for tasks that actually use floating-point or SIMD instructions, rather than unconditionally for every task on every switch regardless of whether it needs to.

The exact set of registers saved, and the order they’re saved and restored in, must be perfectly symmetric: a mismatch between the push sequence and the corresponding pop sequence silently loads garbage into the wrong registers rather than producing any diagnosable error at the point of the mistake, since both sequences execute without fault regardless of whether they actually correspond to each other correctly. Interrupts must also typically be disabled for the duration of the switch itself: an interrupt arriving in the brief window after rsp has been updated to point at the next task’s stack, but before that task’s registers have finished being restored, would push its own interrupt frame onto a stack in an inconsistent, partially-restored state.

  1. ^ Intel, Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A, Chapter 13: describes the x87/SSE state the TS bit and lazy-switching technique above interact with.