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

📚 Interview question

How does the operating system scheduler decide which thread runs next, and what do preemption, time slices, priorities and container CPU limits mean for a .NET service?

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.

Mid-Level Theory Intermediate containers cpu-throttling kubernetes operating-system scheduling threads

🎯 Key points

  • Modern OSes use preemptive scheduling: a running thread is interrupted when its time slice ends or a higher-priority thread becomes ready
  • Threads are runnable, running or blocked (waiting on I/O, locks, sleep); only runnable threads compete for CPU, so many blocked threads cost memory but not CPU
  • Windows uses 32 priority levels with round-robin within a level plus dynamic boosts; Linux uses a weight-based fair scheduler (CFS, with EEVDF as the default fair-scheduling policy from kernel 6.6)
  • Raising thread priority rarely helps and can cause starvation and priority inversion; kernels mitigate inversion with boosts or priority inheritance, but short critical sections are the real fix; never change thread-pool thread priority
  • SMT (Hyper-Threading) siblings share a core, so two runnable threads on sibling logical CPUs do not get two cores' worth of throughput
  • Container CPU limits are enforced as a CFS quota per period (100 ms by default): a container that burns its quota early is throttled for the rest of the period, causing tail-latency spikes even at low average CPU
  • Environment.ProcessorCount reflects the CPU limit (rounded up), which sizes the thread-pool minimum, Server GC heaps and Parallel defaults; DOTNET_PROCESSOR_COUNT overrides it
  • Watch throttled periods alongside p95/p99 latency, and size CPU requests and limits from load tests with headroom for start-up JIT and GC bursts

⏱ 30-second 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.

🗣 Full answer (senior)

Thread states

            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.

Preemption and time slices

  • A hardware timer interrupts the CPU regularly; the scheduler checks whether the current thread has used its slice or whether a higher-priority thread is waiting.
  • Slices are milliseconds long (they vary by OS, configuration and load).
  • Preemption keeps the system responsive, but every switch has a cost (see the context-switching question).

Windows versus Linux, briefly

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.

Priorities in .NET

  • 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.
  • Never change the priority of thread-pool threads; they're shared.
  • Better levers: reduce work, avoid blocking, bound concurrency, isolate heavy work in another process with its own resource limits.

Containers: CPU requests, limits and throttling

  • Kubernetes requests set a relative weight (who gets CPU when there's contention).
  • Limits set a hard quota: with a limit of 2 CPUs, the container can use 200 ms of CPU time every 100 ms period across all its threads. If it uses that in the first 30 ms (for example, a GC plus 16 request threads), it's throttled for the remaining 70 ms. Requests in flight stall, and p99 latency jumps.
  • The .NET runtime reads the limit: Environment.ProcessorCount becomes 2 (rounded up for fractional limits), which sizes the thread pool minimum and the number of Server GC heaps.
  • Metrics to watch: container_cpu_cfs_throttled_periods_total / nr_throttled in cpu.stat, alongside latency.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. CFS quota throttling, drawn

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.

2. Reading throttling data

# 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

3. How .NET reacts to CPU limits

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

4. Sizing guidance

  • Set CPU requests from measured steady-state usage so the scheduler places pods sensibly.
  • Set limits high enough to absorb bursts (start-up, JIT, GC, traffic spikes), or omit CPU limits where your platform policy allows and rely on requests for fairness.
  • Watch throttling, not just average CPU.
  • Remember that start-up (JIT compilation, ReadyToRun loading, cache warm-up) is CPU-heavy; tight limits make cold starts and readiness slow.

5. Starvation, priority inversion and why locks matter

  • Starvation: a low-priority thread never runs because higher-priority ones are always runnable.
  • Priority inversion: a high-priority thread waits for a lock held by a low-priority thread that can't run. OSes mitigate it with boosts, but the cleanest fix is short critical sections and no priority games.

6. Affinity and NUMA

  • On large VMs with several NUMA nodes, keeping threads and their memory on the same node avoids remote memory access. The GC has NUMA awareness; GCHeapAffinitizeMask and related settings exist for specialised tuning.
  • Most services shouldn't set affinity manually.

7. Try it yourself

  1. Run a CPU-bound loop on 8 threads in a container with docker run --cpus=1 and print Environment.ProcessorCount; read /sys/fs/cgroup/cpu.stat before and after.
  2. Measure the latency of a tiny request every 10 ms while the CPU-bound work runs, and observe the spikes caused by throttling.
  3. Repeat with --cpus=4 and compare.

💻 Code

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

⚠ Common mistakes

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

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions time slices or preemption Why: Checks the cost of switching when the scheduler preempts
    How to 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.

  2. When: Candidate is unclear what exactly gets scheduled Why: Confirms the process vs thread distinction
    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

p99 latency spikes caused by CPU throttling at 40% average CPU
Situation
A payments API in AKS ran with a CPU request of 0.5 and a limit of 1 per pod. Dashboards showed average CPU around 40%, yet p99 latency was about 800 ms against a 200 ms objective.
Signals
Latency spikes uncorrelated with traffic peaks, `container_cpu_cfs_throttled_periods_total` at about 30% of periods, and `Environment.ProcessorCount` reporting 1 in the pods.
Investigation
Read `cpu.stat` inside a pod to confirm throttling, captured a dotnet-trace showing bursts of gen 0 GCs and several request threads running in parallel, and found a hot path that blocked on `.Result`, which made the thread pool add threads that then competed for the quota.
Resolution
Raised the CPU limit to 2 while keeping the request at 0.5, fixed the sync-over-async path, and re-ran the load test. Throttling dropped to under 1% of periods and p99 fell to about 120 ms.
Prevention
Added throttling percentage to the service dashboard with an alert, a load test that reports p99 and throttling before release, and a platform guideline on sizing CPU limits for .NET services.

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.

↩ Usually asked after

More in Operating System

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me