Skip to content

Commit 0d3301d

Browse files
github-actions[bot]VSadovCopilot
authored
[release/11.0] Increase spinning/polling aggressiveness in the thread pool in low-saturation scenarios (#132908)
Backport of #132765 to release/11.0 /cc @VSadov ## Customer Impact - [ ] Customer reported - [x] Found internally Too aggressive parking of threadpool threads in response to spurious wake ups may result in having too few active workers in bursty low saturation scenarios and cause massive regressions. Some tests like Websockets show nearly 4x less RPS compared to net10. ## Regression - [x] Yes - [ ] No Introduced in #128606 The change resultied in improvements in high saturation throughput-sensitive scenarios. It was later discovered that in low staturation latency-sensitive scenarios it resulted in regressions. ## Testing The fix was validated with various benchmarks both high-saturation and low, on both x64 and arm. - Websockets - JSON (high, low, mid connection and client thread count) - Orchard - Nuget restore RPS tests ## Risk Low. This is an intentionally small change. The new parking policy will only be enabled when the threadpool has more than 2/3 proc count workers. Co-authored-by: Vladimir Sadov <vsadov@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 761b329 commit 0d3301d

3 files changed

Lines changed: 47 additions & 14 deletions

File tree

‎src/libraries/System.Private.CoreLib/src/System/Threading/Backoff.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ internal static class Backoff
1515
// the exponential backoff will generally be not more than 2X worse than the perfect guess and
1616
// will do a lot less attempts than a simple retry. On multiprocessor machine fruitless attempts
1717
// will cause unnecessary sharing of the contended state which may make modifying the state more expensive.
18-
// To protect against degenerate cases we will cap the per-iteration wait to 1-2 thousand spinwaits.
19-
private const uint MaxExponentialBackoffBits = 10;
18+
// To protect against degenerate cases we will cap the per-iteration wait to 2.2–4.4 microseconds.
19+
private const uint MaxExponentialBackoffBits = 7;
2020

2121
internal static unsafe int Exponential(uint attempt)
2222
{

‎src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ internal sealed partial class LowLevelLifoSemaphore
1717
{
1818
// The spin count is chosen to be in the range of typical thread wake latency and some additional overhead,
1919
// all assuming a single spin is calibrated to around 35 nanoseconds.
20-
// The thread wake latency commonly measures at 2-10 microsecond (year 2026) and unlikely to drastically change.
21-
private const int DefaultSemaphoreSpinCountLimit = 256;
20+
// The thread wake latency commonly measures at ~10 microseconds (year 2026) and is unlikely to drastically change.
21+
// But since the wakes are LIFO, the spin needs to survive additional overhead (we will need to take a lock, unlink the thread).
22+
// So we limit the spin to about 35 microseconds.
23+
private const int DefaultSemaphoreSpinCountLimit = 1024;
2224
// The cooldown roughly serves as detection that the thread did not spend time being blocked.
2325
// If it woke in under 4 microseconds, it was likely a fast/trivial wake without blocking.
2426
private const int DefaultWakeCooldown = 4;
@@ -148,8 +150,9 @@ public bool WaitNoSpin(int timeoutMs)
148150
}
149151

150152
// If we have signals and have waiters, we need to make sure at least one is waking.
151-
// We wake one waiter at a time. If it finds work it will ask for workers and that can wake more waiters
152-
// if other workers do not consume the additional signals.
153+
// We wake one waiter at a time. If it finds a signal it will wake another worker, unless other workers consume
154+
// the additional signals first.
155+
153156
// It is generally unusual to have > 1 signal. That only happens when the count of desired workers had a forced change.
154157
// In any case, we would prefer that extra signals be consumed by active workers, but must guarantee that signals
155158
// are consumed eventually thus we release waiters one by one.
@@ -257,6 +260,11 @@ private bool WaitAsWaiter(int timeoutMs)
257260
if (counts.SignalCount != 0)
258261
{
259262
// success
263+
264+
// If there are remaining signals, wake another waiter to ensure signals are eventually consumed.
265+
// In a saturated pool there may be little new semaphore traffic, and we'd otherwise keep
266+
// sleeping workers counted as running for too long.
267+
MaybeWakeWaiter(newCounts);
260268
return true;
261269
}
262270

‎src/libraries/System.Private.CoreLib/src/System/Threading/PortableThreadPool.WorkerThread.cs‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ private static partial class WorkerThread
1616
{
1717
private static readonly short ThreadsToKeepAlive = DetermineThreadsToKeepAlive();
1818

19+
private static readonly short SpuriousDispatchNoSpinThreshold = DetermineSpuriousDispatchNoSpinThreshold();
20+
1921
// This value represents an assumption of how much uncommitted stack space a worker thread may use in the future.
2022
// Used in calculations to estimate when to throttle the rate of thread injection to reduce the possibility of
2123
// preexisting threads from running out of memory when using new stack space in low-memory situations.
@@ -36,6 +38,23 @@ private static short DetermineThreadsToKeepAlive()
3638
return threadsToKeepAlive >= -1 ? threadsToKeepAlive : DefaultThreadsToKeepAlive;
3739
}
3840

41+
private static short DetermineSpuriousDispatchNoSpinThreshold()
42+
{
43+
// default to 2/3 of proc count.
44+
// At more than this working threads we start parking threads after a spurious dispatch.
45+
short DefaultSpuriousDispatchNoSpinThreshold = (short)(Environment.ProcessorCount * 2 / 3);
46+
47+
// When a worker is invited to dispatch work items but finds none, it may park without spinning first.
48+
// That is only preferable while more than this number of workers are still processing work and can take
49+
// the next request. Set to 0 to park without spinning when at least one other worker is still processing work.
50+
short threshold =
51+
AppContextConfigHelper.GetInt16Config(
52+
"System.Threading.ThreadPool.SpuriousDispatchNoSpinThreshold",
53+
"DOTNET_ThreadPool_SpuriousDispatchNoSpinThreshold",
54+
DefaultSpuriousDispatchNoSpinThreshold);
55+
return threshold >= 0 ? threshold : DefaultSpuriousDispatchNoSpinThreshold;
56+
}
57+
3958
/// <summary>
4059
/// Semaphore for controlling how many threads are currently working.
4160
/// </summary>
@@ -122,7 +141,8 @@ private static void WorkerThreadStart()
122141
// returns true if the worker should Wait without spinning.
123142
private static bool WorkerDoWork(PortableThreadPool threadPoolInstance)
124143
{
125-
bool noSpin;
144+
bool spurious;
145+
short numProcessingWork;
126146

127147
do
128148
{
@@ -137,8 +157,8 @@ private static bool WorkerDoWork(PortableThreadPool threadPoolInstance)
137157
switch (ThreadPoolWorkQueue.Dispatch())
138158
{
139159
case ThreadPoolWorkQueue.DispatchResult.Spurious:
140-
// We were invited but found no work. This is counterproductive. We should park.
141-
noSpin = true;
160+
// We were invited but found no work. This is counterproductive. We may want to park.
161+
spurious = true;
142162
break;
143163

144164
case ThreadPoolWorkQueue.DispatchResult.ShouldStop:
@@ -149,25 +169,28 @@ private static bool WorkerDoWork(PortableThreadPool threadPoolInstance)
149169
default:
150170
// We did some work, but then there was nothing to do.
151171
// Spin a bit before parking in case we are invited back.
152-
noSpin = false;
172+
spurious = false;
153173
break;
154174
}
155175
}
156176
else
157177
{
158178
// Not a common case. This can happen when worker goal was increased and invited extra threads.
159179
// We will spin in case there is work for all and another request will soon follow.
160-
noSpin = false;
180+
spurious = false;
161181
}
162182

163183
// We could not find more work in the queue and will try to stop being active.
164184
// One caveat - in Saturated state we have seen a thread request but could not signal for a worker
165185
// to come and see to it. Thus in Saturated state, one thread will clear the state and will come
166186
// back for another try to clear the thread request and do Dispatch - without consuming a signal.
167187
// See `TryIncrementProcessingWork` for details about Saturated state.
168-
} while (!TryRemoveWorkingWorker(threadPoolInstance));
188+
} while (!TryRemoveWorkingWorker(threadPoolInstance, out numProcessingWork));
169189

170-
return noSpin;
190+
// Parking right away after a spurious dispatch is only worthwhile while other workers remain
191+
// processing work and can take the next request. When few workers are left, the next request is
192+
// likely to come to this thread, so it is cheaper to spin and stay available.
193+
return spurious && numProcessingWork > SpuriousDispatchNoSpinThreshold;
171194
}
172195

