Preemptive scheduling, run queues, time slices, priorities and starvation, how Windows and Linux schedulers differ at a high level, CPU affinity and NUMA, and how cgroup CPU quotas throttle containers and interact with the .NET thread pool and GC.
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.
created
|
v
+-----> RUNNABLE ----(scheduler picks it)----> RUNNING
| ^ |
| | | waits for I/O, lock, sleep
| +----(I/O done, lock acquired)-------+----> BLOCKED
| |
+--------(time slice ends / preempted)-----------+
Only runnable threads compete for CPU. A server with 500 threads mostly blocked on I/O may still have low CPU; 500 runnable threads on 8 cores means lots of waiting and switching.
| Windows | Linux | |
|---|---|---|
| Model | 32 priority levels; round-robin within a level | Fair share of CPU based on weights (nice values); CFS, EEVDF since kernel 6.6 |
| Dynamic behaviour | Temporary priority boosts (for example after I/O completes, for foreground windows) | Tracks virtual run time so threads that ran less get the CPU next |
| Real-time classes | Priorities 16–31 | SCHED_FIFO, SCHED_RR |
Both spread runnable threads across cores and try to keep a thread on the same core (cache warmth) and NUMA node.
Thread.Priority and Process.PriorityClass exist, but raising priority rarely improves throughput and can starve other threads, including the GC's background threads and the thread pool.Environment.ProcessorCount becomes 2 (rounded up for fractional limits), which sizes the thread pool minimum and the number of Server GC heaps.container_cpu_cfs_throttled_periods_total / nr_throttled in cpu.stat, alongside latency.An API in AKS had a CPU limit of 1 and a request of 0.5. Average CPU was only 40%, but p99 latency was 800 ms. cpu.stat showed throttling in about 30% of periods: request bursts and gen 0 GCs used the 100 ms quota in the first few milliseconds. Raising the limit to 2 (keeping the request at 0.5 for bin-packing) and fixing a hot path that blocked on .Result removed the throttling, and p99 fell to 120 ms.
Checks practical OS scheduling knowledge for services running on VMs and in containers.
Green flags: explains thread states, preemption and time slices, knows priorities are rarely the answer, understands CFS quota throttling and its latency effect, connects limits to ProcessorCount, the thread pool and GC, proposes metrics.
Red flags: "increase thread priority"; thinks average CPU tells the whole story; unaware that container limits are enforced per period.
limit = 1 CPU -> quota 100 ms CPU per 100 ms period
period 1: [####################.....................] used 100 ms in the first 25 ms
8 threads busy x 12.5 ms = 100 ms -> THROTTLED for 75 ms
period 2: [###.........................................] normal
period 3: [####################.....................] burst again -> throttled
Average CPU looks like ~40%, but requests arriving during a throttled window wait up to 75 ms.
More threads make throttling worse: they burn the shared quota in parallel.
# Inside the container (cgroup v2)
cat /sys/fs/cgroup/cpu.stat
# nr_periods 12000
# nr_throttled 3600 <- 30% of periods throttled
# throttled_usec 210000000
# Prometheus / Azure Monitor metric
# container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total
| Runtime behaviour | Effect of a 1-CPU limit |
|---|---|
Environment.ProcessorCount |
1 |
| Thread pool minimum workers | 1 (it can still grow) |
| Server GC heaps | 1 (Server GC degenerates towards workstation-like behaviour) |
Parallel defaults (Parallel.For) |
Degree of parallelism 1 |
Override with DOTNET_PROCESSOR_COUNT when you have a reason (for example a fractional limit where rounding up isn't what you want).
GCHeapAffinitizeMask and related settings exist for specialised tuning.docker run --cpus=1 and print Environment.ProcessorCount; read /sys/fs/cgroup/cpu.stat before and after.--cpus=4 and compare.namespace Scheduling;
using System.Diagnostics;
public static class Program
{
public static async Task Main()
{
Console.WriteLine($"ProcessorCount (respects container CPU limit): {Environment.ProcessorCount}");
ThreadPool.GetMinThreads(out int minWorkers, out int minIo);
Console.WriteLine($"Thread pool min workers: {minWorkers}, min I/O: {minIo}");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// Background CPU burners
var burners = Enumerable.Range(0, 8)
.Select(_ => Task.Run(() => { while (!cts.IsCancellationRequested) { } }))
.ToArray();
// Measure how late a 10 ms tick runs: large values indicate throttling or run-queue waits
var worst = TimeSpan.Zero;
var sw = Stopwatch.StartNew();
while (!cts.IsCancellationRequested)
{
var before = sw.Elapsed;
await Task.Delay(10);
var lateness = sw.Elapsed - before - TimeSpan.FromMilliseconds(10);
if (lateness > worst) worst = lateness;
}
await Task.WhenAll(burners);
Console.WriteLine($"Worst scheduling delay: {worst.TotalMilliseconds:F1} ms");
if (File.Exists("/sys/fs/cgroup/cpu.stat"))
{
Console.WriteLine(await File.ReadAllTextAsync("/sys/fs/cgroup/cpu.stat"));
}
}
}
docker run --rm --cpus=1 my-scheduling-demo # compare with --cpus=4
Mistake: raising thread or process priority to make a service faster. It doesn't create CPU; it starves other work (including GC background threads) and can cause priority inversion. Instead: remove unnecessary work and blocking, and isolate competing workloads with separate resource limits.
Mistake: judging CPU health by average utilisation. A container can average 40% CPU and still be throttled for a large share of periods, which shows up only as tail latency. Instead: monitor throttled periods and p95/p99 latency alongside CPU usage.
Mistake: setting tight CPU limits on bursty .NET services. Start-up JIT, GC and request bursts exceed the quota quickly, slowing cold starts and readiness and causing spikes. Instead: size limits from load tests with headroom for bursts, or rely on requests where policy allows.
Mistake: assuming more threads means more throughput. With a fixed CPU quota, more runnable threads burn the quota faster and increase context switching. Instead: keep CPU-bound parallelism near the core count and use async for I/O concurrency.
Mistake: changing priority or affinity of thread-pool threads. Pool threads are shared by the whole process, so changes leak into unrelated work. Instead: use a dedicated thread for special scheduling needs, if at all.
Seniors understand that in the cloud the scheduler's decisions are shaped by cgroup quotas as much as by the kernel's algorithm.
| Problem | Root cause | Senior fix |
|---|---|---|
| p99 spikes at low average CPU | CFS quota throttling from bursts (GC, JIT, request fan-out) | Raise or remove CPU limit (per policy), keep requests realistic, watch nr_throttled |
| Slow cold starts and readiness | Tight limits during JIT and warm-up | ReadyToRun, higher limit, readiness probe tuned to real start-up |
| Starvation of background work | Priority games, CPU-bound loops on pool threads | Normal priority, bounded parallelism, separate process for heavy work |
| Priority inversion / lock convoys | Long critical sections under contention | Shorter locks, lock-free or partitioned state, SemaphoreSlim limits |
| Remote memory access on big VMs | Threads and memory on different NUMA nodes | Let the GC's NUMA awareness work; avoid manual affinity unless measured |
The most common scheduling problem in .NET services is not priority; it is throttling amplified by many runnable threads and GC bursts, visible only in tail latency. Seniors keep CPU-bound parallelism near the effective core count, use async for I/O concurrency, and know how ProcessorCount flows into thread-pool and GC sizing.
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.
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 payments API had a p99 of about [800 ms] even though average CPU was only [40%]. **Task:** I had to explain and fix the latency without simply adding pods. **Action:** I checked the cgroup `cpu.stat` and saw about [30%] of periods throttled: with a 1-CPU limit, request bursts and GC used the whole 100 ms quota in a few milliseconds. I raised the limit, kept the request low for bin-packing, and fixed a blocking `.Result` call that was inflating the thread count. **Result:** Throttling dropped below [1%] and p99 fell to about [120 ms]. Throttling is now on the dashboard with an alert.
Last updated 23 Oct 2026.