From 32fd95b76b6e440ab62ea470322d3d5978f26c90 Mon Sep 17 00:00:00 2001 From: Vladimir Sadov Date: Fri, 28 Aug 2026 14:09:00 -0700 Subject: [PATCH] Increase spinning/polling aggressiveness in the thread pool in low-saturation scenarios (#132765) We made some changes in the threadpool to reduce spinning. In particular to reduce fruitless spinning - when a worker thread scanned through the work queue and found no work whatsoever. We would park such thread as a matter of throttling pointless scanning. The change helped in high saturation scenarios as reducing spurious scans reduces waste and lets other threads do useful work. Unfortunately, in some low-saturation scenarios those spurious scans were load bearing. In such scenarios some redundancy in terms of spurious scans must be tolerated to provide good latency. If we park workers too aggressively when we do not have many workers in the first place we will need to rely on waking them up to serve incoming requests. In a bursty case this could be a noticeable regression. In bursty ping-pong kind of scenario, if this happens on both the app and the client ends, the result could be amplified further. Here we are tuning the heuristic that parks threads after spurious scans to be enabled only when we have more than 2/3 of the proc count workers. There could be better ways to make use of this signal selectively and we should explore further. This is a simple enough change that we can do for net11. The change also increases allowed spin time and lowers the max delay between polls to cap the impact on latency from longer spin, if such happens. (it makes sense to have per iteration cap lower than the total, we had it the other way) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/System/Threading/Backoff.cs | 4 +- .../System/Threading/LowLevelLifoSemaphore.cs | 16 ++++++-- .../PortableThreadPool.WorkerThread.cs | 41 +++++++++++++++---- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.cs index 2d0d587df5d191..2377016e9b4163 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.cs @@ -15,8 +15,8 @@ internal static class Backoff // the exponential backoff will generally be not more than 2X worse than the perfect guess and // will do a lot less attempts than a simple retry. On multiprocessor machine fruitless attempts // will cause unnecessary sharing of the contended state which may make modifying the state more expensive. - // To protect against degenerate cases we will cap the per-iteration wait to 1-2 thousand spinwaits. - private const uint MaxExponentialBackoffBits = 10; + // To protect against degenerate cases we will cap the per-iteration wait to 2.2–4.4 microseconds. + private const uint MaxExponentialBackoffBits = 7; internal static unsafe int Exponential(uint attempt) { diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs index 258d90fb9ba800..446be68c4d1ea2 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs @@ -17,8 +17,10 @@ internal sealed partial class LowLevelLifoSemaphore { // The spin count is chosen to be in the range of typical thread wake latency and some additional overhead, // all assuming a single spin is calibrated to around 35 nanoseconds. - // The thread wake latency commonly measures at 2-10 microsecond (year 2026) and unlikely to drastically change. - private const int DefaultSemaphoreSpinCountLimit = 256; + // The thread wake latency commonly measures at ~10 microseconds (year 2026) and is unlikely to drastically change. + // But since the wakes are LIFO, the spin needs to survive additional overhead (we will need to take a lock, unlink the thread). + // So we limit the spin to about 35 microseconds. + private const int DefaultSemaphoreSpinCountLimit = 1024; // The cooldown roughly serves as detection that the thread did not spend time being blocked. // If it woke in under 4 microseconds, it was likely a fast/trivial wake without blocking. private const int DefaultWakeCooldown = 4; @@ -148,8 +150,9 @@ public bool WaitNoSpin(int timeoutMs) } // If we have signals and have waiters, we need to make sure at least one is waking. - // We wake one waiter at a time. If it finds work it will ask for workers and that can wake more waiters - // if other workers do not consume the additional signals. + // We wake one waiter at a time. If it finds a signal it will wake another worker, unless other workers consume + // the additional signals first. + // It is generally unusual to have > 1 signal. That only happens when the count of desired workers had a forced change. // In any case, we would prefer that extra signals be consumed by active workers, but must guarantee that signals // are consumed eventually thus we release waiters one by one. @@ -257,6 +260,11 @@ private bool WaitAsWaiter(int timeoutMs) if (counts.SignalCount != 0) { // success + + // If there are remaining signals, wake another waiter to ensure signals are eventually consumed. + // In a saturated pool there may be little new semaphore traffic, and we'd otherwise keep + // sleeping workers counted as running for too long. + MaybeWakeWaiter(newCounts); return true; } diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.cs index 4d26c0fe839f08..60153fe1ef6768 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.cs @@ -16,6 +16,8 @@ private static partial class WorkerThread { private static readonly short ThreadsToKeepAlive = DetermineThreadsToKeepAlive(); + private static readonly short SpuriousDispatchNoSpinThreshold = DetermineSpuriousDispatchNoSpinThreshold(); + // This value represents an assumption of how much uncommitted stack space a worker thread may use in the future. // Used in calculations to estimate when to throttle the rate of thread injection to reduce the possibility of // preexisting threads from running out of memory when using new stack space in low-memory situations. @@ -36,6 +38,23 @@ private static short DetermineThreadsToKeepAlive() return threadsToKeepAlive >= -1 ? threadsToKeepAlive : DefaultThreadsToKeepAlive; } + private static short DetermineSpuriousDispatchNoSpinThreshold() + { + // default to 2/3 of proc count. + // At more than this working threads we start parking threads after a spurious dispatch. + short DefaultSpuriousDispatchNoSpinThreshold = (short)(Environment.ProcessorCount * 2 / 3); + + // When a worker is invited to dispatch work items but finds none, it may park without spinning first. + // That is only preferable while more than this number of workers are still processing work and can take + // the next request. Set to 0 to park without spinning when at least one other worker is still processing work. + short threshold = + AppContextConfigHelper.GetInt16Config( + "System.Threading.ThreadPool.SpuriousDispatchNoSpinThreshold", + "DOTNET_ThreadPool_SpuriousDispatchNoSpinThreshold", + DefaultSpuriousDispatchNoSpinThreshold); + return threshold >= 0 ? threshold : DefaultSpuriousDispatchNoSpinThreshold; + } + /// /// Semaphore for controlling how many threads are currently working. /// @@ -122,7 +141,8 @@ private static void WorkerThreadStart() // returns true if the worker should Wait without spinning. private static bool WorkerDoWork(PortableThreadPool threadPoolInstance) { - bool noSpin; + bool spurious; + short numProcessingWork; do { @@ -137,8 +157,8 @@ private static bool WorkerDoWork(PortableThreadPool threadPoolInstance) switch (ThreadPoolWorkQueue.Dispatch()) { case ThreadPoolWorkQueue.DispatchResult.Spurious: - // We were invited but found no work. This is counterproductive. We should park. - noSpin = true; + // We were invited but found no work. This is counterproductive. We may want to park. + spurious = true; break; case ThreadPoolWorkQueue.DispatchResult.ShouldStop: @@ -149,7 +169,7 @@ private static bool WorkerDoWork(PortableThreadPool threadPoolInstance) default: // We did some work, but then there was nothing to do. // Spin a bit before parking in case we are invited back. - noSpin = false; + spurious = false; break; } } @@ -157,7 +177,7 @@ private static bool WorkerDoWork(PortableThreadPool threadPoolInstance) { // Not a common case. This can happen when worker goal was increased and invited extra threads. // We will spin in case there is work for all and another request will soon follow. - noSpin = false; + spurious = false; } // We could not find more work in the queue and will try to stop being active. @@ -165,9 +185,12 @@ private static bool WorkerDoWork(PortableThreadPool threadPoolInstance) // to come and see to it. Thus in Saturated state, one thread will clear the state and will come // back for another try to clear the thread request and do Dispatch - without consuming a signal. // See `TryIncrementProcessingWork` for details about Saturated state. - } while (!TryRemoveWorkingWorker(threadPoolInstance)); + } while (!TryRemoveWorkingWorker(threadPoolInstance, out numProcessingWork)); - return noSpin; + // Parking right away after a spurious dispatch is only worthwhile while other workers remain + // processing work and can take the next request. When few workers are left, the next request is + // likely to come to this thread, so it is cheaper to spin and stay available. + return spurious && numProcessingWork > SpuriousDispatchNoSpinThreshold; } // returns true if the worker is shutting down @@ -234,9 +257,10 @@ private static bool ShouldExitWorker(PortableThreadPool threadPoolInstance, LowL /// Tries to reduce the number of working workers by one. /// If we are in a Saturated state, clears the state instead and returns false. /// Returns true if number of active threads was actually reduced. + /// receives the resulting number of workers processing work. /// See `TryDecrementProcessingWork` for details about Saturated state. /// - private static bool TryRemoveWorkingWorker(PortableThreadPool threadPoolInstance) + private static bool TryRemoveWorkingWorker(PortableThreadPool threadPoolInstance, out short numProcessingWork) { uint collisionCount = 0; while (true) @@ -246,6 +270,7 @@ private static bool TryRemoveWorkingWorker(PortableThreadPool threadPoolInstance bool decremented = newCounts.TryDecrementProcessingWork(); if (threadPoolInstance._separated.counts.InterlockedCompareExchange(newCounts, oldCounts) == oldCounts) { + numProcessingWork = newCounts.NumProcessingWork; return decremented; }