Async All the Way and Thread-Pool Starvation
Recognise sync-over-async, explain why it starves the thread pool under load, and diagnose it in production with dotnet-counters and stack dumps.
// blocks a pool thread for the whole call
var rate = rates.GetAsync("GBP").Result;
// frees the thread while waiting
var rate = await rates.GetAsync("GBP", ct);Kestrel runs your middleware and endpoints on thread-pool threads. An await on I/O that has not finished hands the thread back to the pool; the continuation runs later on whichever thread is free. That is why a pod with a handful of threads can hold thousands of in-flight requests: waiting costs a small heap object, not a thread. Code that blocks instead (.Result, .Wait(), GetAwaiter().GetResult(), Thread.Sleep, a synchronous database driver) keeps the thread for the whole wait.
How starvation happens
The pool's minimum worker count defaults to the number of CPUs. Up to that number, threads are created immediately on demand. Past it, the pool adds threads deliberately slowly, because extra threads on a CPU-bound workload only add context switches. Under load, blocking requests occupy every thread, new requests and the continuations of async work sit in the queue, and latency climbs while CPU stays low. Sync-over-async is worse than plain blocking: the task you block on usually needs a pool thread to run its own continuation, so a blocked thread is waiting for a thread that cannot be scheduled.
ASP.NET Core has no SynchronizationContext, so the classic UI-style deadlock from .Result does not happen and ConfigureAwait(false) is unnecessary in application code. The pool starvation does happen, and it only shows under concurrency: every test passes, then the first traffic spike takes the service down.
Symptoms
- p99 latency in seconds with CPU at 10-20%, and a thread count that keeps rising.
- Health checks and Kubernetes probes time out, pods restart, the remaining pods get more load: a cascade.
- Kestrel logs that its heartbeat took longer than expected; outgoing
HttpClientcalls time out though the dependency is healthy.
Diagnosing
| Tool | What to look at |
|---|---|
dotnet-counters monitor | dotnet.thread_pool.queue.length above zero for long periods, dotnet.thread_pool.thread.count climbing |
dotnet-stack report | Many threads parked in Task.Wait, ManualResetEventSlim.Wait or Monitor.Enter |
dotnet-dump analyze | threadpool and clrstack -all on a dump taken during the incident |
The fix and the decisions
Make the call chain async from the endpoint to the socket: ToListAsync, SaveChangesAsync, ReadFromJsonAsync, SemaphoreSlim.WaitAsync instead of lock around I/O. Kestrel helps by default: AllowSynchronousIO is false, so a synchronous Request.Body.Read throws InvalidOperationException. Do not wrap blocking calls in Task.Run inside a request; that moves the block to another pool thread. Raising the minimum with ThreadPool.SetMinThreads buys time when a library is synchronous and cannot be replaced, but it is a stopgap with a memory cost per thread, not a fix.
Example
When to use it
- Explain why an orders API with 15% CPU returns 8-second responses during a sale and fix the .Result call in its pricing client
- Replace a lock around an HTTP call to an exchange-rate service with SemaphoreSlim.WaitAsync and cancellation
- Capture dotnet-counters and dotnet-stack output from a production pod before Kubernetes restarts it
More examples
Pitfall and fix: a cached rate lookup that blocks
The blocking version looks harmless in a unit test. Under load, one slow call to the rate service parks every thread that reaches the <code>lock</code>. The async version keeps waiters in the semaphore queue as heap objects, double-checks after acquiring it, and passes the request's <code>CancellationToken</code> so abandoned requests stop waiting. Registration matters: as a typed client (<code>AddHttpClient<RateProvider></code>) it would be transient, so every request would get an empty cache and its own semaphore; as a singleton holding an injected <code>HttpClient</code> it would keep one handler forever and miss DNS changes. A singleton that calls <code>IHttpClientFactory.CreateClient</code> per call avoids both.
using System.Net.Http.Json;
public sealed record ExchangeRate(string Currency, decimal ToGbp, DateTimeOffset FetchedAt);
// Before: lock + .Result. Every request that misses the cache holds a pool thread,
// and every other request queues on the lock while still holding its own thread.
public sealed class BlockingRateProvider(HttpClient http)
{
private readonly Lock _gate = new();
private ExchangeRate? _cached;
public ExchangeRate Get(string currency)
{
lock (_gate)
{
if (_cached is { } c && c.Currency == currency && c.FetchedAt > DateTimeOffset.UtcNow.AddMinutes(-5))
return c;
_cached = http.GetFromJsonAsync<ExchangeRate>($"/rates/{currency}").Result!;
return _cached;
}
}
}
// After: async all the way. Waiters queue on the semaphore without holding a thread.
// A singleton, so the cache and the semaphore are shared by every request; it asks the
// factory for a client per call, so handler rotation still picks up DNS changes.
public sealed class RateProvider(IHttpClientFactory httpClients, TimeProvider clock)
{
private readonly SemaphoreSlim _gate = new(1, 1);
private ExchangeRate? _cached;
public async Task<ExchangeRate> GetAsync(string currency, CancellationToken ct)
{
if (IsFresh(_cached, currency)) return _cached!;
await _gate.WaitAsync(ct);
try
{
if (IsFresh(_cached, currency)) return _cached!;
var http = httpClients.CreateClient("rates");
_cached = await http.GetFromJsonAsync<ExchangeRate>($"/rates/{currency}", ct)
?? throw new InvalidOperationException($"No rate for {currency}");
return _cached;
}
finally
{
_gate.Release();
}
}
private bool IsFresh(ExchangeRate? rate, string currency) =>
rate is not null && rate.Currency == currency && rate.FetchedAt > clock.GetUtcNow().AddMinutes(-5);
}
public static class RateProviderRegistration
{
public static IServiceCollection AddRateProvider(this IServiceCollection services)
{
services.AddHttpClient("rates", c => c.BaseAddress = new Uri("https://rates.internal.example"));
services.AddSingleton(TimeProvider.System);
services.AddSingleton<RateProvider>();
return services;
}
}
Diagnosing starvation on a live pod
Run these while the incident is happening; a dump taken after the pod recovers shows nothing. The tools talk to the runtime through a diagnostics socket in <code>/tmp</code>, and production images have no SDK, so <code>dnx</code> is for your own machine: in a pod, use a debug container (or sidecar sharing <code>/tmp</code> and the process namespace) with the single-file tools, running as the same UID. A dump contains connection strings, tokens, keys and customer data in plain text: restrict who can collect and download it, store it encrypted, delete it after analysis, and prefer <code>--type Mini</code> or <code>Heap</code> over <code>Full</code>. A queue length that stays above zero while CPU is low confirms starvation.
# The aspnet and chiseled images have no SDK (and chiseled has no shell), so attach an
# ephemeral debug container that shares the app's process namespace. Its image must
# contain the tools, and it must run as the app's UID (1654) to open the diagnostics socket.
kubectl debug -it orders-api-7d9f8 --target=orders-api \
--image=registry.example.com/dotnet-diag:10.0 -- bash
# Inside the debug container: the single-file tools baked into that image
# (downloaded from https://aka.ms/dotnet-counters/linux-x64 and the matching
# dotnet-stack and dotnet-dump links)
dotnet-counters ps
# Thread-pool and request counters, refreshed every second
dotnet-counters monitor --process-id 1 --refresh-interval 1 \
--counters "System.Runtime[dotnet.thread_pool.queue.length,dotnet.thread_pool.thread.count,dotnet.thread_pool.work_item.count],Microsoft.AspNetCore.Hosting"
# Where are the threads right now? Look for many identical frames in Task.Wait or Monitor.Enter
dotnet-stack report --process-id 1
# Dump for offline analysis, then inside 'analyze': threadpool, clrstack -all, dumpasync.
# A full dump holds every secret and customer record in memory: treat it as sensitive data.
dotnet-dump collect --process-id 1 --type Heap --output /tmp/orders-api.dmp
dotnet-dump analyze /tmp/orders-api.dmp
# On a machine with the .NET 10 SDK, dnx runs the same tools without installing them
dnx dotnet-counters ps
Stopgap: raise the minimum worker threads
<code>ThreadPoolMinThreads</code> writes <code>System.Threading.ThreadPool.MinThreads</code> into <code>runtimeconfig.json</code>, so the pool creates up to 64 workers on demand instead of ramping slowly past the CPU count. Each thread reserves stack memory and adds scheduling cost, so treat this as a dated, ticketed workaround while the blocking code is replaced.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Temporary: the legacy SOAP client used by /invoices is synchronous.
Remove once InvoiceArchiveClient is replaced (ticket ORD-2141). -->
<ThreadPoolMinThreads>64</ThreadPoolMinThreads>
</PropertyGroup>
</Project>
Discussion