Skip to content

Commit 49a88c7

Browse files
author
msftbot[bot]
authored
Memory usage improvements/optimizations to Messenger (#3424)
## Follow up for #3230, part of #3428 <!-- Add the relevant issue number after the "#" mentioned above (for ex: Fixes #1234) which will automatically close the issue once the PR is merged. --> <!-- Add a brief overview here of the feature/bug & fix. --> ## PR Type What kind of change does this PR introduce? <!-- Please uncomment one or more that apply to this PR. --> - Optimization <!-- - Bugfix --> <!-- - Feature --> <!-- - Code style update (formatting) --> <!-- - Refactoring (no functional changes, no api changes) --> <!-- - Build or CI related changes --> <!-- - Documentation content changes --> <!-- - Sample app changes --> <!-- - Other... Please describe: --> ## Overview This PR includes two main parts: an improvement for the memory pooling buffers, and a refactor of how delegates are registered for message handlers. This last part allows to completely remove local closures for message handlers 🚀 ### Memory pooling improvements We're using memory pooling (by relying on the `ArrayPool<T>` APIs) in the `Messenger` class to achieve an amortized 0-allocation execution of the `Send` method in particular (`UnregisterAll` uses this too). We have code like this: https://github.com/windows-toolkit/WindowsCommunityToolkit/blob/5bf426523cf456fc13db7b6505c56cad380d5f5f/Microsoft.Toolkit.Mvvm/Messaging/Messenger.cs#L250 https://github.com/windows-toolkit/WindowsCommunityToolkit/blob/5bf426523cf456fc13db7b6505c56cad380d5f5f/Microsoft.Toolkit.Mvvm/Messaging/Messenger.cs#L401 This works just fine, and we do get the 0-allocation after the initial first invocation where we rent the buffer. There are two downsides here though: - In the `Send` method, we rent an `Action<TMessage>` buffer, so we'll rent a different buffer for every message type - Given that these types are either internal or not likely to ever by used by consumers of the library, all these rented buffers would only ever by used by the `Messenger` class itself, and the consumers would not be able to reuse them on their own. Neither of these points is a big deal and the current implementation is fine, but I think we can do better 😄 **Idea:** let's leverage the fact that arrays are covariant, and only use a single type to solve both problems, like so: ```csharp object[] maps = ArrayPool<object>.Shared.Rent(count); // Do stuff, example: foreach (object obj in maps) { var myActualItem = (SomeFancyTypePossiblyInternal)obj; // Do stuff with the item... } ``` This both allows us to just use `object[]` arrays, which both reduces the total number of rented arrays (as we can reuse them for different message types), and also makes it so that these rented arrays might potentially also be reused by consumers of the library (should they ever need to pool `object[]` arrays), further reducing allocations 🚀 ## Benchmarks Here are some benchmarks comparing the `Messenger` from this PR with the one in the `Preview1`. ### Sending messages | Method | Categories | Mean | Error | StdDev | Ratio | Gen 0 | Gen 1 | Allocated | |--------- |----------------- |-------------:|----------:|----------:|------:|------:|------:|----------:| | Old_Send | DefaultChannel | 161.7 us | 1.52 us | 1.43 us | 1.00 | - | - | - | | **New_Send** | DefaultChannel | **153.9 us** | 1.62 us | 1.43 us | 0.95 | - | - | - | | | | | | | | | | | | Old_Send | MultipleChannels | 148,668.7 us | 886.65 us | 785.99 us | 1.00 | - | - | 336 B | | **New_Send** | MultipleChannels | **138,825.0 us** | 797.61 us | 746.08 us | 0.93 | - | - | 336 B | Performance when sending message is slightly faster than before. Worst case scenario, it's not any slower. ### Registering messages | Method | Mean | Error | StdDev | Ratio | Gen 0 | Gen 1 | Allocated | |------------- |---------:|--------:|--------:|------:|---------:|--------:|----------:| | Old_Register | 443.6 us | 1.73 us | 1.53 us | 1.00 | 113.7695 | 25.3906 | 581.53 KB | | **New_Register** | **386.1 us** | 2.47 us | 2.31 us | 0.87 | 82.5195 | 4.3945 | **381.53 KB** | The new version is **13%** faster when registering messages, and uses **34%** less memory 🚀 ### Enabled recipient type-specific handlers One major annoyance for users working with manually registered handlers was the fact that type information was lost. As in, recipients were registered as just `object` instances, as it was necessary to cast them in the handler every time. This PR also changes this by adding support for a type parameter to specify the recipient type. This enables the following change (which is totally optional anyway, you can still just use `object` if you want): ```csharp // Before Messenger.Register<MyMessage>(this, (s, m) => ((MyViewModel)s).SomeMethod(m)); // After Messenger.Register<MyViewModel, MyMessage>(this, (s, m) => s.SomeMethod(m)); ``` ### Removed local closures The original implementation used `Action<T>` for handlers, which caused closures to be constantly created whenever the users wanted to access any local member on the registered recipient. This was because the recipient itself needed to be captured too to be accessed from the handlers. This detail also made it more difficult for other devs to implement `IMessenger` if they wanted to use a weak reference system. I've replaced that handler type with a custom delegate, called `MessageHandler`: https://github.com/windows-toolkit/WindowsCommunityToolkit/blob/32656db1cdd4ddc25e3a88c297ce4062fe64d2ad/Microsoft.Toolkit.Mvvm/Messaging/IMessenger.cs#L10-L22 This delegate also receives the target recipient as input, which allows developers to just use that to access local members, without the need to create closures. The handlers are now essentially static, so the C# compiler can cache the whole delegate too. This is especially useful for the `IRecipient<TMessage>` pattern, as that was previously creating unnecessary closures when registering handlers - that's completely gone now and delegates are cached there as well 🎉 For instance, you can see that here: https://github.com/windows-toolkit/WindowsCommunityToolkit/blob/32656db1cdd4ddc25e3a88c297ce4062fe64d2ad/Microsoft.Toolkit.Mvvm/Messaging/IMessengerExtensions.cs#L175-L179 We're now using that cached static delegate (will be able to also explicitly mark that as static when C# 9 lands, but it already is) instead of the method group syntax on `recipient.Receive`, which allocated a closure for each invocation. ## PR Checklist Please check if your PR fulfills the following requirements: - [X] Tested code with current [supported SDKs](../readme.md#supported) - [ ] ~~Pull Request has been submitted to the documentation repository [instructions](..\contributing.md#docs). Link: <!-- docs PR link -->~~ - [ ] ~~Sample in sample app has been added / updated (for bug fixes / features)~~ - [ ] ~~Icon has been created (if new sample) following the [Thumbnail Style Guide and templates](https://github.com/windows-toolkit/WindowsCommunityToolkit-design-assets)~~ - [X] Tests for the changes have been added (for bug fixes / features) (if applicable) - [X] Header has been added to all new source files (run *build/UpdateHeaders.bat*) - [X] Contains **NO** breaking changes
2 parents 05d77c4 + 72d4319 commit 49a88c7

20 files changed

+1450
-435
lines changed

Microsoft.Toolkit.Mvvm/ComponentModel/ObservableRecipient.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ public abstract class ObservableRecipient : ObservableObject
2525
/// Initializes a new instance of the <see cref="ObservableRecipient"/> class.
2626
/// </summary>
2727
/// <remarks>
28-
/// This constructor will produce an instance that will use the <see cref="Messaging.Messenger.Default"/> instance
28+
/// This constructor will produce an instance that will use the <see cref="WeakReferenceMessenger.Default"/> instance
2929
/// to perform requested operations. It will also be available locally through the <see cref="Messenger"/> property.
3030
/// </remarks>
3131
protected ObservableRecipient()
32-
: this(Messaging.Messenger.Default)
32+
: this(WeakReferenceMessenger.Default)
3333
{
3434
}
3535

@@ -78,7 +78,7 @@ public bool IsActive
7878
/// <remarks>
7979
/// The base implementation registers all messages for this recipients that have been declared
8080
/// explicitly through the <see cref="IRecipient{TMessage}"/> interface, using the default channel.
81-
/// For more details on how this works, see the <see cref="MessengerExtensions.RegisterAll"/> method.
81+
/// For more details on how this works, see the <see cref="IMessengerExtensions.RegisterAll"/> method.
8282
/// If you need more fine tuned control, want to register messages individually or just prefer
8383
/// the lambda-style syntax for message registration, override this method and register manually.
8484
/// </remarks>

Microsoft.Toolkit.Mvvm/Messaging/IMessenger.cs

+74-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,70 @@
77

88
namespace Microsoft.Toolkit.Mvvm.Messaging
99
{
10+
/// <summary>
11+
/// A <see langword="delegate"/> used to represent actions to invoke when a message is received.
12+
/// The recipient is given as an input argument to allow message registrations to avoid creating
13+
/// closures: if an instance method on a recipient needs to be invoked it is possible to just
14+
/// cast the recipient to the right type and then access the local method from that instance.
15+
/// </summary>
16+
/// <typeparam name="TRecipient">The type of recipient for the message.</typeparam>
17+
/// <typeparam name="TMessage">The type of message to receive.</typeparam>
18+
/// <param name="recipient">The recipient that is receiving the message.</param>
19+
/// <param name="message">The message being received.</param>
20+
public delegate void MessageHandler<in TRecipient, in TMessage>(TRecipient recipient, TMessage message)
21+
where TRecipient : class
22+
where TMessage : class;
23+
1024
/// <summary>
1125
/// An interface for a type providing the ability to exchange messages between different objects.
26+
/// This can be useful to decouple different modules of an application without having to keep strong
27+
/// references to types being referenced. It is also possible to send messages to specific channels, uniquely
28+
/// identified by a token, and to have different messengers in different sections of an applications.
29+
/// In order to use the <see cref="IMessenger"/> functionalities, first define a message type, like so:
30+
/// <code>
31+
/// public sealed class LoginCompletedMessage { }
32+
/// </code>
33+
/// Then, register your a recipient for this message:
34+
/// <code>
35+
/// Messenger.Default.Register&lt;MyRecipientType, LoginCompletedMessage&gt;(this, (r, m) =>
36+
/// {
37+
/// // Handle the message here...
38+
/// });
39+
/// </code>
40+
/// The message handler here is a lambda expression taking two parameters: the recipient and the message.
41+
/// This is done to avoid the allocations for the closures that would've been generated if the expression
42+
/// had captured the current instance. The recipient type parameter is used so that the recipient can be
43+
/// directly accessed within the handler without the need to manually perform type casts. This allows the
44+
/// code to be less verbose and more reliable, as all the checks are done just at build time. If the handler
45+
/// is defined within the same type as the recipient, it is also possible to directly access private members.
46+
/// This allows the message handler to be a static method, which enables the C# compiler to perform a number
47+
/// of additional memory optimizations (such as caching the delegate, avoiding unnecessary memory allocations).
48+
/// Finally, send a message when needed, like so:
49+
/// <code>
50+
/// Messenger.Default.Send&lt;LoginCompletedMessage&gt;();
51+
/// </code>
52+
/// Additionally, the method group syntax can also be used to specify the message handler
53+
/// to invoke when receiving a message, if a method with the right signature is available
54+
/// in the current scope. This is helpful to keep the registration and handling logic separate.
55+
/// Following up from the previous example, consider a class having this method:
56+
/// <code>
57+
/// private static void Receive(MyRecipientType recipient, LoginCompletedMessage message)
58+
/// {
59+
/// // Handle the message there
60+
/// }
61+
/// </code>
62+
/// The registration can then be performed in a single line like so:
63+
/// <code>
64+
/// Messenger.Default.Register(this, Receive);
65+
/// </code>
66+
/// The C# compiler will automatically convert that expression to a <see cref="MessageHandler{TRecipient,TMessage}"/> instance
67+
/// compatible with <see cref="IMessengerExtensions.Register{TRecipient,TMessage}(IMessenger,TRecipient,MessageHandler{TRecipient,TMessage})"/>.
68+
/// This will also work if multiple overloads of that method are available, each handling a different
69+
/// message type: the C# compiler will automatically pick the right one for the current message type.
70+
/// It is also possible to register message handlers explicitly using the <see cref="IRecipient{TMessage}"/> interface.
71+
/// To do so, the recipient just needs to implement the interface and then call the
72+
/// <see cref="IMessengerExtensions.RegisterAll(IMessenger,object)"/> extension, which will automatically register
73+
/// all the handlers that are declared by the recipient type. Registration for individual handlers is supported as well.
1274
/// </summary>
1375
public interface IMessenger
1476
{
@@ -28,13 +90,15 @@ bool IsRegistered<TMessage, TToken>(object recipient, TToken token)
2890
/// <summary>
2991
/// Registers a recipient for a given type of message.
3092
/// </summary>
93+
/// <typeparam name="TRecipient">The type of recipient for the message.</typeparam>
3194
/// <typeparam name="TMessage">The type of message to receive.</typeparam>
3295
/// <typeparam name="TToken">The type of token to use to pick the messages to receive.</typeparam>
3396
/// <param name="recipient">The recipient that will receive the messages.</param>
3497
/// <param name="token">A token used to determine the receiving channel to use.</param>
35-
/// <param name="action">The <see cref="Action{T}"/> to invoke when a message is received.</param>
98+
/// <param name="handler">The <see cref="MessageHandler{TRecipient,TMessage}"/> to invoke when a message is received.</param>
3699
/// <exception cref="InvalidOperationException">Thrown when trying to register the same message twice.</exception>
37-
void Register<TMessage, TToken>(object recipient, TToken token, Action<TMessage> action)
100+
void Register<TRecipient, TMessage, TToken>(TRecipient recipient, TToken token, MessageHandler<TRecipient, TMessage> handler)
101+
where TRecipient : class
38102
where TMessage : class
39103
where TToken : IEquatable<TToken>;
40104

@@ -83,6 +147,14 @@ TMessage Send<TMessage, TToken>(TMessage message, TToken token)
83147
where TMessage : class
84148
where TToken : IEquatable<TToken>;
85149

150+
/// <summary>
151+
/// Performs a cleanup on the current messenger.
152+
/// Invoking this method does not unregister any of the currently registered
153+
/// recipient, and it can be used to perform cleanup operations such as
154+
/// trimming the internal data structures of a messenger implementation.
155+
/// </summary>
156+
void Cleanup();
157+
86158
/// <summary>
87159
/// Resets the <see cref="IMessenger"/> instance and unregisters all the existing recipients.
88160
/// </summary>

Microsoft.Toolkit.Mvvm/Messaging/MessengerExtensions.cs renamed to Microsoft.Toolkit.Mvvm/Messaging/IMessengerExtensions.cs

+43-8
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,20 @@
88
using System.Linq.Expressions;
99
using System.Reflection;
1010
using System.Runtime.CompilerServices;
11+
using Microsoft.Toolkit.Mvvm.Messaging.Internals;
1112

1213
namespace Microsoft.Toolkit.Mvvm.Messaging
1314
{
1415
/// <summary>
1516
/// Extensions for the <see cref="IMessenger"/> type.
1617
/// </summary>
17-
public static partial class MessengerExtensions
18+
public static class IMessengerExtensions
1819
{
1920
/// <summary>
2021
/// A class that acts as a container to load the <see cref="MethodInfo"/> instance linked to
2122
/// the <see cref="Register{TMessage,TToken}(IMessenger,IRecipient{TMessage},TToken)"/> method.
2223
/// This class is needed to avoid forcing the initialization code in the static constructor to run as soon as
23-
/// the <see cref="MessengerExtensions"/> type is referenced, even if that is done just to use methods
24+
/// the <see cref="IMessengerExtensions"/> type is referenced, even if that is done just to use methods
2425
/// that do not actually require this <see cref="MethodInfo"/> instance to be available.
2526
/// We're effectively using this type to leverage the lazy loading of static constructors done by the runtime.
2627
/// </summary>
@@ -32,7 +33,7 @@ private static class MethodInfos
3233
static MethodInfos()
3334
{
3435
RegisterIRecipient = (
35-
from methodInfo in typeof(MessengerExtensions).GetMethods()
36+
from methodInfo in typeof(IMessengerExtensions).GetMethods()
3637
where methodInfo.Name == nameof(Register) &&
3738
methodInfo.IsGenericMethod &&
3839
methodInfo.GetGenericArguments().Length == 2
@@ -174,7 +175,7 @@ static Action<IMessenger, object, TToken> GetRegistrationAction(Type type, Metho
174175
public static void Register<TMessage>(this IMessenger messenger, IRecipient<TMessage> recipient)
175176
where TMessage : class
176177
{
177-
messenger.Register<TMessage, Unit>(recipient, default, recipient.Receive);
178+
messenger.Register<IRecipient<TMessage>, TMessage, Unit>(recipient, default, (r, m) => r.Receive(m));
178179
}
179180

180181
/// <summary>
@@ -191,7 +192,7 @@ public static void Register<TMessage, TToken>(this IMessenger messenger, IRecipi
191192
where TMessage : class
192193
where TToken : IEquatable<TToken>
193194
{
194-
messenger.Register<TMessage, TToken>(recipient, token, recipient.Receive);
195+
messenger.Register<IRecipient<TMessage>, TMessage, TToken>(recipient, token, (r, m) => r.Receive(m));
195196
}
196197

197198
/// <summary>
@@ -200,13 +201,47 @@ public static void Register<TMessage, TToken>(this IMessenger messenger, IRecipi
200201
/// <typeparam name="TMessage">The type of message to receive.</typeparam>
201202
/// <param name="messenger">The <see cref="IMessenger"/> instance to use to register the recipient.</param>
202203
/// <param name="recipient">The recipient that will receive the messages.</param>
203-
/// <param name="action">The <see cref="Action{T}"/> to invoke when a message is received.</param>
204+
/// <param name="handler">The <see cref="MessageHandler{TRecipient,TMessage}"/> to invoke when a message is received.</param>
204205
/// <exception cref="InvalidOperationException">Thrown when trying to register the same message twice.</exception>
205206
/// <remarks>This method will use the default channel to perform the requested registration.</remarks>
206-
public static void Register<TMessage>(this IMessenger messenger, object recipient, Action<TMessage> action)
207+
public static void Register<TMessage>(this IMessenger messenger, object recipient, MessageHandler<object, TMessage> handler)
207208
where TMessage : class
208209
{
209-
messenger.Register(recipient, default(Unit), action);
210+
messenger.Register(recipient, default(Unit), handler);
211+
}
212+
213+
/// <summary>
214+
/// Registers a recipient for a given type of message.
215+
/// </summary>
216+
/// <typeparam name="TRecipient">The type of recipient for the message.</typeparam>
217+
/// <typeparam name="TMessage">The type of message to receive.</typeparam>
218+
/// <param name="messenger">The <see cref="IMessenger"/> instance to use to register the recipient.</param>
219+
/// <param name="recipient">The recipient that will receive the messages.</param>
220+
/// <param name="handler">The <see cref="MessageHandler{TRecipient,TMessage}"/> to invoke when a message is received.</param>
221+
/// <exception cref="InvalidOperationException">Thrown when trying to register the same message twice.</exception>
222+
/// <remarks>This method will use the default channel to perform the requested registration.</remarks>
223+
public static void Register<TRecipient, TMessage>(this IMessenger messenger, TRecipient recipient, MessageHandler<TRecipient, TMessage> handler)
224+
where TRecipient : class
225+
where TMessage : class
226+
{
227+
messenger.Register(recipient, default(Unit), handler);
228+
}
229+
230+
/// <summary>
231+
/// Registers a recipient for a given type of message.
232+
/// </summary>
233+
/// <typeparam name="TMessage">The type of message to receive.</typeparam>
234+
/// <typeparam name="TToken">The type of token to use to pick the messages to receive.</typeparam>
235+
/// <param name="messenger">The <see cref="IMessenger"/> instance to use to register the recipient.</param>
236+
/// <param name="recipient">The recipient that will receive the messages.</param>
237+
/// <param name="token">A token used to determine the receiving channel to use.</param>
238+
/// <param name="handler">The <see cref="MessageHandler{TRecipient,TMessage}"/> to invoke when a message is received.</param>
239+
/// <exception cref="InvalidOperationException">Thrown when trying to register the same message twice.</exception>
240+
public static void Register<TMessage, TToken>(this IMessenger messenger, object recipient, TToken token, MessageHandler<object, TMessage> handler)
241+
where TMessage : class
242+
where TToken : IEquatable<TToken>
243+
{
244+
messenger.Register(recipient, token, handler);
210245
}
211246

212247
/// <summary>

Microsoft.Toolkit.Mvvm/Messaging/Microsoft.Collections.Extensions/DictionarySlim{TKey,TValue}.cs renamed to Microsoft.Toolkit.Mvvm/Messaging/Internals/Microsoft.Collections.Extensions/DictionarySlim{TKey,TValue}.cs

+17-9
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,11 @@ public void Clear()
130130
this.entries = InitialEntries;
131131
}
132132

133-
/// <inheritdoc cref="Dictionary{TKey,TValue}.ContainsKey"/>
133+
/// <summary>
134+
/// Checks whether or not the dictionary contains a pair with a specified key.
135+
/// </summary>
136+
/// <param name="key">The key to look for.</param>
137+
/// <returns>Whether or not the key was present in the dictionary.</returns>
134138
public bool ContainsKey(TKey key)
135139
{
136140
Entry[] entries = this.entries;
@@ -176,7 +180,18 @@ public bool TryGetValue(TKey key, out TValue? value)
176180
}
177181

178182
/// <inheritdoc/>
179-
public bool TryRemove(TKey key, out object? result)
183+
public bool TryRemove(TKey key)
184+
{
185+
return TryRemove(key, out _);
186+
}
187+
188+
/// <summary>
189+
/// Tries to remove a value with a specified key, if present.
190+
/// </summary>
191+
/// <param name="key">The key of the value to remove.</param>
192+
/// <param name="result">The removed value, if it was present.</param>
193+
/// <returns>Whether or not the key was present.</returns>
194+
public bool TryRemove(TKey key, out TValue? result)
180195
{
181196
Entry[] entries = this.entries;
182197
int bucketIndex = key.GetHashCode() & (this.buckets.Length - 1);
@@ -218,13 +233,6 @@ public bool TryRemove(TKey key, out object? result)
218233
return false;
219234
}
220235

221-
/// <inheritdoc/>
222-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
223-
public bool Remove(TKey key)
224-
{
225-
return TryRemove(key, out _);
226-
}
227-
228236
/// <summary>
229237
/// Gets the value for the specified key, or, if the key is not present,
230238
/// adds an entry and returns the value by ref. This makes it possible to

Microsoft.Toolkit.Mvvm/Messaging/Microsoft.Collections.Extensions/IDictionarySlim{TKey}.cs renamed to Microsoft.Toolkit.Mvvm/Messaging/Internals/Microsoft.Collections.Extensions/IDictionarySlim{TKey}.cs

+3-11
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,10 @@ internal interface IDictionarySlim<in TKey> : IDictionarySlim
1414
where TKey : IEquatable<TKey>
1515
{
1616
/// <summary>
17-
/// Tries to remove a value with a specified key.
17+
/// Tries to remove a value with a specified key, if present.
1818
/// </summary>
1919
/// <param name="key">The key of the value to remove.</param>
20-
/// <param name="result">The removed value, if it was present.</param>
21-
/// <returns>.Whether or not the key was present.</returns>
22-
bool TryRemove(TKey key, out object? result);
23-
24-
/// <summary>
25-
/// Removes an item from the dictionary with the specified key, if present.
26-
/// </summary>
27-
/// <param name="key">The key of the item to remove.</param>
28-
/// <returns>Whether or not an item was removed.</returns>
29-
bool Remove(TKey key);
20+
/// <returns>Whether or not the key was present.</returns>
21+
bool TryRemove(TKey key);
3022
}
3123
}

0 commit comments

Comments
 (0)