Skip to content

Commit bb26341

Browse files
authored
perf: improve lock contention and async overhead (#3693)
* perf: improve lock contention and async overhead * refactor: streamline property injection logic by removing redundant comments
1 parent 28c2a68 commit bb26341

File tree

9 files changed

+36
-76
lines changed

9 files changed

+36
-76
lines changed

TUnit.Engine/Interfaces/ITestCoordinator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ namespace TUnit.Engine.Interfaces;
99
/// </summary>
1010
internal interface ITestCoordinator
1111
{
12-
Task ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken);
12+
ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken);
1313
}

TUnit.Engine/Scheduling/TestRunner.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ internal TestRunner(
3939
private readonly ThreadSafeDictionary<string, Task> _executingTests = new();
4040
private Exception? _firstFailFastException;
4141

42-
public async Task ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
42+
public async ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
4343
{
4444
// Prevent double execution with a simple lock
45-
var executionTask = _executingTests.GetOrAdd(test.TestId, _ => ExecuteTestInternalAsync(test, cancellationToken));
45+
var executionTask = _executingTests.GetOrAdd(test.TestId, _ => ExecuteTestInternalAsync(test, cancellationToken).AsTask());
4646
await executionTask.ConfigureAwait(false);
4747
}
4848

49-
private async Task ExecuteTestInternalAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
49+
private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
5050
{
5151
try
5252
{

TUnit.Engine/Scheduling/TestScheduler.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ private async Task ExecuteWithGlobalLimitAsync(
420420

421421
try
422422
{
423-
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken);
423+
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken).AsTask();
424424
await test.ExecutionTask.ConfigureAwait(false);
425425
}
426426
finally
@@ -458,9 +458,7 @@ await Parallel.ForEachAsync(
458458

459459
try
460460
{
461-
#pragma warning disable IL2026 // ExecuteTestAsync uses reflection, but caller (ExecuteWithGlobalLimitAsync) is already marked with RequiresUnreferencedCode
462-
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct);
463-
#pragma warning restore IL2026
461+
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct).AsTask();
464462
await test.ExecutionTask.ConfigureAwait(false);
465463
}
466464
finally
@@ -485,9 +483,7 @@ await Parallel.ForEachAsync(
485483
},
486484
async (test, ct) =>
487485
{
488-
#pragma warning disable IL2026 // ExecuteTestAsync uses reflection, but caller (ExecuteWithGlobalLimitAsync) is already marked with RequiresUnreferencedCode
489-
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct);
490-
#pragma warning restore IL2026
486+
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct).AsTask();
491487
await test.ExecutionTask.ConfigureAwait(false);
492488
}
493489
).ConfigureAwait(false);

TUnit.Engine/Services/DataSourceInitializer.cs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ namespace TUnit.Engine.Services;
1212
/// </summary>
1313
internal sealed class DataSourceInitializer
1414
{
15-
private readonly Dictionary<object, Task> _initializationTasks = new();
16-
private readonly object _lock = new();
15+
private readonly ConcurrentDictionary<object, Lazy<Task>> _initializationTasks = new();
1716
private PropertyInjectionService? _propertyInjectionService;
1817

1918
/// <summary>
@@ -42,30 +41,22 @@ public async Task<T> EnsureInitializedAsync<T>(
4241
}
4342

4443
// Check if already initialized or being initialized
45-
Task? existingTask;
46-
lock (_lock)
47-
{
48-
if (_initializationTasks.TryGetValue(dataSource, out existingTask))
49-
{
50-
// Already initialized or being initialized
51-
}
52-
else
53-
{
54-
// Start initialization
55-
existingTask = InitializeDataSourceAsync(dataSource, objectBag, methodMetadata, events, cancellationToken);
56-
_initializationTasks[dataSource] = existingTask;
57-
}
58-
}
44+
// Use Lazy<Task> to ensure only one initialization task is created per data source (thread-safe)
45+
var lazyTask = _initializationTasks.GetOrAdd(
46+
dataSource,
47+
_ => new Lazy<Task>(() => InitializeDataSourceAsync(dataSource, objectBag, methodMetadata, events, cancellationToken)));
48+
49+
var task = lazyTask.Value;
5950

6051
// Wait for initialization with cancellation support
6152
if (cancellationToken.CanBeCanceled)
6253
{
63-
await existingTask.ConfigureAwait(false);
54+
await task.ConfigureAwait(false);
6455
cancellationToken.ThrowIfCancellationRequested();
6556
}
6657
else
6758
{
68-
await existingTask.ConfigureAwait(false);
59+
await task.ConfigureAwait(false);
6960
}
7061

7162
return dataSource;
@@ -221,9 +212,6 @@ private void CollectNestedObjects(
221212
/// </summary>
222213
public void ClearCache()
223214
{
224-
lock (_lock)
225-
{
226-
_initializationTasks.Clear();
227-
}
215+
_initializationTasks.Clear();
228216
}
229217
}

TUnit.Engine/Services/PropertyInjectionService.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ public async Task InjectPropertiesIntoArgumentsAsync(object?[] arguments, Concur
4040
return;
4141
}
4242

43-
// Fast path: check if any arguments need injection
4443
var injectableArgs = arguments
4544
.Where(argument => argument != null && PropertyInjectionCache.HasInjectableProperties(argument.GetType()))
4645
.ToArray();
@@ -50,7 +49,6 @@ public async Task InjectPropertiesIntoArgumentsAsync(object?[] arguments, Concur
5049
return;
5150
}
5251

53-
// Process arguments in parallel
5452
var argumentTasks = injectableArgs
5553
.Select(argument => InjectPropertiesIntoObjectAsync(argument!, objectBag, methodMetadata, events))
5654
.ToArray();
@@ -80,7 +78,6 @@ public Task InjectPropertiesIntoObjectAsync(object instance, ConcurrentDictionar
8078
throw new ArgumentNullException(nameof(events), "TestContextEvents must not be null. Each test permutation must have a unique TestContextEvents instance for proper disposal tracking.");
8179
}
8280

83-
// Start with an empty visited set for cycle detection
8481
#if NETSTANDARD2_0
8582
var visitedObjects = new ConcurrentDictionary<object, byte>();
8683
#else
@@ -96,8 +93,7 @@ internal async Task InjectPropertiesIntoObjectAsyncCore(object instance, Concurr
9693
return;
9794
}
9895

99-
// Prevent cycles - if we're already processing this object, skip it
100-
// TryAdd returns false if the key already exists (thread-safe)
96+
// Prevent cycles
10197
if (!visitedObjects.TryAdd(instance, 0))
10298
{
10399
return;
@@ -117,13 +113,11 @@ await PropertyInjectionCache.GetOrAddInjectionTask(instance, async _ =>
117113
{
118114
var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType());
119115

120-
// Use the orchestrator for property initialization
121116
await _orchestrator.InitializeObjectWithPropertiesAsync(
122117
instance, plan, objectBag, methodMetadata, events, visitedObjects);
123118
});
124119
}
125120

