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

📚 Interview question

How do you recognise, diagnose and fix ThreadPool starvation in a production ASP.NET Core service?

Starvation symptoms, Kestrel heartbeat warnings, dotnet-counters and dotnet-trace evidence, dump analysis, root causes and short- versus long-term fixes.

Senior Debugging Advanced aspnet-core diagnostics performance threadpool-starvation

🎯 Key points

  • Starvation: pool threads blocked faster than the pool injects new ones
  • Symptoms: high latency, low CPU, growing queue, slowly rising threads, Kestrel heartbeat warnings
  • Confirm with dotnet-counters queue length and thread count
  • Find the cause with dotnet-stack or a dump and parallelstacks
  • Usual causes: .Result/.Wait, synchronous I/O, long locks, loops on the pool
  • SetMinThreads and scaling are mitigations; making code async is the fix
  • Prevent with analysers, load tests and queue-length alerts

⏱ 30-second answer

ThreadPool starvation happens when pool threads are blocked faster than the pool can add new ones, so queued work (requests, continuations, timers) waits. Symptoms: latency rises sharply while CPU stays low, the pool queue length grows, thread count climbs slowly, health checks time out and Kestrel logs heartbeat warnings mentioning possible starvation. I confirm it with dotnet-counters (queue length and thread count), then take dotnet-stack snapshots or a dump to see what the threads are blocked on, usually .Result, .Wait(), synchronous I/O or locks. The fix is to make that code asynchronous; raising SetMinThreads only buys time.

🗣 Full answer (senior)

How I would answer this in an interview

"ThreadPool starvation happens when pool threads get blocked faster than the pool can inject new ones. Queued work (incoming requests, await continuations, timer callbacks) then just waits. The signature is unmistakable: latency explodes while CPU stays low, the pool queue length grows, thread count creeps up by about one or two per second, health probes time out, and Kestrel logs heartbeat warnings that mention possible thread pool starvation. I confirm it with dotnet-counters, then find the cause from stacks, via dotnet-stack snapshots or a dump with parallelstacks, looking for many threads parked in the same frame: .Result/.Wait(), synchronous I/O, or a contended lock. To mitigate during the incident, I scale out or temporarily raise min threads. The fix is removing the blocking: async end to end, or isolating unavoidable blocking onto dedicated, bounded threads. Then I prevent it with analysers, load tests and a queue-length alert."


1. Symptoms

Signal Starvation CPU saturation Slow dependency
Latency Very high High High
CPU Low / moderate ~100% Low
dotnet.thread_pool.queue.length Growing May grow Near 0
dotnet.thread_pool.thread.count Slowly rising Stable Stable
Stacks Waiting (Task.Wait, GetResult, Monitor.Enter, sync I/O) Running user code Few threads; many pending async ops
Kestrel Heartbeat warnings Possibly No

2. Diagnosis runbook

Step Command / tool Look for
1. Confirm dotnet-counters monitor -n MyApi --counters System.Runtime Queue length up, threads creeping, CPU low
2. Stacks (cheap) dotnet-stack report -p <pid> × 2–3 Same blocking frame on many threads
3. Dump (deep) dotnet-dump collect → parallelstacks, threadpool, clrstack -all Hot frame and the call path into it
4. Trace (optional) dotnet-trace with ThreadPool events Thread injections with reason Starvation
5. In containers dotnet-monitor sidecar or kubectl debug Automated capture when queue length crosses a threshold

3. Usual root causes

Cause Where it hides
Sync-over-async (.Result, .Wait(), GetAwaiter().GetResult()) Controllers, filters, DI factories, IOptions setup, third-party SDKs (auth token caches!)
Synchronous I/O EF Core sync methods, HttpClient.Send, File.ReadAllText, sync stream reads
Long locks around slow work Caches refreshed inside lock
Long-running loops on Task.Run Background pollers occupying pool threads permanently
Synchronous logging sinks Network sinks called on every request
Thread.Sleep in request paths Retry loops

4. Mitigation vs fix

