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; }