126-
// After properties are initialized, recursively inject nested properties
127121
await RecurseIntoNestedPropertiesAsync(instance, objectBag, methodMetadata, events, visitedObjects);
128122
}
129123
catch (Exception ex)
Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,26 @@
1+
using System.Collections.Concurrent;
2+
13
namespace TUnit.Engine.Services.TestExecution;
24

35
/// <summary>
46
/// Thread-safe object pool for HashSet instances used during test execution.
57
/// Single Responsibility: Managing pooled HashSet objects to reduce allocations.
8+
/// Uses lock-free concurrent collections for high-performance parallel test execution.
69
/// </summary>
710
internal sealed class HashSetPool
811
{
9-
private readonly Dictionary<Type, object> _pools = new();
10-
private readonly object _lock = new();
12+
private readonly ConcurrentDictionary<Type, ConcurrentBag<object>> _pools = new();
1113

1214
public HashSet<T> Rent<T>()
1315
{
1416
var type = typeof(T);
17+
var bag = _pools.GetOrAdd(type, _ => new ConcurrentBag<object>());
1518

16-
lock (_lock)
19+
if (bag.TryTake(out var pooledSet))
1720
{
18-
if (!_pools.TryGetValue(type, out var poolObj))
19-
{
20-
poolObj = new Stack<HashSet<T>>();
21-
_pools[type] = poolObj;
22-
}
23-
24-
var pool = (Stack<HashSet<T>>)poolObj;
25-
26-
if (pool.Count > 0)
27-
{
28-
var set = pool.Pop();
29-
set.Clear();
30-
return set;
31-
}
21+
var set = (HashSet<T>)pooledSet;
22+
set.Clear();
23+
return set;
3224
}
3325

3426
return [];
@@ -37,19 +29,9 @@ public HashSet<T> Rent<T>()
3729
public void Return<T>(HashSet<T> set)
3830
{
3931
var type = typeof(T);
32+
set.Clear();
4033

41-
lock (_lock)
42-
{
43-
set.Clear();
44-
45-
if (!_pools.TryGetValue(type, out var poolObj))
46-
{
47-
poolObj = new Stack<HashSet<T>>();
48-
_pools[type] = poolObj;
49-
}
50-
51-
var pool = (Stack<HashSet<T>>)poolObj;
52-
pool.Push(set);
53-
}
34+
var bag = _pools.GetOrAdd(type, _ => new ConcurrentBag<object>());
35+
bag.Add(set);
5436
}
5537
}

TUnit.Engine/Services/TestExecution/TestCoordinator.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,13 @@ public TestCoordinator(
5050
_hashSetPool = hashSetPool;
5151
}
5252

53-
public async Task ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
53+
public async ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
5454
{
5555
await _executionGuard.TryStartExecutionAsync(test.TestId,
56-
() => ExecuteTestInternalAsync(test, cancellationToken));
56+
() => ExecuteTestInternalAsync(test, cancellationToken).AsTask());
5757
}
5858

59-
private async Task ExecuteTestInternalAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
59+
private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
6060
{
6161
try
6262
{

TUnit.Engine/TestExecutor.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(() =>
5151
/// Creates a test executor delegate that wraps the provided executor with hook orchestration.
5252
/// Uses focused services that follow SRP to manage lifecycle and execution.
5353
/// </summary>
54-
public async Task ExecuteAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
54+
public async ValueTask ExecuteAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
5555
{
5656

5757
var testClass = executableTest.Metadata.TestClassType;
@@ -148,7 +148,7 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
148148
}
149149
}
150150

151-
private static async Task ExecuteTestAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
151+
private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
152152
{
153153
// Skip the actual test invocation for skipped tests
154154
if (executableTest.Context.Metadata.TestDetails.ClassInstance is SkippedTestInstance ||

TUnit.Engine/TestInitializer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public TestInitializer(EventReceiverOrchestrator eventReceiverOrchestrator,
2121
_objectTracker = objectTracker;
2222
}
2323

24-
public async Task InitializeTest(AbstractExecutableTest test, CancellationToken cancellationToken)
24+
public async ValueTask InitializeTest(AbstractExecutableTest test, CancellationToken cancellationToken)
2525
{
2626
var testClassInstance = test.Context.Metadata.TestDetails.ClassInstance;
2727

0 commit comments

Comments
 (0)