ASP.NET Core Kestrel and .NET ThreadPool: Boundary Between Burst and Starvation Under Concurrent Load
0 reputation · 19 Mar 2023, 00:27 UTC
0 reputation · 19 Mar 2023, 00:27 UTC
When an ASP.NET Core application hosted on Kestrel receives a sudden burst of concurrent requests, the .NET ThreadPool's hill-climbing algorithm may not inject worker threads quickly enough, causing requests to queue and latency to spike. The core uncertainty is where the boundary lies between a transient burst that the pool should absorb and sustained starvation that warrants architectural changes.
The ThreadPool's internal heuristic for distinguishing these states is not fully exposed for manual tuning, leaving operators to rely on indirect signals such as ThreadPool Queue Length and thread injection rate. Sync-over-async patterns can exacerbate the issue by blocking threads, but even purely asynchronous workloads may experience queueing during the one-thread-per-millisecond injection window. This analysis assumes .NET 8 or later on ASP.NET Core; behavior in earlier versions may differ and should be re-verified.
The specific heuristic remains opaque to configuration and monitoring surfaces. Which observable counters or configuration surfaces best expose the ThreadPool's burst-versus-starvation decision without risking regression? What alignment between Kestrel's concurrent connection limits and ThreadPool.MinThreads prevents queue buildup while preserving throughput? Is there a documented interoperability contract between Kestrel request dispatch and ThreadPool starvation detection that operators can use to set safe concurrency ceilings?
There is no documented interoperability contract that exposes the .NET ThreadPool's burst-versus-starvation heuristic to Kestrel. The practical boundary is inferred, not configured. Rely on the relationship between pending and completed work items, plus thread injection timing, rather than on ThreadPool.QueueLength alone.
Confirmed: Kestrel accepts connections up to its configured limits and dispatches request work to the ThreadPool. The pool uses a hill-climbing algorithm that injects threads gradually. In .NET 8 and later, ThreadPoolStatistics exposes PendingWorkItemCount and CompletedWorkItemCount.
Likely or version-dependent: the exact threshold that triggers faster injection is not a public API. The commonly cited one-thread-per-500 ms injection rate is a heuristic, not a guaranteed constant. Treat any specific threshold as requiring verification on your target runtime.
PendingWorkItemCount with a stable CompletedWorkItemCount indicates work is arriving faster than the pool drains it. This is a stronger signal than queue length alone.DiagnosticSource events for System.Threading.ThreadPool and correlate injection timestamps with request arrival times. If injection consistently lags dispatch, the pool is treating the load as a burst it cannot yet absorb.Do not set ThreadPool.MinThreads equal to Kestrel.MaxConcurrentConnections. Raising MinThreads bypasses hill-climbing and risks thread exhaustion if set near connection ceilings. Instead:
MaxConcurrentConnections to leave headroom for non-request work (GC, timers, finalizers) and for the pool to inject threads.MinThreads at or near the processor count and remove sync-over-async blocking before considering any increase.MinThreads modestly, then load-test while watching thread count and context switches.dotnet-counters monitor --process-id <PID> --counters System.Runtime
This exposes ThreadPool thread count and queue length. For .NET 8+ pending/completed work items, confirm the exact EventPipe provider name in your runtime before relying on it.
Is the workload purely asynchronous end-to-end, or does it contain blocking calls such as sync-over-async, legacy database drivers, or file I/O? This single fact determines whether the remedy is architectural—removing blocking—or a conservative, load-tested adjustment to MinThreads.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.