Action Type Notes
Scale out Mitigation Spreads load; doesn't remove blocking
Raise min threads (System.Threading.ThreadPool.MinThreads) Mitigation Creates threads immediately up to the minimum; costs memory/context switches; set an owner + removal date
Make the blocking path async Fix Including third-party upgrades
Move unavoidable blocking to dedicated threads with bounded concurrency Fix E.g. legacy SDK behind a Channel<T> + dedicated worker threads
Shorten locks / use SemaphoreSlim Fix No I/O under locks

5. Prevention

  • Analysers: CA1849 (call async methods in async methods), VSTHRD002 (avoid synchronous waits), VSTHRD103.
  • A load test before releases that pushes concurrency past the pool minimum.
  • Alert on queue length and on Kestrel heartbeat warnings.
  • A runbook for capturing stacks inside containers.

Production example

Our [API, ~3,000 requests/second] added a new authentication library the week before a [busy Monday]. That morning p99 hit [12 s] with CPU at [20%]; pool queue length was in the [thousands], Kestrel logged heartbeat warnings, and liveness probes started restarting pods, which made it worse. dotnet-stack snapshots from two pods showed [about 180] threads inside the library's token cache, blocked on .Result of an HTTP token request. I applied System.Threading.ThreadPool.MinThreads = 200 via runtime config as a stopgap, which stabilised latency within minutes, then switched to the library's async token API and removed the override [a week later]. We added a pre-release load test, enabled CA1849/VSTHRD002 as errors, and alerted on queue length.

🔍 What the interviewer is checking

A core senior production-debugging question.

Green flags

  • Describes low CPU plus high latency plus growing queue.
  • Uses dotnet-counters, dotnet-stack and dumps.
  • Identifies blocking calls as root cause.
  • Separates mitigation from the real fix.

Red flags

  • Only suggests more instances or bigger machines.
  • Never mentions stacks or evidence.

📚 Learn in depth

1. Why starvation snowballs

request thread T1: client.GetStringAsync(url).Result   -> blocks T1
the HTTP response arrives -> its continuation needs a pool thread to run
all pool threads are blocked in .Result waiting for continuations
-> continuations wait in the queue -> blocked threads keep waiting
-> only slow thread injection (gate thread / hill climbing) breaks the cycle

Each blocked thread waits on work that itself needs a free thread, so throughput collapses until injection catches up. Since .NET 6 the pool compensates faster when threads block on tasks, but it still lags behind a burst.

2. Reading parallelstacks

> parallelstacks
  187 System.Threading.ManualResetEventSlim.Wait
  187 System.Threading.Tasks.Task.SpinThenBlockingWait
  187 System.Threading.Tasks.Task.InternalWaitCore
  187 System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
  187 Vendor.Auth.TokenCache.GetToken()        <-- culprit (sync wrapper over async)
  187 Vendor.Auth.AuthHandler.SendAsync
  ...

The frame just above the wait frames is where the blocking was introduced.

3. Kestrel heartbeat warning

Kestrel runs a heartbeat once per second on a timer. When pool threads are starved, the heartbeat itself runs late and Kestrel logs a warning that the heartbeat took longer than expected, noting it could be caused by thread pool starvation. Treat it as an early-warning signal and alert on it.

4. Automating evidence capture

dotnet-monitor collection rules can watch runtime counters and collect stacks/dumps when, for example, queue length exceeds a threshold for 30 seconds:

{
  "CollectionRules": {
    "Starvation": {
      "Trigger": { "Type": "EventCounter", "Settings": {
          "ProviderName": "System.Runtime", "CounterName": "threadpool-queue-length",
          "GreaterThan": 100, "SlidingWindowDuration": "00:00:30" } },
      "Actions": [ { "Type": "CollectStacks" }, { "Type": "CollectDump", "Settings": { "Type": "WithHeap" } } ],
      "Limits": { "ActionCount": 1, "ActionCountSlidingWindowDuration": "01:00:00" }
    }
  }
}

(Check counter names for your runtime version; .NET 9+ also exposes System.Diagnostics.Metrics-based names.)

5. Configuring min threads (temporary)

// runtimeconfig.template.json
{ "configProperties": { "System.Threading.ThreadPool.MinThreads": 200 } }

