📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

What is a context switch, what does it cost, and why can having too many threads make a .NET service slower?

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.

Mid-Level Theory Intermediate async context-switching operating-system performance thread-pool threads

๐ŸŽฏ Key points

  • A context switch saves one thread's CPU state and restores another's so it can run
  • Switching between processes also changes the address space, which costs more (TLB effects)
  • Direct cost is a few microseconds; the indirect cost of cold caches and TLBs is often larger
  • Voluntary switches happen when a thread blocks; involuntary ones when its time slice ends or it's preempted
  • Many runnable threads on few cores mean frequent involuntary switches and poor cache reuse
  • Blocking calls (sync-over-async, sync I/O, lock contention) multiply threads and switches
  • Measure with vmstat, pidstat -w, perf, or Windows performance counters; reduce with async I/O, bounded parallelism and less contention

โฑ 30-second answer

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.

๐Ÿ—ฃ Full answer (senior)

What happens in a switch

  1. A trigger: the thread blocks (I/O, lock, Sleep), its time slice expires, or a higher-priority thread wakes.
  2. The kernel saves the current thread's CPU state (general-purpose and vector registers, instruction pointer, stack pointer, flags).
  3. The scheduler picks the next runnable thread.
  4. If it belongs to a different process, the kernel switches page tables (and the TLB loses much of its usefulness).
  5. The kernel restores the new thread's state and returns to user mode.

The costs

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.

Voluntary and involuntary

  • Voluntary: the thread blocks. Common in synchronous I/O, lock contention, Thread.Sleep, .Wait()/.Result.
  • Involuntary: the thread was still runnable but got preempted. Common when there are many more runnable threads than cores.

Why too many threads hurt .NET services

  • Blocking calls on thread-pool threads (sync-over-async, synchronous database or HTTP calls) make the pool inject threads to keep work moving. Hundreds of threads then compete for a few cores.
  • Each thread has a stack (about 1โ€“1.5 MB reserved), adding memory pressure.
  • More threads contend for the same locks, causing more voluntary switches and convoys.
  • The GC must suspend every managed thread at a safe point for blocking collections; more threads mean longer suspension.
  • With container CPU limits, more runnable threads burn the quota faster and trigger throttling.

How async helps

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.

Production example

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.

๐Ÿ” What the interviewer is checking

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.

๐Ÿ“š Learn in depth

1. A switch, drawn

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)

2. Thread versus process switch

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

3. Measuring

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

  • Performance Monitor: System\Context Switches/sec, Thread(*)\Context Switches/sec.
  • PerfView or WPA (ETW CSwitch events) show which threads switch and why (wait reasons).

.NET

  • dotnet-counters: threadpool-thread-count, threadpool-queue-length, monitor-lock-contention-count.
  • A thread count far above the core count with a growing queue means blocking.

4. Interpreting the numbers

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

5. Bounded parallelism in .NET

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.

6. Spinning versus blocking

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.

7. Try it yourself

  1. Start 1,000 Task.Run(() => Thread.Sleep(5000)) and watch threadpool-thread-count climb in dotnet-counters and cs in vmstat.
  2. Replace with await Task.Delay(5000) and compare.
  3. Write a CPU-bound loop over 64 threads on a 4-core machine versus 4 threads, and compare total throughput and involuntary switches with pidstat -w.

๐Ÿ’ป Code

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.

โš  Common mistakes

  • 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.

๐Ÿ› Senior insight: trade-offs

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.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate mentions involuntary switches or throttling Why: Probes the scheduler and CFS quotas in containers
    How to answer

    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.

  2. When: Candidate says switches pollute caches Why: Checks cache locality knowledge from the CPU group
    How to answer

    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.

  3. When: Candidate compares thread and process switches Why: Confirms what processes and threads share
    How to answer

    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.

๐Ÿ›  Real-world problems: how you handle them

Synchronous SOAP calls drive 600 threads and 100,000 context switches per second
Situation
A customer-account API on an 8-core VM in Azure Australia East called a legacy SOAP service synchronously from controllers. At peak, about 400 requests per second.
Signals
p99 latency in seconds, thread pool thread count around 600, `vmstat` showing over 100,000 context switches per second, high system CPU, and a growing `threadpool-queue-length` in dotnet-counters.
Investigation
Took a dump and used `dotnet-dump analyze` with `clrstack -all`, which showed hundreds of threads blocked in the synchronous SOAP client and one middleware calling `.Result` on an async token validation.
Resolution
Regenerated the SOAP client with async methods, made the controllers and middleware async end to end, and limited concurrent calls to the legacy service with a `SemaphoreSlim` sized to its capacity. Threads fell to about 40, context switches to about 15,000 per second, and p99 to 150 ms.
Prevention
Enabled the analyser rules that flag sync-over-async, added thread count and queue length to the dashboard with alerts, and added a load test to the release pipeline.

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.

โ†ฉ Usually asked after

More in Operating System

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me