Repository navigation
Replies: 1 comment
Analysis of the Stalled Timer and ThreadPool Scaling BehaviorYour reproduction highlights a subtle and fascinating interaction between work-stealing queue prioritization, sync-over-async inlining, and the GateThread starvation heuristic in CoreCLR. Here is the exact explanation of why the 1. Local Queue vs. Global Queue TopologyIn the .NET ThreadPool (
A worker thread always drains its own local queue first. It only searches the global queue when its local queue is empty, or periodically every 2. Why the Local Queues Never DrainIn your var ts = Enumerable.Range(0, 6)
.Select(_ => Task.Run(() => Thread.Sleep(16))).ToArray();
Task.WaitAll(ts);When Because you have 10 concurrent loops continuously producing 6 new tasks every iteration, worker threads are trapped in an endless loop of:
The worker threads rarely return to the outer thread pool dispatch loop, starving the global queue where the 3. Why the Starvation Engine Fails to Add ThreadsThe critical question is: Why doesn't the thread pool detect this starvation and spawn a new thread? In // PortableThreadPool.GateThread.cs
if (!disableStarvationDetection &&
threadPoolInstance._pendingBlockingAdjustment == PendingBlockingAdjustment.None &&
threadPoolInstance._separated._hasOutstandingThreadRequest != 0 &&
SufficientDelaySinceLastDequeue(threadPoolInstance))
{
// Inject a worker thread
}The key gatekeeper is private static bool SufficientDelaySinceLastDequeue(PortableThreadPool threadPoolInstance)
{
uint delay = (uint)(Environment.TickCount - threadPoolInstance._separated.lastDispatchTime);
uint minimumDelay = (uint)threadPoolInstance._separated.counts.NumThreadsGoal * DequeueDelayThresholdMs;
return delay > minimumDelay;
}Notice what In your code:
The Furthermore, Hill Climbing ( 4. Why Attaching and Freezing Threads "Un-stalls" ItWhen you attach a debugger and freeze the worker threads:
SummaryThis is not an outright functional crash, but a known heuristic limitation of the ThreadPool: Starvation detection tracks process-wide dispatch frequency rather than per-queue dwell time. A steady stream of short, local-queue work items can completely mask global-queue starvation from the runtime's self-tuning algorithms. |
Uh oh!
There was an error while loading. Please reload this page.
Description
I ran into a thread-pool/timer "starvation" situation I haven't seen before.
It doesn't seem to be the case of neither debug-threadpool-starvation nor another case from Kevin Gosse.
Even though it resembles the later, it manifests differently.
The ThreadPool Thread Count remains very low at a constant value of 5-6 and never increases.
The ThreadPool Queue Length oscillates between 6-30.
The TimerCallback is called once and then it "stalls", even though an expectation is that it should be called every second.
The minimal code to reproduce the starvation
Questions
Based on the referenced articles, I assumed/verified that EndlessLoopProcessor keeps local thread-pool queues full.
What I don't understand is why the timer callback is never executed?
If callbacks in the global queue are never processed due to all running worker threads are busy processing
their local queues, why thread-pool simply doesn't spawn a new worker thread?
If my assumption regarding timer callback being queued into the global queue is correct, it's even more puzzling, because when I inspected the global queue I wasn't able to find the timer callback there (I might just missed it though).
When I attach debugger and freeze/un-freeze threads executing EndlessLoopProcessor there is a high chance the program "un-stalls" and continues as would be expected, that is, the TimerCallback gets called every second.
When this happens
!sos.threadpoolshows that number of worker threads is higher than it used to be.That might explain why the timer callbacks resume, because now there is a worker thread which can process the timer callback.
Why doesn't the thread-pool increase the number of worker threads itself?
In the following scenarios, the thread-pool does increase the number of worker threads.
Remarks
Just to be clear, I'm not interested in how to solve this situation, because it's obvious - don't block the thread pool thread as EndlessLoopProcessor does (using a dedicated thread for EndlessLoopProcessor does the trick, either creating it manually or using
Task.Factory.StartNewwithTaskCreationOptions.LongRunninghint which essentially does the same).I'm curious, why the thread-pool doesn't self-tune by increasing the number of worker threads?
I observed this behavior on .NET Framework 4.8, .NET Core 3.1 and .NET 6.
All reactions