How the Windows Scheduler Actually Works
Thread priorities, quantum expiry, and the distinction between processor affinity and NUMA locality. What happens when your thread gets preempted and why it matters.
Priority Is Not What You Think It Is
Windows uses a 32-level priority scheme. Levels 1 to 15 are dynamic priorities for normal user-mode threads. Levels 16 to 31 are real-time priorities. The common misconception is that priority controls how much CPU time a thread gets. It doesn't. It controls which thread runs when there's contention for a processor.
A thread at priority 8 doesn't run half as much as a thread at priority 9. If both are runnable at the same time, the priority-9 thread runs first and the priority-8 thread doesn't run at all until the priority-9 thread blocks or its quantum expires. If only the priority-8 thread is runnable, it runs with the full processor to itself regardless of its priority. Priority is a tie-breaker, not a time allocation mechanism.
Quantums and What Happens When They Expire
A quantum is the maximum amount of CPU time a thread can use before the scheduler considers preempting it. On Windows client, the default quantum is roughly 30ms. On Windows Server the default is about 180ms. The server setting favours throughput over responsiveness: a server thread doing batch work shouldn't be preempted constantly.
When a quantum expires, the scheduler looks for a ready thread at equal or higher priority. If one exists, a context switch happens. If not, the current thread keeps running with a fresh quantum. This is why a CPU-bound thread at high priority can starve lower-priority threads: it keeps consuming its quantum, getting replenished, and never yielding to lower-priority work. The OS applies starvation prevention through priority boosts, but it's not a reliable safety net for poorly designed thread priority schemes.
Affinity, Ideal Processors, and NUMA
Processor affinity restricts which processors a thread is allowed to run on. Setting a thread's affinity to a single processor means it will only ever run on that core, regardless of how busy it is compared to other cores. For a thread that benefits strongly from hot L1/L2 cache, pinning it to a core keeps that data warm. For a thread that's frequently blocked, pinning wastes scheduler flexibility.
NUMA matters on multi-socket machines. Memory access to the local NUMA node is faster than access to memory on a remote node. The Windows scheduler is NUMA-aware and prefers to schedule threads on the processor that's local to the memory they're working with. Getting this wrong in a memory-intensive application on a large server produces performance that looks fine in testing and degrades mysteriously at scale when the machine has multiple sockets.
Priority Boosts
Windows automatically boosts thread priorities in certain situations. When a thread completes an I/O wait, it gets a temporary boost of +1 to +8 depending on the I/O type. Foreground threads get a boost relative to background threads. Threads starving at low priority get a boost to prevent starvation. These boosts decay over time as the thread uses CPU.
The foreground boost is why interactive applications feel responsive even when background work is running. The foreground process's threads get a quantum multiplier, typically 3x on client Windows, meaning they get longer time slices when they do get to run. This is a scheduler policy specifically for interactive responsiveness, not throughput, which is why server editions disable it by default.
Spin vs Wait: When Each Makes Sense
There are two ways to wait for a condition: spin (loop checking it, burning CPU) or block (tell the scheduler you're waiting, give up the processor, wake when signalled). Most developers know they should block. The nuance is knowing when spinning is actually faster.
Blocking has real overhead: you transition to the kernel, the scheduler removes you from the ready queue, a context switch runs another thread, and when your condition is met you go through the dispatcher wake path and get scheduled again. The minimum round-trip for blocking and waking is typically 10 to 30 microseconds depending on timer resolution and scheduler pressure. If the condition will be satisfied in less time than that, spinning is faster, which is exactly why spinlocks exist at DISPATCH_LEVEL in the kernel.
What This Means When You're Actually Debugging
Thread starvation is the most common scheduler-related problem. A high-priority thread that loops continuously without blocking will monopolise its processor and starve lower-priority threads indefinitely. The fix is almost always inserting a yield or redesigning around event-driven wakeups rather than polling.
Priority inversion is subtler. A high-priority thread is blocked waiting for a mutex held by a low-priority thread. A medium-priority thread is running continuously, preventing the low-priority thread from running to release the mutex. The high-priority thread can't run either. Priority inheritance, where the mutex grants the holder the highest waiter's priority temporarily, solves this. Windows CRITICAL_SECTION implements priority inheritance, which is one reason to prefer it over raw Events for mutual exclusion.
The hardest version of this bug I've debugged came down to acquiring a spinlock at PASSIVE_LEVEL in one code path and at DISPATCH_LEVEL in another. If the PASSIVE_LEVEL acquisition got preempted by a DPC running the DISPATCH_LEVEL path on the same processor, the DPC would spin waiting for a lock held by a thread that could no longer run, because DPCs block everything beneath them. Classic deadlock. The fix was ensuring every acquisition raised to DISPATCH_LEVEL first, so DPC preemption was masked for the entire critical section.