Description
Background and Motivation
The ConditionalWeakTable<TKey, TValue>
is an extremely useful type that allows developers to leverage GC dependent handles (which are not directly available), along with some other nice features on top (eg. auto trimming, thread-safety by default, etc.). There is currently one drawback though when trying to use this type in code that's trying to be as fast and memory efficient as possible, which is that it is not possible to enumerate key-value pairs in a ConditionalWeakTable<TKey, TValue>
table without incurring in unwanted allocations and extra overhead. Specifically:
- The
GetEnumerator
implementation returns a reference type enumerator, incurring in one allocation - The enumerator type also has a finalizer, which makes the allocation slower even though it's not actually needed in most cases (given that when using
foreach
,Dispose()
is always called anyway, which suppresses the finalizer).
Proposed API
This proposal is to expose a value-based enumerator, allowing developers to enumerate key-value pairs in ConditionalWeakTable<TKey, TValue>
instances without incurring in any allocations at all, and with also some nice performance improvements on top (which are just a natural consequence of moving to a value-based enumerator in this case and removing the two performance penalties mentioned before).
The proposed API is this one:
namespace System.Runtime.CompilerServices
{
public sealed class ConditionalWeakTable<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
where TKey : class
where TValue : class?
{
+ public Enumerator GetEnumerator();
+ public ref struct Enumerator
+ {
+ public KeyValuePair<TKey, TValue> Current { get; }
+
+ public bool MoveNext();
+ public void Dispose();
+ }
}
}
This is perfectly safe when used through the foreach
pattern, given that the C# compiler will still generate the same calls as when using a normal iterator, with the same try/finally
blocks and the final Dispose()
call (which is needed here to decrement the internal reference count for the ConditionalWeakTable<TKey, TValue>
instance).
I'd be happy to contribute this feature myself in case the proposal was approved 🙌
Usage Examples
We're using ConditionalWeakTable<TKey, TValue>
in the WeakReferenceMessenger
type in the MVVM Toolkit. Currently this type incurs in allocations when broadcasting messages solely for the Enumerator
type being instantiated every single time. As a test, I've hacked the DependentHandle
type in a benchmark project (by defining it in a dummy System.Private.CoreLib
project and using [IgnoreAccessChecksTo]
and PrivateAssets="All"
so that the MVVM Toolkit would load the true one from CoreCLR instead, see here, and then I copied the ConditionalWeakTable<TKey, TValue>
source to it while adding the new API mentioned above to it. I then proceeded to run the same benchmark (see here) across the original implementation of WeakReferenceMessenger
, and one using this modified ConditionalWeakTable<TKey, TValue>
implementation:
Method | Mean | Error | StdDev | Ratio | Allocated |
---|---|---|---|---|---|
Original | 21.79 ms | 0.296 ms | 0.277 ms | 1.00 | 192,029 B |
Improved | 18.66 ms | 0.216 ms | 0.191 ms | 0.86 | - |
✅ Completely allocation free broadcast
✅ About 15% faster too while at it, which doesn't hurt
I reckon there might be many other scenarios where library authors are using ConditionalWeakTable<TKey, TValue>
as well where the ability to iterate key-value pairs without incurring in extra allocations would be very welcome as well.
Alternative Designs
While this would still be perfectly safe under normal usage, I understand that it's still technically slightly less safe than the normal enumerator, given that developers could in theory (but also, why?) do weird stuff like copying the enumerator, use it manually outside of a foreach
block, etc. To that I have two possible answers:
- The
ConditionalWeakTable<TKey, TValue>
is already in theSystem.Runtime.CompilerServices
namespace, which is intrinsically considered "unsafe" and "use at your own risk" in general, so it's assumed that developers using this type (or any of the others in this namespace in general) would know what they're doing. Because of this, I think adding this new API directly onConditionalWeakTable<TKey, TValue>
would be fine. - In case this proposed API was still considered not safe enough to add to
ConditionalWeakTable<TKey, TValue>
directly, an alternative proposal could be to just move the API toCollectionMarshal
instead. This would be clunkier (the enumerator type would have to either be moved too or just not be usable directly fromConditionalWeakTable<TKey, TValue>
, and it'd also need a newGetEnumerator()
method on it just returningthis
, in order to be usable in aforeach
block), but in turn it'd entirely separate this slightly more unsafe API into this new special-purposed type (which also contains a bunch of other unsafe APIs for other collection types).
I'd personally argue for 1) given the above (and especially because it'd make the new enumerator much less verbose to use), but at the end of the day I'd be happy with either as long as the core functionality is present, which is true in both cases 😄
Risks
Very low, as mentioned above. It'd be a niche API on an already niche type, in a niche namespace that's already being used to host all sorts of weird and unsafe APIs. Developers ever finding this should already be very much aware of how to use it properly (especially given that we would also include detailed XML docs to call out all the various potential pitfalls for incorrect usage).
Additional context
For reference, as mentioned above, we could use this API to completely remove memory allocations in our WeakReferenceMessenger
implementation in the MVVM Toolkit (part of the Windows Community Toolkit). We particularly focused on speed and efficiency in our messenger types, which are currently far ahead of all other available MVVM libraries (eg. Prism, Caliburn, etc.). Here's some benchmarks with our current implementations:
BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.1023 (20H2/October2020Update)
AMD Ryzen 7 2700X, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100-preview.4.21255.9
[Host] : .NET 5.0.6 (5.0.621.22011), X64 RyuJIT
DefaultJob : .NET 5.0.6 (5.0.621.22011), X64 RyuJIT
Method | Mean | Error | Ratio | Gen 0 | Allocated |
---|---|---|---|---|---|
MVVMToolkitStrong | 6.428 ms | 0.0108 ms | 0.08 | - | - |
MVVMToolkitWeak | 20.883 ms | 0.3085 ms | 0.25 | 31.2500 | 192,009 B |
MVVMLight | 82.511 ms | 1.5461 ms | 1.00 | 10857.1429 | 45,920,081 B |
Prism | 216.294 ms | 2.1281 ms | 2.62 | 18000.0000 | 76,096,445 B |
CaliburnMicro | 465.634 ms | 2.7784 ms | 5.65 | 95000.0000 | 397,825,336 B |
This proposed API would allow us to both completely eliminate allocations in the broadcast method for the weak messenger, as well as reducing the performance gap between our two messenger types (cc. @michael-hawker, Windows Community Toolkit lead).