Or the MSBuild property <ThreadPoolMinThreads>200</ThreadPoolMinThreads>, or ThreadPool.SetMinThreads(200, 200) at startup. Record it as a mitigation with an owner and a removal date.

6. Isolating unavoidable blocking

// Legacy sync-only SDK: run it on a few dedicated threads, never on the pool
var requests = Channel.CreateBounded<(Request Req, TaskCompletionSource<Response> Tcs)>(500);
for (int i = 0; i < 4; i++)
    new Thread(() =>
    {
        foreach (var (req, tcs) in requests.Reader.ReadAllAsync().ToBlockingEnumerable())
        {
            try { tcs.TrySetResult(legacy.Call(req)); }
            catch (Exception ex) { tcs.TrySetException(ex); }
        }
    }) { IsBackground = true, Name = $"legacy-sdk-{i}" }.Start();

Callers await the TCS; only the four dedicated threads ever block.

7. Version notes

Version Change
.NET Core 3.0 dotnet-counters, dotnet-dump
.NET 5 dotnet-stack
.NET 6 Managed thread pool; faster compensation for blocking on tasks
.NET 7 ToBlockingEnumerable() for IAsyncEnumerable<T>
.NET 9 dotnet.thread_pool.* metrics in System.Runtime meter
.NET 10 Same diagnosis approach

8. Try it yourself

  1. Run the code example and load /bad with bombardier -c 200 -d 60s; watch dotnet-counters.
  2. Load /good with the same settings and compare.
  3. Take dotnet-stack report during the /bad test and find the blocking frame.
  4. Add System.Threading.ThreadPool.MinThreads = 300 and repeat /bad: better, but memory and threads are much higher.
  5. Run in Docker with --cpus=1 and see starvation start at far lower concurrency.
  6. Configure a dotnet-monitor rule to capture stacks automatically when queue length is high.

💻 Code

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
var app = builder.Build();

// BAD: sync-over-async blocks a pool thread for the whole call.
app.MapGet("/bad", (IHttpClientFactory f) =>
{
    var client = f.CreateClient();
    string body = client.GetStringAsync("https://example.com/").Result; // starvation under load
    return body.Length;
});

// GOOD: the thread is released while the call is in flight.
app.MapGet("/good", async (IHttpClientFactory f, CancellationToken ct) =>
{
    var client = f.CreateClient();
    string body = await client.GetStringAsync("https://example.com/", ct);
    return body.Length;
});

// Diagnostic endpoint (protect it in real systems).
app.MapGet("/diag/threadpool", () => new
{
    Threads = ThreadPool.ThreadCount,
    Pending = ThreadPool.PendingWorkItemCount,
    Completed = ThreadPool.CompletedWorkItemCount
});

app.Run();

Commands:

dotnet-counters monitor -n MyApi System.Runtime
dotnet-stack report -p 1234 > stacks.txt
dotnet-dump collect -p 1234

⚠ Common mistakes

1. Treating SetMinThreads as the fix

What happens: min threads raised to 500 and the incident closed. Why it is wrong: blocking remains; it fails again at higher load, with more memory and context switching. Do instead: use it to stabilise, then remove the blocking.

2. Scaling up CPU because latency is high

What happens: bigger instances are provisioned. Why it is wrong: threads are waiting, not computing. Do instead: check CPU and queue length together to distinguish starvation from saturation.

3. Looking only at your own code

What happens: the team searches its repo for .Result and finds nothing. Why it is wrong: SDKs and libraries often block internally. Do instead: read the stacks: they include library frames.

4. Restarting pods without collecting stacks

What happens: liveness probes or engineers restart pods. Why it is wrong: the evidence disappears; restarts can amplify the problem by moving load to other pods. Do instead: capture stacks first; automate capture with dotnet-monitor.

5. Aggressive liveness probes during starvation

What happens: probes time out and kill pods that would recover. Why it is wrong: cascading restarts reduce capacity further. Do instead: separate liveness from readiness; give liveness generous timeouts.

6. Wrapping sync code in Task.Run in request handlers

