Repository navigation
feat(engine): raise thread pool minimum when blocking tests starve the pool #7030
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
8f8da2a
feat(engine): raise thread pool minimum when blocking tests starve th…
thomhurst 127165e
feat(settings): add Execution.ThreadPoolTuningEnabled
thomhurst d175009
fix(engine): harden thread pool starvation monitor per review
thomhurst 2b5515b
fix(engine): make starvation detection jitter-tolerant and avoid per-…
thomhurst e97f2e3
fix(engine): clamp starvation monitor cap and log raises
thomhurst 83b29ea
fix(engine): escalate starvation raise and detect saturation by busy …
thomhurst 476c856
fix(engine): clamp starvation monitor raise to the pool maximum
thomhurst 162ea8c
chore(engine): document starvation monitor assumptions and log why it…
thomhurst 9a54757
fix(engine): keep starvation monitoring when the minimum already meet…
thomhurst 3eb4412
test(engine): keep escalation tests valid on runners with 3 or fewer …
thomhurst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
216 changes: 216 additions & 0 deletions
216
src/TUnit.Engine/Scheduling/ThreadPoolStarvationMonitor.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| using TUnit.Core.Logging; | ||
| #if NET | ||
| using System.Diagnostics; | ||
| using System.Runtime.Versioning; | ||
| using TUnit.Engine.Configuration; | ||
| #endif | ||
|
|
||
| namespace TUnit.Engine.Scheduling; | ||
|
|
||
| /// <summary> | ||
| /// Raises the thread pool's minimum worker thread count while tests run, but only once the pool | ||
| /// is starved by blocked threads. | ||
| /// <para> | ||
| /// Test bodies run inline on pool threads. When concurrently running tests block (Thread.Sleep, | ||
| /// synchronous I/O, sync-over-async), the pool adds threads beyond its minimum (ProcessorCount by | ||
| /// default) at only one or two per second. Timer callbacks — Task.Delay, CancelAfter and TUnit's own | ||
| /// [Timeout] — also need a pool thread, so starvation makes timing-sensitive tests in other classes | ||
| /// overshoot their deadlines. | ||
| /// </para> | ||
| /// <para> | ||
| /// A static minimum is not free: extra pool threads spin while searching for work and slow down | ||
| /// suites of many short tests. The monitor therefore raises the minimum only after a probe work | ||
| /// item waits a whole sample interval for a thread while the process leaves most CPUs idle — the | ||
| /// signature of blocked, not busy, threads. Pending-item counts alone are not enough: the engine's | ||
| /// own short work items keep a small queue at low CPU without any test blocking. | ||
| /// </para> | ||
| /// </summary> | ||
| internal sealed class ThreadPoolStarvationMonitor : IDisposable | ||
| { | ||
| #if NET | ||
| private static readonly TimeSpan SampleInterval = TimeSpan.FromMilliseconds(50); | ||
|
|
||
| // Below this share of total CPU capacity, queued work is waiting on blocked threads rather | ||
| // than on busy ones, so adding threads helps instead of oversubscribing the cores. | ||
| internal const double StarvedCpuUtilization = 0.5; | ||
|
|
||
| // Never disposed: the sampling thread may still be waiting on it after Dispose returns. | ||
| private readonly ManualResetEventSlim _stopSignal = new(false); | ||
| private readonly Lock _lock = new(); | ||
| private readonly ILogger _logger; | ||
| private readonly int _originalMinWorkerThreads; | ||
| private readonly int _maxWorkerThreads; | ||
| private int _raisedMinWorkerThreads; | ||
| private bool _stopped; | ||
| // Timestamp of the queued probe work item, or 0 once a pool thread has run it. | ||
| private long _probeQueuedAt; | ||
|
|
||
| private ThreadPoolStarvationMonitor(int originalMinWorkerThreads, int maxWorkerThreads, ILogger logger) | ||
| { | ||
| _originalMinWorkerThreads = originalMinWorkerThreads; | ||
| _maxWorkerThreads = maxWorkerThreads; | ||
| _logger = logger; | ||
| } | ||
| #endif | ||
|
|
||
| /// <summary> | ||
| /// Starts monitoring, or returns <c>null</c> when monitoring cannot help or is disabled. | ||
| /// </summary> | ||
| public static ThreadPoolStarvationMonitor? Start(int maxParallelism, ILogger logger) | ||
| { | ||
| #if NET | ||
| if (OperatingSystem.IsBrowser()) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| if (IsTruthyEnv(Environment.GetEnvironmentVariable(EnvironmentConstants.DisableThreadPoolTuning))) | ||
| { | ||
| logger.LogDebug($"Thread pool starvation monitor disabled by {EnvironmentConstants.DisableThreadPoolTuning}"); | ||
| return null; | ||
| } | ||
|
|
||
| ThreadPool.GetMinThreads(out var originalMinWorkerThreads, out _); | ||
|
|
||
| // Headroom above the test limit for timer callbacks, the dynamic test queue and engine work. | ||
| var maxWorkerThreads = (int)Math.Min((long)maxParallelism + Environment.ProcessorCount, short.MaxValue); | ||
|
|
||
| if (originalMinWorkerThreads >= maxWorkerThreads) | ||
| { | ||
| // The user already configured enough threads. | ||
| return null; | ||
| } | ||
|
|
||
| var monitor = new ThreadPoolStarvationMonitor(originalMinWorkerThreads, maxWorkerThreads, logger); | ||
|
|
||
| // A dedicated thread: the monitor must keep sampling while every pool thread is blocked. | ||
| var thread = new Thread(monitor.Run) | ||
| { | ||
| IsBackground = true, | ||
| Name = "TUnit thread pool starvation monitor", | ||
| }; | ||
| thread.Start(); | ||
|
thomhurst marked this conversation as resolved.
Resolved
|
||
| return monitor; | ||
| #else | ||
| // ThreadPool.PendingWorkItemCount is unavailable; the runtime's own thread injection applies. | ||
| return null; | ||
| #endif | ||
| } | ||
|
|
||
| #if NET | ||
| [UnsupportedOSPlatform("browser")] | ||
| private void Run() | ||
| { | ||
| var processorCount = Environment.ProcessorCount; | ||
| var previousCpuTime = GetProcessCpuTime(); | ||
| var previousTimestamp = Stopwatch.GetTimestamp(); | ||
|
|
||
| while (!_stopSignal.Wait(SampleInterval)) | ||
| { | ||
| var cpuTime = GetProcessCpuTime(); | ||
| var timestamp = Stopwatch.GetTimestamp(); | ||
|
|
||
| var elapsedMs = Stopwatch.GetElapsedTime(previousTimestamp, timestamp).TotalMilliseconds; | ||
| var cpuUtilization = elapsedMs <= 0 | ||
| ? 1 | ||
| : (cpuTime - previousCpuTime).TotalMilliseconds / (elapsedMs * processorCount); | ||
|
|
||
| var probeQueuedAt = Volatile.Read(ref _probeQueuedAt); | ||
|
|
||
| if (probeQueuedAt == 0) | ||
| { | ||
| // The global queue is where timer callbacks wait for a thread, so a probe queued | ||
| // there measures how late a Task.Delay or [Timeout] continuation would run. | ||
| Volatile.Write(ref _probeQueuedAt, timestamp); | ||
| ThreadPool.UnsafeQueueUserWorkItem(static monitor => Volatile.Write(ref monitor._probeQueuedAt, 0), this, preferLocal: false); | ||
| } | ||
| else if (IsStarved(Stopwatch.GetElapsedTime(probeQueuedAt, timestamp), cpuUtilization)) | ||
| { | ||
| // One raise to the cap is all the monitor does, so it stops sampling afterwards. | ||
| Raise(); | ||
| return; | ||
| } | ||
|
|
||
| previousCpuTime = cpuTime; | ||
| previousTimestamp = timestamp; | ||
| } | ||
| } | ||
|
|
||
| private void Raise() | ||
| { | ||
| lock (_lock) | ||
| { | ||
| if (_stopped) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Raise straight to the cap in one step. When threads are blocked in Task.Wait or .Result, | ||
| // the runtime's cooperative-blocking logic takes over the raise and adds threads only | ||
| // under narrow conditions. Repeated small raises then left the pool stuck below its new | ||
| // minimum for seconds, but a single raise reliably adds the threads. | ||
| ThreadPool.GetMinThreads(out var currentMinWorkerThreads, out var currentMinIoThreads); | ||
|
|
||
| if (currentMinWorkerThreads < _maxWorkerThreads | ||
| && ThreadPool.SetMinThreads(_maxWorkerThreads, currentMinIoThreads)) | ||
| { | ||
| _raisedMinWorkerThreads = _maxWorkerThreads; | ||
|
thomhurst marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| [UnsupportedOSPlatform("browser")] | ||
| private static TimeSpan GetProcessCpuTime() | ||
| { | ||
| #if NET9_0_OR_GREATER | ||
| return Environment.CpuUsage.TotalTime; | ||
| #else | ||
| using var process = Process.GetCurrentProcess(); | ||
| return process.TotalProcessorTime; | ||
|
thomhurst marked this conversation as resolved.
Resolved
|
||
| #endif | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Queued work that waits a whole sample interval for a thread while most CPUs are idle means | ||
| /// pool threads are blocked rather than busy. | ||
| /// </summary> | ||
| internal static bool IsStarved(TimeSpan probeWait, double cpuUtilization) | ||
| => probeWait >= SampleInterval && cpuUtilization < StarvedCpuUtilization; | ||
|
|
||
| private static bool IsTruthyEnv(string? value) | ||
| => value is not null | ||
| && (value == "1" | ||
| || value.Equals("true", StringComparison.OrdinalIgnoreCase) | ||
| || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); | ||
| #endif | ||
|
|
||
| public void Dispose() | ||
| { | ||
| #if NET | ||
| lock (_lock) | ||
| { | ||
| if (_stopped) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _stopped = true; | ||
| _stopSignal.Set(); | ||
|
|
||
| if (_raisedMinWorkerThreads == 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Leave the pool as we found it unless user code changed the minimum in the meantime. | ||
| ThreadPool.GetMinThreads(out var currentMinWorkerThreads, out var currentMinIoThreads); | ||
| if (currentMinWorkerThreads == _raisedMinWorkerThreads) | ||
| { | ||
| ThreadPool.SetMinThreads(_originalMinWorkerThreads, currentMinIoThreads); | ||
|
thomhurst marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| _logger.LogDebug($"Thread pool starvation detected while tests ran; minimum worker threads were raised from {_originalMinWorkerThreads} to {_raisedMinWorkerThreads}"); | ||
| #endif | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| #if NET | ||
| using System.Text; | ||
| using TUnit.Engine.Helpers; | ||
| using TUnit.Engine.Scheduling; | ||
|
|
||
| namespace TUnit.UnitTests; | ||
|
|
||
| public class ThreadPoolStarvationMonitorTests | ||
| { | ||
| [Test] | ||
| [Arguments(60, 0.1, true)] | ||
| [Arguments(50, 0.49, true)] | ||
| [Arguments(10, 0.1, false)] | ||
| [Arguments(60, 0.9, false)] | ||
| public async Task Starved_Only_When_Probe_Waits_And_Cpus_Are_Idle(int probeWaitMs, double cpuUtilization, bool expected) | ||
| { | ||
| await Assert.That(ThreadPoolStarvationMonitor.IsStarved(TimeSpan.FromMilliseconds(probeWaitMs), cpuUtilization)) | ||
| .IsEqualTo(expected); | ||
|
thomhurst marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| [Test] | ||
| [Arguments(10, 0, false)] | ||
| [Arguments(10, 9, false)] | ||
| [Arguments(10, 10, true)] | ||
| [Arguments(10, 500, true)] | ||
| public async Task Detects_Saturated_Thread_Pool(int threadCount, long pendingWorkItems, bool expected) | ||
| { | ||
| await Assert.That(TimeoutDiagnostics.IsThreadPoolSaturated(threadCount, pendingWorkItems)).IsEqualTo(expected); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Timeout_Diagnostics_Include_Thread_Pool_Snapshot_And_Saturation_Hint() | ||
| { | ||
| var sb = new StringBuilder(); | ||
| TimeoutDiagnostics.AppendThreadPoolDiagnostics(sb, threadCount: 4, pendingWorkItems: 50); | ||
| var message = sb.ToString(); | ||
|
|
||
| await Assert.That(message).Contains("--- Thread Pool ---"); | ||
| await Assert.That(message).Contains("Threads: 4, pending work items: 50"); | ||
| await Assert.That(message).Contains("thread pool appears saturated"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Timeout_Diagnostics_Omit_Saturation_Hint_When_Pool_Is_Idle() | ||
| { | ||
| var sb = new StringBuilder(); | ||
| TimeoutDiagnostics.AppendThreadPoolDiagnostics(sb, threadCount: 4, pendingWorkItems: 0); | ||
|
|
||
| await Assert.That(sb.ToString()).DoesNotContain("saturated"); | ||
| } | ||
| } | ||
| #endif | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.