Global and local queues, work stealing, minimum threads, hill climbing, starvation injection and the metrics to watch in .NET 10.
The ThreadPool is a process-wide set of reusable background threads that run queued work items: Task.Run, async continuations, timer callbacks and ASP.NET Core requests. Work goes into a global queue or, when queued from a pool thread, into that thread's local queue, and idle threads steal from others. The pool starts up to minimum threads (default: processor count) immediately on demand; above that it adds threads gradually, using a hill-climbing algorithm that measures throughput plus a starvation detector that injects threads when queued work is not progressing. That gradual injection is why blocking pool threads causes latency spikes.
"The ThreadPool is a process-wide set of reusable background threads that run short work items:
Task.Rundelegates,awaitcontinuations, timer callbacks, I/O completions and every ASP.NET Core request. Work is queued either to a global FIFO queue or, when it's queued from a pool thread, to that thread's local queue, and idle threads steal from each other. For sizing, the pool creates threads immediately up to its minimum (by default the processor count). Above that it adds threads gradually: a hill-climbing controller tunes the count for throughput, and a starvation mechanism injects extra threads at a limited rate when queued work isn't making progress. That slow injection is the key production fact: if pool threads block on.Result, synchronous I/O or long locks, requests queue up and latency explodes while CPU stays low. The fix is removing the blocking, not raisingSetMinThreads."
| Work | Queued how |
|---|---|
Task.Run, Task.Factory.StartNew (default scheduler) |
Pool work item |
await continuations with no SynchronizationContext |
Pool work item |
System.Threading.Timer, PeriodicTimer continuations |
Pool work item |
| Socket / file I/O completions | Completion callback, then pool work item |
| ASP.NET Core (Kestrel) request processing | Pool work items |
Parallel.For, PLINQ |
Pool threads (plus the calling thread) |
global queue (FIFO) <-- work queued from non-pool threads, PreferFairness
|
+--------------+--------------+
| | |
worker 1 worker 2 worker 3
[local deque] [local deque] [local deque]
owner pops newest (LIFO); idle workers steal oldest (FIFO) from others
| Why | Effect |
|---|---|
| Local queues | No contention on a single global lock; warm caches for continuations |
| LIFO for the owner | Recently queued data is still in cache |
| Stealing | Idle cores pick up work from busy ones |
| Range | Behaviour |
|---|---|
Up to min threads (default Environment.ProcessorCount) |
Created immediately when work is waiting |
| Above min | Hill climbing: small adjustments, keeping changes that improve completions per second |
| Work queued but not progressing | Starvation injection: extra threads added at a limited rate (on the order of one or two per second) |
Threads blocking on tasks (Task.Wait, .Result) |
Since .NET 6 the pool notices and injects faster, which helps but doesn't remove the latency cost |
| Max | Very large (32,767 workers on 64-bit); almost never the limit |
| Idle | Threads above the minimum retire after a period without work |
Since .NET 6 the pool is the managed "portable" implementation on all platforms.
8 cores, min = 8 threads
8 requests each block 2 s on .Result -> all 8 pool threads blocked
500 more requests arrive -> queued, nobody to run them
pool injects ~1-2 threads/sec -> the queue drains slowly; latency in seconds
CPU: low -> "the server is idle but slow"
Metric (.NET 9+ System.Runtime meter) |
Healthy | Starvation |
|---|---|---|
dotnet.thread_pool.queue.length |
Near 0 | Growing |
dotnet.thread_pool.thread.count |
Stable, small | Climbing slowly |
dotnet.thread_pool.work_item.count (rate) |
Tracks traffic | Drops vs traffic |
| CPU | Matches load | Low despite high latency |
In code: ThreadPool.ThreadCount, PendingWorkItemCount, CompletedWorkItemCount.
| Step | Tool |
|---|---|
| Confirm | dotnet-counters monitor -n MyApp --counters System.Runtime |
| Find what threads are doing | dotnet-dump collect → dotnet-dump analyze → parallelstacks / clrstack -all |
| Fix | Make the blocking path async end to end; shorten locks; move long loops off the pool |
| Mitigate temporarily | ThreadPool.SetMinThreads or System.Threading.ThreadPool.MinThreads in config |
After a release, our [product catalogue API] at [1,500 requests/second] started timing out at the start of each traffic burst. Latency went from [50 ms] to [5–8 s], CPU stayed low, dotnet.thread_pool.queue.length climbed into the [thousands], and thread count rose by about one per second. I took a dump and ran parallelstacks: most pool threads sat in Task.Wait under a new pricing client that called HttpClient.SendAsync(...).Result. As a stopgap I raised min threads to [200] via configuration, which stabilised latency. Then I made the pricing client async end to end, removed the override, and added an analyser rule against .Result/.Wait(), a queue-length alert and a burst load test in CI. Latency returned to [50 ms].
A senior-level check on whether the candidate can explain and diagnose pool behaviour.
Green flags
Red flags
SetMinThreads as the fix without root cause.| Queued from | Goes to | Order | ExecutionContext |
|---|---|---|---|
Non-pool thread (Task.Run from Main) |
Global queue | FIFO | Captured |
Pool thread (Task.Run, continuation) |
That thread's local queue | Owner LIFO, thieves FIFO | Captured |
TaskCreationOptions.PreferFairness |
Global queue | FIFO | Captured |
ThreadPool.UnsafeQueueUserWorkItem(item, preferLocal) |
Global or local | As above | Not captured |
| Kestrel / sockets internal work | Often IThreadPoolWorkItem via unsafe queueing |
— | Not captured (framework manages flow) |
A worker's loop is roughly: local queue → global queue → steal from a random other worker → park (spin briefly, then wait on a semaphore).
The controller samples throughput (completed items per interval) at different thread counts, effectively running a small optimisation:
count = target
every sample period:
measure completions/sec
if increasing threads improved throughput: keep going up
else: move down
It works well for CPU-bound or mixed workloads where more threads can raise throughput. It reacts slowly to sudden blocking, because blocked threads don't produce clear throughput signals quickly. That's what the starvation mechanism covers.
A background "gate" thread periodically checks whether work items are being dequeued. If the pool is at its target, work is pending, and nothing has completed for a while, it adds a thread. This happens at a bounded rate so a burst of blocking doesn't create thousands of threads. In .NET 6+, when a pool thread blocks waiting on a task, the runtime can count it as "blocking" and compensate more aggressively, but each injected thread still costs memory and scheduling, and latency still rises.
SetMinThreads: what it really doesThreadPool.SetMinThreads(workerThreads: 200, completionPortThreads: 200);
"System.Threading.ThreadPool.MinThreads" in runtimeconfig.json, or the MSBuild property ThreadPoolMinThreads.Use it as an incident mitigation or for a known bursty legacy workload, never as the default fix.
Environment.ProcessorCount reflects the CPU limit, so a 2-CPU pod gets a minimum of 2 workers.DOTNET_PROCESSOR_COUNT can override what the runtime sees (use carefully).> dotnet-dump analyze app.dmp
> threadpool # pool state: threads, queue, min/max
> parallelstacks # merged stacks: look for many threads at the same frame
300 System.Threading.Monitor.Wait
300 System.Threading.ManualResetEventSlim.Wait
300 System.Threading.Tasks.Task.InternalWaitCore
300 PricingClient.GetPrice <-- .Result on an async call
The culprit is the frame just above Task.Wait/.Result.
| Version | Change |
|---|---|
| .NET Framework 4.0 | Hill climbing, work-stealing local queues |
| .NET Core 3.0 | ThreadPool.ThreadCount, PendingWorkItemCount, CompletedWorkItemCount |
| .NET 6 | Managed portable thread pool on all platforms; faster compensation for blocking on tasks |
| .NET 7 | Managed I/O completion handling on Windows (see next question) |
| .NET 9 | System.Runtime metrics with dotnet.thread_pool.* names |
| .NET 10 | Same model; guidance unchanged: don't block pool threads |
ThreadPool.SetMinThreads(64, 64) before the loop and compare total time.Task.Delay(500).Wait(); load it with bombardier -c 200, watch dotnet-counters, then fix it with await and compare.threadpool and parallelstacks.--cpus=1 and see how much faster starvation sets in.using System.Diagnostics;
using System.Threading;
ThreadPool.GetMinThreads(out int minWorkers, out int minIo);
Console.WriteLine($"Min workers {minWorkers}, min IO {minIo}");
var sw = Stopwatch.StartNew();
var blocking = Enumerable.Range(0, 64)
.Select(_ => Task.Run(() => Thread.Sleep(1000))) // blocks pool threads
.ToArray();
while (!Task.WaitAll(blocking, 500))
{
Console.WriteLine($"{sw.Elapsed.TotalSeconds:F1}s threads={ThreadPool.ThreadCount} " +
$"pending={ThreadPool.PendingWorkItemCount}");
}
Console.WriteLine($"Blocking version took {sw.Elapsed.TotalSeconds:F1}s");
sw.Restart();
await Task.WhenAll(Enumerable.Range(0, 64).Select(_ => Task.Delay(1000)));
Console.WriteLine($"Async version took {sw.Elapsed.TotalSeconds:F1}s, threads={ThreadPool.ThreadCount}");
SetMinThreads as the permanent fixWhat happens: latency spikes are "fixed" by setting min threads to 500. Why it is wrong: it hides blocking code and adds memory, context switching and GC suspension cost. Do instead: find and remove the blocking; use the setting only as a temporary mitigation.
What happens: "the pool will just create more threads if needed". Why it is wrong: above the minimum, injection is throttled. Do instead: design so pool threads never block for long.
What happens: Task.Run(() => { while (true) { ... blocking ... } }).
Why it is wrong: each loop permanently removes a thread from the pool.
Do instead: a dedicated thread, or async code that yields.
What happens: a work item sets Thread.CurrentThread.Priority, Name or culture.
Why it is wrong: the change leaks to unrelated work later on that thread.
Do instead: don't touch pool thread state; use a dedicated thread if needed.
What happens: "we have 400 threads, that's plenty". Why it is wrong: blocked threads don't do work; the queue length tells you if work is waiting. Do instead: watch queue length and completions alongside thread count and CPU.
Task.Run in ASP.NET Core request handlers "to make it async"What happens: await Task.Run(() => SyncDbCall()).
Why it is wrong: it moves the blocking to another pool thread and adds overhead.
Do instead: use truly async APIs; see the async-over-sync question.
PreferFairness or custom schedulers to "fix" latencyWhat happens: tweaking queueing order to speed things up. Why it is wrong: ordering isn't the problem when threads are blocked. Do instead: remove blocking first.
What happens: min threads raised for a CPU-heavy service. Why it is wrong: beyond core count, more threads only add context switching. Do instead: limit CPU parallelism to roughly the core count; scale out.
What happens: code tested on a 16-core laptop runs on 1-CPU pods. Why it is wrong: the pool minimum is 1–2 threads, so a single blocking call stalls the service. Do instead: load-test in the real container shape.
What happens: random config changes during an incident.
Why it is wrong: the cause is almost always one specific blocking call.
Do instead: take a dump and read parallelstacks; the hot frame points at the culprit.
The pool is tuned for short, non-blocking work. Nearly every pool problem I have seen in production is blocking: .Result, .Wait(), synchronous I/O or long locks. I watch queue length as the main health signal, because thread count alone looks fine until it is too late.
| Signal | Meaning | Action |
|---|---|---|
| Queue length near 0, few threads | Healthy | Nothing |
| Queue growing, threads rising slowly | Starvation from blocking | Dump, find the blocking call |
| Many threads, high CPU | CPU-bound saturation | Profile, scale out, limit parallelism |
| Many threads, low CPU | Threads blocked on I/O or locks | Make the I/O async, shorten locks |
Worker threads run CPU work and async continuations. I/O completion threads exist to receive notifications that an asynchronous I/O operation finished. On Windows the OS mechanism is an I/O completion port; on Linux .NET uses epoll (kqueue on macOS) with a small number of event-loop threads. In modern .NET those completions are picked up by a few poller threads and the continuation is normally queued to the worker pool, so the worker count and queue length are what matter. No thread waits while the I/O is in flight: that is the whole point of async 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.
A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.
How to tell it (STAR)
After a release, our [catalogue API] began timing out on each burst even though CPU was low. The pool queue length was growing and threads were added only slowly, which pointed to blocked threads. A dump showed most of them stuck in `.Result` on [a new pricing client]. I raised the minimum threads as a stopgap, then made the client async end to end and removed the override. Latency went back to [50 ms] and we added a dashboard and an analyser rule.
Last updated 31 Oct 2026.