In this article, we will explore how publishing MediatR notifications in parallel works. Also, we will dive into the advantages and possible pitfalls of using parallel publishing.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

How MediatR Publishing Works by Default

MediatR uses a sequential publishing strategy by default. This means that each handler is called one after the other, waiting for the previous one to finish. Let’s inspect the actual implementation from the MediatR library:

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
public class ForeachAwaitPublisher : INotificationPublisher
{
    public async Task Publish(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification,
        CancellationToken cancellationToken)
    {
        foreach (var handler in handlerExecutors)
        {
            await handler.HandlerCallback(notification, cancellationToken).ConfigureAwait(false);
        }
    }
}

The default publisher simply loops over the handlers, invoking and awaiting the callback method. Since MediatR is tightly integrated with Microsoft’s dependency injection, it gets the handlers directly from the ServiceProvider.

The ServiceProvider guarantees that it returns requested services – our notification handlers – in the order of registration. By paying attention to the order in which we register the notification handlers, we can control which handler will run first. However MediatR can register all handlers automatically, so if we would like to control the order, then we should register everything manually.

Let’s discuss now how to switch the sequential implementation to a parallel one.

How to Implement Parallel Publishing With MediatR

First, let’s create a new console app. Then, let’s install the latest MediatR NuGet package. We must use at least version 12, which supports custom publishing strategies, since earlier versions only support the sequential publishing strategy. Next, let’s install the Microsoft.Extensions.DependencyInjection NuGet package, which we will need to build a ServiceProvider object.

To test notification publishing, let’s create a test notification:

public sealed record Notification(string Message) : INotification;

Then let’s create two handlers:

public sealed class FirstNotificationHandler : INotificationHandler<Notification>
{
    public async Task Handle(Notification notification, CancellationToken cancellationToken)
    {
        await Task.Delay(Random.Shared.Next(1000, 5000), cancellationToken);
        Console.WriteLine($"FirstNotificationHandler: {notification.Message}");
    }
}

public sealed class SecondNotificationHandler : INotificationHandler<Notification>
{
    public async Task Handle(Notification notification, CancellationToken cancellationToken)
    {
        await Task.Delay(Random.Shared.Next(1000, 5000), cancellationToken);
        Console.WriteLine($"SecondNotificationHandler: {notification.Message}");
    }
}

Both of these methods do the same thing, including waiting a random amount of time between 1 and 5 seconds. This delay is necessary to simulate long-running async operations and to make our test more realistic.

Finally, let’s configure dependency injection in the Program class:

var services = new ServiceCollection();
services.AddMediatR(c =>
{
    c.RegisterServicesFromAssemblyContaining<Program>();
    c.NotificationPublisher = new TaskWhenAllPublisher();
});

var serviceProvider = services.BuildServiceProvider();

We set MediatR’s built-in TaskWhenAllPublisher as the notification publisher because, as its name suggests, it will run all handlers in parallel and will only return once all the handlers run to completion. We also pass the Program class to MediatR to register the handlers from its assembly, and finally, we build the service provider.

Testing Parallel Publishing

Once we have our service provider, let’s request the IMediator interface from it, and publish a notification:

var mediator = serviceProvider.GetRequiredService<IMediator>();

await mediator.Publish(new Notification("Hello, World!"));

Let’s run the solution a few times and inspect the outputs:

1st run:
SecondNotificationHandler: Hello, World!
FirstNotificationHandler: Hello, World!

2nd run:
FirstNotificationHandler: Hello, World!
SecondNotificationHandler: Hello, World!

Depending on the random delay and the execution of the async code, our notification handlers will run in different order, but in parallel. Let’s add a new logging line to each handler:

public async Task Handle(Notification notification, CancellationToken cancellationToken)
{
    Console.WriteLine("FirstNotificationHandler: Start");
    await Task.Delay(Random.Shared.Next(1000, 5000), cancellationToken);
    Console.WriteLine($"FirstNotificationHandler: {notification.Message}");
}

Let’s rerun the solution and inspect the console output closely while it’s running:

FirstNotificationHandler: Start
SecondNotificationHandler: Start
SecondNotificationHandler: Hello, World!
FirstNotificationHandler: Hello, World!

The first two lines appeared immediately, nearly at the same time, then after the delays passed we got the last two lines. This proves that the handlers ran in parallel and that despite the FirstNotificationHandler being called earlier, the SecondNotificationHandler finished faster.

Now let’s review when to use parallel publishing and what to watch out for.

What Are the Pros and Cons?

The most significant advantage of parallel publishing MediatR notifications is speed. By running in parallel, we can achieve a significant increase in execution times if we have many handlers.

Due to running in parallel, we can also utilize our system better by using more threads, but there’s an important catch here. In a server environment, we may not want to use more than one thread in parallel to serve a single request. Imagine thousands of users simultaneously using our application, and each of their requests starts 10 threads in parallel to publish notifications. That will quickly consume all ThreadPool threads, and new user requests will not be served until the load decreases.

Another potential drawback is race conditions. For example, if all handlers modify the same data inside a database, it could cause random results and overwrites. However, this is not an issue if the handlers only read data, or do not modify the same data.

Conclusion

We started by looking at the default sequential publish strategy, which was the only one available until MediatR version 12. Then we implemented parallel publishing using the built-in TaskWhenAllPublisher and proved that it is truly parallel.

Finally, we discussed the pros and cons, including speed gains and the potential for ThreadPool starvation and race conditions. As with everything in software development, we should examine each use case to decide whether the pros outweigh the cons.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!