Skip to content

Commit 7632619

Browse files
committed
Switch to ValueTask<T> to avoid allocations from commands
When commands complete before a method returning, we can avoid a Task allocation altogether by using ValueTask instead of Task. Since the goal of executing a command is typically to retrieve a value and use that right-away, the optimization makes sense in general. Aligns with the new NotifyAsync for events too. Fixes #125
1 parent f347d1a commit 7632619

10 files changed

Lines changed: 88 additions & 47 deletions

File tree

readme.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The events-based API surface on the message bus is simple enough:
4545
```csharp
4646
public interface IMessageBus
4747
{
48-
void Notify<TEvent>(TEvent e);
48+
ValueTask NotifyAsync<TEvent>(TEvent e);
4949
IObservable<TEvent> Observe<TEvent>();
5050
}
5151
```
@@ -132,9 +132,9 @@ public interface IMessageBus
132132
// sync value-returning
133133
TResult Execute<TResult>(ICommand<TResult> command);
134134
// async void
135-
Task ExecuteAsync(IAsyncCommand command, CancellationToken cancellation);
135+
ValueTask ExecuteAsync(IAsyncCommand command, CancellationToken cancellation);
136136
// async value-returning
137-
Task<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, CancellationToken cancellation);
137+
ValueTask<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, CancellationToken cancellation);
138138
// async stream
139139
IAsyncEnumerable<TResult> ExecuteStream<TResult>(IStreamCommand<TResult> command, CancellationToken cancellation);
140140
}
@@ -150,7 +150,7 @@ class FindDocumentsHandler : IAsyncCommandHandler<FindDocument, IEnumerable<stri
150150
{
151151
public bool CanExecute(FindDocument command) => !string.IsNullOrEmpty(command.Filter);
152152

153-
public Task<IEnumerable<string>> ExecuteAsync(FindDocument command, CancellationToken cancellation)
153+
public ValueTask<IEnumerable<string>> ExecuteAsync(FindDocument command, CancellationToken cancellation)
154154
=> // evaluate command.Filter across all documents and return matches
155155
}
156156
```
@@ -411,6 +411,16 @@ builder.Services.AddServices();
411411

412412
<!-- #duck -->
413413

414+
<!-- #perf -->
415+
# Performance
416+
417+
The performance of Merq is on par with the best implementations of the
418+
the same pattern, for example [MediatR](https://www.nuget.org/packages/mediatr):
419+
420+
<!-- include ./src/Merq.Benchmarks/BenchmarkDotNet.Artifacts/results/Merq.MerqVsMediatR.Benchmark-report-github.md -->
421+
422+
<!-- #perf -->
423+
414424
<!-- #ci -->
415425

416426
# Dogfooding

src/Merq.Benchmarks/MerqVsMediatR.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Threading;
33
using System.Threading.Tasks;
44
using BenchmarkDotNet.Attributes;
5-
using BenchmarkDotNet.Diagnostics.Windows.Configs;
65
using MediatR;
76
using Microsoft.Extensions.DependencyInjection;
87

@@ -73,5 +72,5 @@ public class PingMerqAsync : IAsyncCommand<string>;
7372
public class PingMerqAsyncHandler : IAsyncCommandHandler<PingMerqAsync, string>
7473
{
7574
public bool CanExecute(PingMerqAsync command) => true;
76-
public Task<string> ExecuteAsync(PingMerqAsync command, CancellationToken cancellation) => Task.FromResult("Pong");
75+
public ValueTask<string> ExecuteAsync(PingMerqAsync command, CancellationToken cancellation) => ValueTask.FromResult("Pong");
7776
}

src/Merq.Benchmarks/Program.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using BenchmarkDotNet.Configs;
2-
using BenchmarkDotNet.Running;
1+
using BenchmarkDotNet.Running;
32
using Merq.MerqVsMediatR;
43

54
// Debug in-process configuration

src/Merq.Core/MessageBus.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ public TResult Execute<TResult>(ICommand<TResult> command, [CallerMemberName] st
238238
}
239239

240240
/// <inheritdoc/>
241-
public Task ExecuteAsync(IAsyncCommand command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)
241+
public ValueTask ExecuteAsync(IAsyncCommand command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)
242242
{
243243
var type = GetCommandType(command);
244244
using var activity = StartCommandActivity(type, command, callerName, callerFile, callerLine);
@@ -266,7 +266,7 @@ public Task ExecuteAsync(IAsyncCommand command, CancellationToken cancellation =
266266
}
267267

268268
/// <inheritdoc/>
269-
public Task<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)
269+
public ValueTask<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)
270270
{
271271
var type = GetCommandType(command);
272272
using var activity = StartCommandActivity(type, command, callerName, callerFile, callerLine);
@@ -278,7 +278,7 @@ public Task<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, Cance
278278
// For public types, we can use the faster dynamic dispatch approach
279279
return WithResult<TResult>().ExecuteAsync((dynamic)command, cancellation);
280280
#endif
281-
return (Task<TResult>)resultAsyncExecutors.GetOrAdd(type, type
281+
return (ValueTask<TResult>)resultAsyncExecutors.GetOrAdd(type, type
282282
=> (ResultAsyncDispatcher)Activator.CreateInstance(
283283
typeof(ResultAsyncDispatcher<,>).MakeGenericType(type, typeof(TResult)),
284284
this)!)
@@ -548,7 +548,7 @@ commandType is not null &&
548548
throw new InvalidOperationException($"No service for type '{typeof(ICommandHandler<TCommand>)}' has been registered.");
549549
}
550550

551-
Task ExecuteAsyncCore<TCommand>(TCommand command, CancellationToken cancellation) where TCommand : IAsyncCommand
551+
ValueTask ExecuteAsyncCore<TCommand>(TCommand command, CancellationToken cancellation) where TCommand : IAsyncCommand
552552
{
553553
var handler = services.GetService<IAsyncCommandHandler<TCommand>>();
554554
if (handler != null)
@@ -608,7 +608,7 @@ commandType is not null &&
608608
throw new InvalidOperationException($"No service for type '{typeof(ICommandHandler<TCommand, TResult>)}' has been registered.");
609609
}
610610

611-
Task<TResult> ExecuteAsyncCore<TCommand, TResult>(TCommand command, CancellationToken cancellation) where TCommand : IAsyncCommand<TResult>
611+
ValueTask<TResult> ExecuteAsyncCore<TCommand, TResult>(TCommand command, CancellationToken cancellation) where TCommand : IAsyncCommand<TResult>
612612
{
613613
var handler = services.GetService<IAsyncCommandHandler<TCommand, TResult>>();
614614
if (handler != null)
@@ -681,7 +681,7 @@ readonly struct With<TResult>(MessageBus bus)
681681
public TResult Execute<TCommand>(TCommand command) where TCommand : ICommand<TResult>
682682
=> bus.ExecuteCore<TCommand, TResult>(command);
683683

684-
public Task<TResult> ExecuteAsync<TCommand>(TCommand command, CancellationToken cancellation) where TCommand : IAsyncCommand<TResult>
684+
public ValueTask<TResult> ExecuteAsync<TCommand>(TCommand command, CancellationToken cancellation) where TCommand : IAsyncCommand<TResult>
685685
=> bus.ExecuteAsyncCore<TCommand, TResult>(command, cancellation);
686686

687687
#if NET6_0_OR_GREATER
@@ -751,12 +751,12 @@ class ResultDispatcher<TCommand, TResult>(MessageBus bus) : ResultDispatcher whe
751751

752752
abstract class VoidAsyncDispatcher
753753
{
754-
public abstract Task ExecuteAsync(IExecutable command, CancellationToken cancellation);
754+
public abstract ValueTask ExecuteAsync(IExecutable command, CancellationToken cancellation);
755755
}
756756

757757
class VoidAsyncDispatcher<TCommand>(MessageBus bus) : VoidAsyncDispatcher where TCommand : IAsyncCommand
758758
{
759-
public override Task ExecuteAsync(IExecutable command, CancellationToken cancellation)
759+
public override ValueTask ExecuteAsync(IExecutable command, CancellationToken cancellation)
760760
=> bus.ExecuteAsyncCore((TCommand)command, cancellation);
761761
}
762762

src/Merq.Tests/MessageBusServiceSpec.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ public async Task when_executing_async_command_without_handler_then_throws()
204204
Assert.False(bus.CanExecute(new AsyncCommand()));
205205
Assert.False(bus.CanHandle<AsyncCommand>());
206206
Assert.False(bus.CanHandle(new AsyncCommand()));
207-
await Assert.ThrowsAsync<InvalidOperationException>(() => bus.ExecuteAsync(new AsyncCommand(), CancellationToken.None));
207+
await Assert.ThrowsAsync<InvalidOperationException>(async () => await bus.ExecuteAsync(new AsyncCommand(), CancellationToken.None));
208208
}
209209

210210
[Fact]
@@ -213,7 +213,7 @@ public async Task when_executing_async_command_with_result_without_handler_then_
213213
Assert.False(bus.CanExecute(new AsyncCommandWithResult()));
214214
Assert.False(bus.CanHandle<AsyncCommandWithResult>());
215215
Assert.False(bus.CanHandle(new AsyncCommandWithResult()));
216-
await Assert.ThrowsAsync<InvalidOperationException>(() => bus.ExecuteAsync(new AsyncCommandWithResult(), CancellationToken.None));
216+
await Assert.ThrowsAsync<InvalidOperationException>(async () => await bus.ExecuteAsync(new AsyncCommandWithResult(), CancellationToken.None));
217217
}
218218

219219
[Fact]

src/Merq/IAsyncCommandHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public interface IAsyncCommandHandler<in TCommand> : IAsyncCommandHandler, IExec
2323
/// </summary>
2424
/// <param name="command">The command parameters for the execution.</param>
2525
/// <param name="cancellation">Cancellation token to cancel command execution.</param>
26-
Task ExecuteAsync(TCommand command, CancellationToken cancellation = default);
26+
ValueTask ExecuteAsync(TCommand command, CancellationToken cancellation = default);
2727
}
2828

2929
/// <summary>
@@ -44,5 +44,5 @@ public interface IAsyncCommandHandler<in TCommand, TResult> : IAsyncCommandHandl
4444
/// <param name="command">The command parameters for the execution.</param>
4545
/// <param name="cancellation">Cancellation token to cancel command execution.</param>
4646
/// <returns>The result of the execution.</returns>
47-
Task<TResult> ExecuteAsync(TCommand command, CancellationToken cancellation = default);
47+
ValueTask<TResult> ExecuteAsync(TCommand command, CancellationToken cancellation = default);
4848
}

src/Merq/IMessageBus.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public interface IMessageBus
6868
/// <param name="callerName">Optional calling member name, provided by default by the compiler.</param>
6969
/// <param name="callerFile">Optional calling file name, provided by default by the compiler.</param>
7070
/// <param name="callerLine">Optional calling line number, provided by default by the compiler.</param>
71-
Task ExecuteAsync(IAsyncCommand command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default);
71+
ValueTask ExecuteAsync(IAsyncCommand command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default);
7272

7373
/// <summary>
7474
/// Executes the given asynchronous command and returns a result from it.
@@ -80,7 +80,7 @@ public interface IMessageBus
8080
/// <param name="callerFile">Optional calling file name, provided by default by the compiler.</param>
8181
/// <param name="callerLine">Optional calling line number, provided by default by the compiler.</param>
8282
/// <returns>The result of executing the command.</returns>
83-
Task<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default);
83+
ValueTask<TResult> ExecuteAsync<TResult>(IAsyncCommand<TResult> command, CancellationToken cancellation = default, [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default);
8484

8585
#if NET6_0_OR_GREATER
8686
/// <summary>

src/Merq/PublicAPI.Shipped.txt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
Merq.IAsyncCommand
33
Merq.IAsyncCommand<TResult>
44
Merq.IAsyncCommandHandler
5-
Merq.IAsyncCommandHandler<TCommand, TResult>
6-
Merq.IAsyncCommandHandler<TCommand, TResult>.ExecuteAsync(TCommand command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<TResult>!
75
Merq.IAsyncCommandHandler<TCommand>
8-
Merq.IAsyncCommandHandler<TCommand>.ExecuteAsync(TCommand command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
6+
Merq.IAsyncCommandHandler<TCommand, TResult>
7+
Merq.IAsyncCommandHandler<TCommand, TResult>.ExecuteAsync(TCommand command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<TResult>
8+
Merq.IAsyncCommandHandler<TCommand>.ExecuteAsync(TCommand command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
99
Merq.ICanExecute<TCommand>
1010
Merq.ICanExecute<TCommand>.CanExecute(TCommand command) -> bool
1111
Merq.ICommand
@@ -26,12 +26,14 @@ Merq.IMessageBus.CanHandle(Merq.IExecutable! command) -> bool
2626
Merq.IMessageBus.CanHandle<TCommand>() -> bool
2727
Merq.IMessageBus.Execute(Merq.ICommand! command, string? callerName = null, string? callerFile = null, int? callerLine = null) -> void
2828
Merq.IMessageBus.Execute<TResult>(Merq.ICommand<TResult>! command, string? callerName = null, string? callerFile = null, int? callerLine = null) -> TResult
29-
Merq.IMessageBus.ExecuteAsync(Merq.IAsyncCommand! command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken), string? callerName = null, string? callerFile = null, int? callerLine = null) -> System.Threading.Tasks.Task!
30-
Merq.IMessageBus.ExecuteAsync<TResult>(Merq.IAsyncCommand<TResult>! command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken), string? callerName = null, string? callerFile = null, int? callerLine = null) -> System.Threading.Tasks.Task<TResult>!
29+
Merq.IMessageBus.ExecuteAsync(Merq.IAsyncCommand! command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken), string? callerName = null, string? callerFile = null, int? callerLine = null) -> System.Threading.Tasks.ValueTask
30+
Merq.IMessageBus.ExecuteAsync<TResult>(Merq.IAsyncCommand<TResult>! command, System.Threading.CancellationToken cancellation = default(System.Threading.CancellationToken), string? callerName = null, string? callerFile = null, int? callerLine = null) -> System.Threading.Tasks.ValueTask<TResult>
3131
Merq.IMessageBus.NotifyAsync<TEvent>(TEvent e, string? callerName = null, string? callerFile = null, int? callerLine = null) -> System.Threading.Tasks.ValueTask
3232
Merq.IMessageBus.Observe<TEvent>() -> System.IObservable<TEvent>!
3333
Merq.IMessageBusExtensions
3434
static Merq.IMessageBusExtensions.Execute<TCommand>(this Merq.IMessageBus! bus, string? callerName = null, string? callerFile = null, int? callerLine = null) -> void
3535
static Merq.IMessageBusExtensions.Notify<TEvent>(this Merq.IMessageBus! bus, string? callerName = null, string? callerFile = null, int? callerLine = null) -> void
3636
static Merq.IMessageBusExtensions.Notify<TEvent>(this Merq.IMessageBus! bus, TEvent e, string? callerName = null, string? callerFile = null, int? callerLine = null) -> void
3737
static Merq.IMessageBusExtensions.NotifyAsync<TEvent>(this Merq.IMessageBus! bus, string? callerName = null, string? callerFile = null, int? callerLine = null) -> System.Threading.Tasks.ValueTask
38+
Merq.ValueTaskExtensions
39+
static Merq.ValueTaskExtensions.Forget(this System.Threading.Tasks.ValueTask task) -> void

src/Merq/ValueTaskExtensions.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.ComponentModel;
2+
using System.Threading.Tasks;
3+
4+
namespace Merq;
5+
6+
/// <summary>
7+
/// Provides the <see cref="Forget"/> extension method to <see cref="ValueTask"/>.
8+
/// </summary>
9+
[EditorBrowsable(EditorBrowsableState.Never)]
10+
public static class ValueTaskExtensions
11+
{
12+
/// <summary>
13+
/// Observes the value task to avoid exceptions.
14+
/// </summary>
15+
public static void Forget(this ValueTask task)
16+
{
17+
// note: this code is inspired by a tweet from Ben Adams: https://twitter.com/ben_a_adams/status/1045060828700037125
18+
// Only care about tasks that may fault (not completed) or are faulted,
19+
// so fast-path for SuccessfullyCompleted and Canceled tasks.
20+
if (!task.IsCompleted || task.IsFaulted)
21+
{
22+
// use "_" (Discard operation) to remove the warning IDE0058: Because this call is not awaited, execution of the current method continues before the call is completed
23+
// https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/discards?WT.mc_id=DT-MVP-5003978#a-standalone-discard
24+
#pragma warning disable CA2012
25+
_ = ForgetAwaited(task);
26+
#pragma warning restore CA2012
27+
}
28+
29+
// Allocate the async/await state machine only when needed for performance reasons.
30+
// More info about the state machine: https://blogs.msdn.microsoft.com/seteplia/2017/11/30/dissecting-the-async-methods-in-c/?WT.mc_id=DT-MVP-5003978
31+
async static ValueTask ForgetAwaited(ValueTask task)
32+
{
33+
try
34+
{
35+
// No need to resume on the original SynchronizationContext, so use ConfigureAwait(false)
36+
await task.ConfigureAwait(false);
37+
}
38+
catch
39+
{
40+
// Nothing to do here
41+
}
42+
}
43+
}
44+
}

src/Samples/Library1/Commands.cs

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,48 +24,35 @@ public void Execute(NoOp command) { }
2424
}
2525

2626
[Service]
27-
public class EchoHandler : ICommandHandler<Echo, string>
27+
public class EchoHandler(IMessageBus bus) : ICommandHandler<Echo, string>
2828
{
29-
readonly IMessageBus? bus;
30-
31-
public EchoHandler() { }
32-
33-
public EchoHandler(IMessageBus bus) => this.bus = bus;
34-
3529
public bool CanExecute(Echo command) => !string.IsNullOrEmpty(command.Message);
3630

3731
public string Execute(Echo command)
3832
{
3933
if (string.IsNullOrEmpty(command.Message))
4034
throw new NotSupportedException("Cannot echo an empty or null message");
4135

42-
bus?.Notify(new OnDidSay(command.Message));
36+
bus.NotifyAsync(new OnDidSay(command.Message)).Forget();
4337
return command.Message;
4438
}
4539
}
4640

4741
[Service]
48-
public class EchoAsyncHandler : IAsyncCommandHandler<EchoAsync, string>
42+
public class EchoAsyncHandler(IMessageBus bus) : IAsyncCommandHandler<EchoAsync, string>
4943
{
50-
readonly IMessageBus? bus;
51-
52-
public EchoAsyncHandler() { }
53-
54-
public EchoAsyncHandler(IMessageBus bus) => this.bus = bus;
55-
56-
5744
public bool CanExecute(EchoAsync command) => true;
5845

59-
public Task<string> ExecuteAsync(EchoAsync command, CancellationToken cancellation = default)
46+
public async ValueTask<string> ExecuteAsync(EchoAsync command, CancellationToken cancellation = default)
6047
{
61-
bus?.Notify(new OnDidSay(command.Message));
62-
return Task.FromResult(command.Message);
48+
await bus!.NotifyAsync(new OnDidSay(command.Message));
49+
return command.Message;
6350
}
6451
}
6552

6653
[Service]
6754
public class NoOpAsyncHandler : IAsyncCommandHandler<NoOpAsync>
6855
{
6956
public bool CanExecute(NoOpAsync command) => true;
70-
public Task ExecuteAsync(NoOpAsync command, CancellationToken cancellation = default) => Task.CompletedTask;
57+
public ValueTask ExecuteAsync(NoOpAsync command, CancellationToken cancellation = default) => new();
7158
}

0 commit comments

Comments
 (0)