What happens: await Task.Run(() => SyncCall()) as a "fix". Why it is wrong: the blocking just moves to another pool thread. Do instead: true async APIs, or dedicated threads for unavoidable blocking.

7. Ignoring Kestrel heartbeat warnings

What happens: warnings scroll by in logs. Why it is wrong: they're an early signal of starvation. Do instead: alert on them.

8. Testing only at low concurrency

What happens: load tests with concurrency below the pool minimum. Why it is wrong: starvation only appears beyond the minimum. Do instead: test bursts well above core count, in the real container shape.

9. Blocking in startup/DI paths

What happens: .Result in a singleton factory or options setup. Why it is wrong: first requests under load block many threads at once. Do instead: async initialisation in hosted services or lazy async patterns.

10. No queue-length monitoring

What happens: dashboards show only CPU and latency. Why it is wrong: you can't tell starvation from other slowness. Do instead: dashboard and alert on pool queue length and thread count.

🏛 Senior insight: trade-offs

Starvation is the most common async production failure I see, and it is always diagnosable from stacks. I keep queue length on dashboards, alert on it, and make sure there is a runbook for capturing stacks inside containers.

Phase Action
Detect Alert on pool queue length and Kestrel heartbeat warnings
Stabilise Scale out, temporary min threads
Diagnose dotnet-stack, dump, parallelstacks
Fix Async end to end, isolate unavoidable blocking
Prevent Analysers, load tests, review checklist

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions slow thread injection Why: Checks ThreadPool internals
    How to answer

    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.

  2. When: Candidate finds .Result in stacks Why: Checks sync-over-async removal
    How to answer

    Sync-over-async is synchronously blocking on asynchronous work: .Result, .Wait(), GetAwaiter().GetResult() or Task.WaitAll. It holds a thread for the whole operation, causes ThreadPool starvation on servers and deadlocks under a SynchronizationContext. It sneaks in through constructors, DI factory lambdas, property getters, Dispose, lock blocks, synchronous interfaces (IComparer, old filters), static initialisers and "quick fixes" in legacy code. Remove it by making the call chain async, using async factory methods or lazy async initialisation, IAsyncDisposable, hosted-service warm-up, and async-capable interfaces. Where a sync boundary is unavoidable (for example a legacy interface), keep it at the edge and document it.

  3. When: Candidate finds synchronous I/O Why: Checks blocking calls in ASP.NET Core
    How to answer

    Any call that holds a request thread while waiting is a blocking call: sync-over-async (.Result, .Wait()), synchronous I/O (Stream.Read on the request body, File.ReadAllText, EF Core ToList()/SaveChanges(), HttpClient.Send), Thread.Sleep, long lock sections and synchronous logging to slow sinks. Kestrel disallows synchronous request and response body I/O by default (AllowSynchronousIO = false) and throws InvalidOperationException if you try. Replace them with async equivalents (ReadAsync, ToListAsync, SaveChangesAsync, SendAsync, Task.Delay, SemaphoreSlim.WaitAsync), find them with analysers (CA1849), stack snapshots and load tests.

🛠 Real-world problems: how you handle them

Starvation from an authentication library's sync token cache
Situation
An [API at 3,000 requests per second] added a new authentication library before a [busy Monday morning].
Signals
p99 latency [12 s], CPU [20%], pool queue length in the [thousands], Kestrel heartbeat warnings, liveness probe restarts.
Investigation
`dotnet-stack` snapshots showed [180] threads in the library's token cache waiting on `.Result` of an HTTP token request.
Resolution
Applied `SetMinThreads(200, 200)` via runtimeconfig as a stopgap, then upgraded to the library's async token API and removed the override a week later.
Prevention
Added a pre-release load test, CA1849 and VSTHRD002 analysers, and a queue-length alert.

How to tell it (STAR)

On a [Monday morning] our API slowed to twelve-second responses with only 20% CPU. Queue length was huge and Kestrel warned about starvation. Stack snapshots showed most threads blocked in a new auth library's synchronous token fetch. I raised min threads to stabilise, then switched to the library's async API and removed the workaround. We now load test every release and alert on queue length.

↩ Usually asked after

More in Concurrency Production Problems

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me