173196
// returns true if the worker is shutting down
@@ -234,9 +257,10 @@ private static bool ShouldExitWorker(PortableThreadPool threadPoolInstance, LowL
234257
/// Tries to reduce the number of working workers by one.
235258
/// If we are in a Saturated state, clears the state instead and returns false.
236259
/// Returns true if number of active threads was actually reduced.
260+
/// <paramref name="numProcessingWork"/> receives the resulting number of workers processing work.
237261
/// See `TryDecrementProcessingWork` for details about Saturated state.
238262
/// </summary>
239-
private static bool TryRemoveWorkingWorker(PortableThreadPool threadPoolInstance)
263+
private static bool TryRemoveWorkingWorker(PortableThreadPool threadPoolInstance, out short numProcessingWork)
240264
{
241265
uint collisionCount = 0;
242266
while (true)
@@ -246,6 +270,7 @@ private static bool TryRemoveWorkingWorker(PortableThreadPool threadPoolInstance
246270
bool decremented = newCounts.TryDecrementProcessingWork();
247271
if (threadPoolInstance._separated.counts.InterlockedCompareExchange(newCounts, oldCounts) == oldCounts)
248272
{
273+
numProcessingWork = newCounts.NumProcessingWork;
249274
return decremented;
250275
}
251276

0 commit comments

Comments
 (0)