What the OS saves and restores on a context switch, thread versus process switches, direct and indirect costs (cache and TLB pollution), voluntary versus involuntary switches, how blocking code and oversized thread counts multiply them, and how to measure and reduce them.
A context switch is the OS stopping one thread and starting another on a core: it saves the registers, instruction pointer and stack pointer of the old thread and restores those of the new one. Switching between processes also swaps the address space, which is more expensive. The direct cost is a few microseconds, but the indirect cost is often bigger: the new thread finds caches and TLBs cold. Switches are voluntary when a thread blocks, and involuntary when its time slice ends. Too many threads in a .NET service, usually caused by blocking code that makes the thread pool inject more threads, means more switching, more cache misses, more memory for stacks and more lock contention, so throughput falls and latency rises. The fix is async I/O, bounded parallelism around the core count, and less lock contention.
Sleep), its time slice expires, or a higher-priority thread wakes.| Cost | Typical size | Notes |
|---|---|---|
| Direct (kernel work) | ~1โ5 ยตs | Higher with security mitigations and when vector registers must be saved |
| Cache pollution | Can be tens of microseconds of slower execution | The new thread's working set isn't in L1/L2 |
| TLB refill | Extra page-table walks | Mainly on process switches |
| Scheduler and lock overhead | Grows with run-queue length | Many runnable threads per core |
At 50,000 switches per second per core, even 5 ยตs each is 25% of a core.
lock contention, Thread.Sleep, .Wait()/.Result.await on I/O returns the thread to the pool instead of blocking it. The OS completes the I/O and a pool thread runs the continuation. The number of threads stays close to the core count even with thousands of concurrent requests, so switches and memory stay low.
A legacy ASP.NET Core API called a SOAP service synchronously inside controllers. Under load the thread count climbed to 600, vmstat showed over 100,000 context switches per second on an 8-core VM, and p99 latency reached seconds. Converting the client calls to async (and removing a .Result in middleware) brought threads down to about 40 and context switches to around 15,000 per second, and p99 dropped to 150 ms.
Checks understanding of the cost model behind async and thread pool guidance.
Green flags: explains what's saved and restored, direct versus indirect costs, voluntary versus involuntary, links blocking to thread injection, knows how to measure, proposes async and bounded parallelism.
Red flags: "more threads equals more throughput"; thinks async is about making single requests faster; can't name a way to measure switching.
core 0 timeline
thread A running | [kernel: save A regs, pick B, (switch page tables?), load B regs] | thread B running
^-- direct cost: ~1-5 us --^
thread B then runs slowly for a while: its data and code are not in L1/L2 (indirect cost)
| Thread switch (same process) | Process switch | |
|---|---|---|
| Registers | Saved and restored | Saved and restored |
| Address space | Unchanged | Page tables switched |
| TLB | Mostly still valid | Largely invalidated (tagged TLBs help) |
| Cost | Lower | Higher |
Linux
vmstat 1 # 'cs' column: context switches per second (system-wide)
pidstat -w -p <pid> 1 # cswch/s (voluntary) and nvcswch/s (involuntary) per process
pidstat -wt -p <pid> 1 # per thread
perf stat -e context-switches,cpu-migrations -p <pid> -- sleep 10
Windows
System\Context Switches/sec, Thread(*)\Context Switches/sec..NET
dotnet-counters: threadpool-thread-count, threadpool-queue-length, monitor-lock-contention-count.| Pattern | Likely cause | Fix |
|---|---|---|
| High voluntary switches, many threads | Blocking I/O or sync-over-async | Async all the way |
| High voluntary switches, few threads | Lock contention | Shorter critical sections, SemaphoreSlim, lock-free structures, partitioning |
| High involuntary switches | More runnable threads than cores | Bound CPU parallelism; reduce work |
| High CPU migrations | Threads bouncing between cores | Usually fine; investigate only if caches suffer |
await Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, async (item, ct) =>
{
await ProcessAsync(item, ct);
});
For I/O-bound fan-out, bound by downstream capacity (for example, 20 concurrent calls to a partner API) with SemaphoreSlim or a Channel<T> with a fixed number of consumers.
Short waits can be cheaper to spin than to block (no switch). SpinWait, SemaphoreSlim and lock already spin briefly before blocking. Long spins waste CPU, so don't write your own busy-wait loops.
Task.Run(() => Thread.Sleep(5000)) and watch threadpool-thread-count climb in dotnet-counters and cs in vmstat.await Task.Delay(5000) and compare.pidstat -w.namespace ContextSwitching;
using System.Diagnostics;
public static class Program
{
public static async Task Main()
{
// BAD: 500 blocked pool threads (simulating sync-over-async I/O)
await Measure("Blocking", () =>
Task.WhenAll(Enumerable.Range(0, 500).Select(_ => Task.Run(() => Thread.Sleep(2_000)))));
// GOOD: 500 concurrent waits without holding threads
await Measure("Async", () =>
Task.WhenAll(Enumerable.Range(0, 500).Select(_ => Task.Delay(2_000))));
// CPU-bound work: bound parallelism to the core count
var items = Enumerable.Range(0, 64).ToArray();
await Measure("Bounded CPU work", () =>
Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
(_, _) => { SpinFor(TimeSpan.FromMilliseconds(50)); return ValueTask.CompletedTask; }));
}
private static async Task Measure(string label, Func<Task> work)
{
var sw = Stopwatch.StartNew();
var sampler = Task.Run(async () =>
{
int peak = 0;
while (sw.Elapsed < TimeSpan.FromSeconds(30))
{
peak = Math.Max(peak, ThreadPool.ThreadCount);
await Task.Delay(100);
if (Volatile.Read(ref _done)) break;
}
return peak;
});
Volatile.Write(ref _done, false);
await work();
Volatile.Write(ref _done, true);
int peakThreads = await sampler;
Console.WriteLine($"{label}: {sw.ElapsedMilliseconds} ms, peak pool threads {peakThreads}");
}
private static bool _done;
private static void SpinFor(TimeSpan duration)
{
var sw = Stopwatch.StartNew();
while (sw.Elapsed < duration) { }
}
}
The blocking version takes much longer than 2 seconds because the pool injects threads gradually, and the peak thread count is far higher.
Mistake: thinking a context switch is free because it's "just a few microseconds". At tens of thousands per second, plus cold caches afterwards, switches consume a significant share of CPU. Instead: measure switches per second and correlate them with thread counts and latency.
Mistake: fixing thread pool starvation by raising SetMinThreads permanently.
More threads hide the blocking, increase switching and memory, and fail again at higher load.
Instead: remove blocking calls; use SetMinThreads only as a temporary mitigation with a plan to fix the cause.
Mistake: running unbounded Task.Run or Parallel.ForEach for CPU work.
More runnable threads than cores leads to involuntary switching and worse throughput.
Instead: bound CPU parallelism to about the core count with MaxDegreeOfParallelism.
Mistake: holding locks while doing I/O.
Other threads block on the lock, causing voluntary switches and convoys.
Instead: keep critical sections short and do I/O outside locks, or use async-friendly coordination (SemaphoreSlim.WaitAsync).
Mistake: writing custom busy-wait loops to avoid switching. Spinning burns CPU, starves other threads and makes throttling worse in containers. Instead: use the built-in synchronisation primitives, which spin briefly and then block.
Context switching is the hidden tax of thread-per-request designs and blocking code. Seniors recognise the signature (thread count rising, high switch rates, high system CPU, rising tail latency) and fix the cause: async I/O end to end, bounded parallelism, and reduced lock contention. They also know when threads are fine: a handful of dedicated background threads is not a problem. The goal is a thread count close to the core count for CPU work and near-zero blocked threads for I/O.
The scheduler keeps a queue of runnable threads per core and gives each a time slice. Scheduling is preemptive: when the slice ends, or a higher-priority thread becomes ready, the running thread is interrupted and another runs. Threads that wait for I/O, locks or timers are blocked and don't use CPU. Windows uses priority-based round-robin with temporary boosts; Linux uses a fair scheduler (CFS, and EEVDF from kernel 6.6). For a .NET service the practical points are: don't play with thread priorities; blocking threads wastes them; and in containers, a CPU limit is enforced as a quota per 100 ms period, so a burst of work can use the quota early and get throttled, causing latency spikes. Environment.ProcessorCount follows the limit and sizes the thread pool and GC.
CPUs load memory in cache lines of 64 bytes, so after one access the neighbouring bytes are already in cache (spatial locality), and recently used data is likely still there (temporal locality). Hardware prefetchers also spot sequential access and fetch ahead. A C# int[,] is stored row-major: one row's elements are next to each other. Walking row by row uses every byte of each line and the prefetcher keeps up; walking column by column jumps a whole row each step, wasting most of each line and missing cache, often 5โ10ร slower for large arrays. The same logic favours arrays of structs over lists of objects scattered on the heap, and struct-of-arrays layouts for hot numeric fields.
A process is a running program with its own virtual address space, handles (open files, sockets) and security context; processes are isolated from each other. A thread is a path of execution inside a process with its own stack, registers and thread-local storage, while sharing the process's memory with other threads. Sharing makes communication cheap but requires synchronisation. In .NET, a System.Diagnostics.Process represents an OS process, a Thread is one-to-one with an OS thread, the thread pool keeps a reusable set of threads, and a Task is a unit of work scheduled onto them. An await on I/O usually uses no thread at all while it waits.
How to tell it (STAR)
**Situation:** At [company], our account API's p99 latency reached [seconds] at peak, on an [8-core] VM that wasn't even fully busy. **Task:** I had to find the cause and stabilise latency. **Action:** dotnet-counters showed [600] pool threads and a growing queue, and vmstat showed over [100,000] context switches a second. A dump revealed hundreds of threads blocked in a synchronous SOAP client and a `.Result` in middleware. I converted the path to async end to end and bounded calls to the legacy service. **Result:** Threads fell to about [40], context switches dropped by [85%], and p99 went to [150 ms]. We added analyser rules and dashboards to catch blocking code early.
Last updated 23 Oct 2026.