Skip to content

Commit 88a51fa

Browse files
authored
perf: optimize asynchronous event handling with lock-free operations and immutable collections (#3535)
* refactor: optimize asynchronous event handling with lock-free operations and immutable collections * refactor: optimize collection handling and reduce allocations in test identifier generation * refactor: streamline event receiver orchestration and improve task management with array pooling * refactor: optimize AsyncEvent handling by removing locks and improving invocation management * refactor: update AsyncEvent class to replace Order property with InvocationList and new callback methods
1 parent 5a05731 commit 88a51fa

20 files changed

+394
-371
lines changed

TUnit.Assertions/Conditions/Helpers/CollectionEquivalencyChecker.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ public static CheckResult AreEquivalent<TItem>(
3434
return CheckResult.Failure("collection was null");
3535
}
3636

37-
var actualList = actual.ToList();
38-
var expectedList = expected.ToList();
37+
// Optimize for collections that are already lists to avoid re-enumeration
38+
var actualList = actual is List<TItem> actualListCasted ? actualListCasted : actual.ToList();
39+
var expectedList = expected is List<TItem> expectedListCasted ? expectedListCasted : expected.ToList();
3940

4041
// Check counts first
4142
if (actualList.Count != expectedList.Count)

TUnit.Core/AsyncEvent.cs

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,85 @@
1-
using TUnit.Core.Interfaces;
1+
using TUnit.Core.Interfaces;
22

33
namespace TUnit.Core;
44

55
public class AsyncEvent<TEventArgs>
66
{
7-
public int Order
8-
{
9-
get;
10-
set
11-
{
12-
field = value;
13-
14-
if (InvocationList.Count > 0)
15-
{
16-
InvocationList[^1].Order = field;
17-
}
18-
}
19-
} = int.MaxValue / 2;
20-
21-
internal List<Invocation> InvocationList { get; } = [];
22-
23-
private static readonly Lock _newEventLock = new();
24-
private readonly Lock _locker = new();
7+
private List<Invocation>? _handlers;
258

269
public class Invocation(Func<object, TEventArgs, ValueTask> factory, int order) : IEventReceiver
2710
{
28-
public int Order
29-
{
30-
get;
31-
internal set;
32-
} = order;
11+
public int Order { get; } = order;
3312

3413
public async ValueTask InvokeAsync(object sender, TEventArgs eventArgs)
3514
{
3615
await factory(sender, eventArgs);
3716
}
3817
}
3918

40-
public static AsyncEvent<TEventArgs> operator +(
41-
AsyncEvent<TEventArgs>? e, Func<object, TEventArgs, ValueTask> callback
42-
)
19+
public void Add(Func<object, TEventArgs, ValueTask> callback, int order = int.MaxValue / 2)
4320
{
4421
if (callback == null)
4522
{
46-
throw new NullReferenceException("callback is null");
23+
throw new ArgumentNullException(nameof(callback));
4724
}
4825

49-
lock (_newEventLock)
50-
{
51-
e ??= new AsyncEvent<TEventArgs>();
52-
}
26+
var invocation = new Invocation(callback, order);
27+
var insertIndex = FindInsertionIndex(order);
28+
(_handlers ??= []).Insert(insertIndex, invocation);
29+
}
5330

54-
lock (e._locker)
31+
public void AddAt(Func<object, TEventArgs, ValueTask> callback, int index, int order = int.MaxValue / 2)
32+
{
33+
if (callback == null)
5534
{
56-
e.InvocationList.Add(new Invocation(callback, e.Order));
57-
e.Order = int.MaxValue / 2;
35+
throw new ArgumentNullException(nameof(callback));
5836
}
5937

60-
return e;
38+
var invocation = new Invocation(callback, order);
39+
var handlers = _handlers ??= [];
40+
var clampedIndex = index < 0 ? 0 : (index > handlers.Count ? handlers.Count : index);
41+
handlers.Insert(clampedIndex, invocation);
6142
}
6243

63-
public AsyncEvent<TEventArgs> InsertAtFront(Func<object, TEventArgs, ValueTask> callback)
44+
public IReadOnlyList<Invocation> InvocationList
6445
{
65-
if (callback == null)
46+
get
6647
{
67-
throw new NullReferenceException("callback is null");
68-
}
48+
if (_handlers == null)
49+
{
50+
return [];
51+
}
52+
53+
return _handlers;
6954

70-
lock (_locker)
71-
{
72-
InvocationList.Insert(0, new Invocation(callback, Order));
73-
Order = int.MaxValue / 2;
7455
}
56+
}
7557

58+
public AsyncEvent<TEventArgs> InsertAtFront(Func<object, TEventArgs, ValueTask> callback)
59+
{
60+
AddAt(callback, 0);
7661
return this;
7762
}
63+
64+
public static AsyncEvent<TEventArgs> operator +(
65+
AsyncEvent<TEventArgs>? e, Func<object, TEventArgs, ValueTask> callback)
66+
{
67+
e ??= new AsyncEvent<TEventArgs>();
68+
e.Add(callback);
69+
return e;
70+
}
71+
72+
private int FindInsertionIndex(int order)
73+
{
74+
int left = 0, right = (_handlers ??= []).Count;
75+
while (left < right)
76+
{
77+
var mid = left + (right - left) / 2;
78+
if (_handlers[mid].Order <= order)
79+
left = mid + 1;
80+
else
81+
right = mid;
82+
}
83+
return left;
84+
}
7885
}

TUnit.Core/DataGeneratorMetadataCreator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static DataGeneratorMetadata CreateDataGeneratorMetadata(
2222
// Filter out CancellationToken if it's the last parameter (handled by the engine)
2323
if (generatorType == DataGeneratorType.TestParameters && parametersToGenerate.Length > 0)
2424
{
25-
var lastParam = parametersToGenerate[parametersToGenerate.Length - 1];
25+
var lastParam = parametersToGenerate[^1];
2626
if (lastParam.Type == typeof(CancellationToken))
2727
{
2828
var newArray = new ParameterMetadata[parametersToGenerate.Length - 1];
@@ -244,7 +244,7 @@ private static ParameterMetadata[] FilterOutCancellationToken(ParameterMetadata[
244244
{
245245
if (parameters.Length > 0)
246246
{
247-
var lastParam = parameters[parameters.Length - 1];
247+
var lastParam = parameters[^1];
248248
if (lastParam.Type == typeof(CancellationToken))
249249
{
250250
var newArray = new ParameterMetadata[parameters.Length - 1];

TUnit.Core/TUnit.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
6464
<PackageReference Include="Microsoft.CSharp" />
6565
<PackageReference Include="System.Text.Json" />
66+
<PackageReference Include="System.Collections.Immutable" />
6667
</ItemGroup>
6768
<ItemGroup>
6869
<Compile Remove="IClassDataSourceAttribute.cs" />

TUnit.Engine/Building/TestBuilder.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -813,16 +813,15 @@ public async Task<AbstractExecutableTest> BuildTestAsync(TestMetadata metadata,
813813
private static string? GetBasicSkipReason(TestMetadata metadata, Attribute[]? cachedAttributes = null)
814814
{
815815
var attributes = cachedAttributes ?? metadata.AttributeFactory();
816-
var skipAttributes = attributes.OfType<SkipAttribute>().ToList();
816+
var skipAttributes = attributes.OfType<SkipAttribute>();
817817

818-
if (skipAttributes.Count == 0)
819-
{
820-
return null; // No skip attributes
821-
}
818+
SkipAttribute? firstSkipAttribute = null;
822819

823820
// Check if all skip attributes are basic (non-derived) SkipAttribute instances
824821
foreach (var skipAttribute in skipAttributes)
825822
{
823+
firstSkipAttribute ??= skipAttribute;
824+
826825
var attributeType = skipAttribute.GetType();
827826
if (attributeType != typeof(SkipAttribute))
828827
{
@@ -832,8 +831,8 @@ public async Task<AbstractExecutableTest> BuildTestAsync(TestMetadata metadata,
832831
}
833832

834833
// All skip attributes are basic SkipAttribute instances
835-
// Return the first reason (they all should skip)
836-
return skipAttributes[0].Reason;
834+
// Return the first reason (they all should skip), or null if no skip attributes
835+
return firstSkipAttribute?.Reason;
837836
}
838837

839838

TUnit.Engine/Capabilities/StopExecutionCapability.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public async Task StopTestExecutionAsync(CancellationToken cancellationToken)
1515

1616
if (OnStopRequested != null)
1717
{
18-
foreach (var invocation in OnStopRequested.InvocationList.OrderBy(x => x.Order))
18+
foreach (var invocation in OnStopRequested.InvocationList)
1919
{
2020
await invocation.InvokeAsync(this, EventArgs.Empty);
2121
}

TUnit.Engine/ConcurrentHashSet.cs

Lines changed: 14 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,34 @@
1-
namespace TUnit.Engine;
1+
using System.Collections.Concurrent;
22

3-
internal class ConcurrentHashSet<T>
4-
{
5-
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
6-
private readonly HashSet<T> _hashSet = [];
3+
namespace TUnit.Engine;
74

8-
#region Implementation of ICollection<T> ...ish
5+
/// <summary>
6+
/// Thread-safe hash set implementation using ConcurrentDictionary for better performance.
7+
/// Provides lock-free reads and fine-grained locking for writes.
8+
/// </summary>
9+
internal class ConcurrentHashSet<T> where T : notnull
10+
{
11+
private readonly ConcurrentDictionary<T, byte> _dictionary = new();
912

1013
public bool Add(T item)
1114
{
12-
_lock.EnterWriteLock();
13-
14-
try
15-
{
16-
return _hashSet.Add(item);
17-
}
18-
finally
19-
{
20-
if (_lock.IsWriteLockHeld)
21-
{
22-
_lock.ExitWriteLock();
23-
}
24-
}
15+
return _dictionary.TryAdd(item, 0);
2516
}
2617

2718
public void Clear()
2819
{
29-
_lock.EnterWriteLock();
30-
31-
try
32-
{
33-
_hashSet.Clear();
34-
}
35-
finally
36-
{
37-
if (_lock.IsWriteLockHeld)
38-
{
39-
_lock.ExitWriteLock();
40-
}
41-
}
20+
_dictionary.Clear();
4221
}
4322

4423
public bool Contains(T item)
4524
{
46-
_lock.EnterReadLock();
47-
48-
try
49-
{
50-
return _hashSet.Contains(item);
51-
}
52-
finally
53-
{
54-
if (_lock.IsReadLockHeld)
55-
{
56-
_lock.ExitReadLock();
57-
}
58-
}
25+
return _dictionary.ContainsKey(item);
5926
}
6027

6128
public bool Remove(T item)
6229
{
63-
_lock.EnterWriteLock();
64-
65-
try
66-
{
67-
return _hashSet.Remove(item);
68-
}
69-
finally
70-
{
71-
if (_lock.IsWriteLockHeld)
72-
{
73-
_lock.ExitWriteLock();
74-
}
75-
}
76-
}
77-
78-
public int Count
79-
{
80-
get
81-
{
82-
_lock.EnterReadLock();
83-
84-
try
85-
{
86-
return _hashSet.Count;
87-
}
88-
finally
89-
{
90-
if (_lock.IsReadLockHeld)
91-
{
92-
_lock.ExitReadLock();
93-
}
94-
}
95-
}
96-
}
97-
98-
#endregion
99-
100-
#region Dispose
101-
102-
public void Dispose()
103-
{
104-
Dispose(true);
105-
GC.SuppressFinalize(this);
106-
}
107-
108-
protected virtual void Dispose(bool disposing)
109-
{
110-
if (disposing)
111-
{
112-
_lock.Dispose();
113-
}
114-
}
115-
116-
~ConcurrentHashSet()
117-
{
118-
Dispose(false);
30+
return _dictionary.TryRemove(item, out _);
11931
}
12032

121-
#endregion
33+
public int Count => _dictionary.Count;
12234
}

TUnit.Engine/Discovery/ReflectionTestMetadata.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,16 @@ async Task<object> CreateInstance(TestContext testContext)
8181

8282
// Create test invoker with CancellationToken support
8383
// Determine if the test method has a CancellationToken parameter
84-
var parameterTypes = MethodMetadata.Parameters.Select(static p => p.Type).ToArray();
85-
var hasCancellationToken = parameterTypes.Any(t => t == typeof(CancellationToken));
86-
var cancellationTokenIndex = hasCancellationToken
87-
? Array.IndexOf(parameterTypes, typeof(CancellationToken))
88-
: -1;
84+
var cancellationTokenIndex = -1;
85+
for (var i = 0; i < MethodMetadata.Parameters.Length; i++)
86+
{
87+
if (MethodMetadata.Parameters[i].Type == typeof(CancellationToken))
88+
{
89+
cancellationTokenIndex = i;
90+
break;
91+
}
92+
}
93+
var hasCancellationToken = cancellationTokenIndex != -1;
8994

9095
Func<object, object?[], TestContext, CancellationToken, Task> invokeTest = async (instance, args, testContext, cancellationToken) =>
9196
{

0 commit comments

Comments
 (0)