visit
One day, you go to wire up an myObject.SomeEvent += SomeEventHandler
, and your event handler needs to await some asynchronous code. You take all of the right steps and change your method signature to get that beautiful async Task added in, replacing void. But suddenly, you get a compile error about your event handler not being compatible.
I wanted to demonstrate this with a simple bit of code that you can literally try out in your browser. Let’s discuss the code below (which, by the way, is using the top-level statements feature of .NET 7.0 so if you’re not used to seeing a
using System;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine("Start");
try
{
// NOTE: uncomment the single line for each one of the scenarios below one at a time to try it out!
// Scenario 1: we can await an async Task which allows us to catch exceptions
//await AsyncTask();
// Scenario 2: we cannot await an async void and as a result we cannot catch the exception
//AsyncVoid();
// Scenario 3: we purposefully wrap the async void in a Task (which we can await), but it still blows up
//await Task.Run(AsyncVoid);
}
catch (Exception ex)
{
Console.WriteLine("Look! We caught the exception here!");
Console.WriteLine(ex);
}
Console.WriteLine("End");
async void AsyncVoid()
{
Console.WriteLine("Entering async void method");
await AsyncTask();
// Pretend there's some super critical code right here
// ...
Console.WriteLine("Leaving async void method.");
}
async Task AsyncTask()
{
Console.WriteLine("Entering async Task method");
Console.WriteLine("About to throw...");
throw new Exception("The expected exception");
}
You’ve been patient enough, so let’s dive right into it. If you want to follow along with a working demo right in your browser,
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
var someEventRaisingObject = new SomeEventRaisingObject();
// notice the async void. BEHOLD!!
someEventRaisingObject.TheEvent += async (s, e) =>
{
Console.WriteLine("Entering the event handler...");
await TaskThatIsReallyImportantAsync();
Console.WriteLine("Exiting the event handler...");
};
try
{
// forcefully fire our event!
await someEventRaisingObject.RaiseEventAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Caught an exception in our handler: {ex.Message}");
}
// just block so we can wait for async operations to complete
Console.WriteLine("Press Enter to exit");
Console.ReadLine();
async Task TaskThatIsReallyImportantAsync()
{
// switch between these two lines (comment one or the other out) to play with the behavior
throw new Exception("This is an expected exception");
//await Task.Run(() => Console.WriteLine("Look at us writing to the console from a task!"));
}
class SomeEventRaisingObject
{
// you could in theory have your own event args here
public event EventHandler<EventArgs> TheEvent;
public Task RaiseEventAsync()
{
// the old way (if you toggle this way with the exception throwing, it will not hit our handler!)
//TheEvent?.Invoke(this, EventArgs.Empty);
//return Task.CompletedTask;
// the new way (if you toggle this way with the exception throwing, it WILL hit our handler!)
return InvokeAsync(TheEvent, true, true, this, EventArgs.Empty);
}
internal static async Task InvokeAsync(
MulticastDelegate multicastDelegate,
bool forceOrdering,
bool stopOnFirstError,
params object[] args)
{
if (multicastDelegate is null)
{
return;
}
var tcs = new TaskCompletionSource<bool>();
var delegates = multicastDelegate.GetInvocationList();
var count = delegates.Length;
// keep track of exceptions along the way and a separate collection
// for exceptions we have assigned to the TCS
var assignedExceptions = new List<Exception>();
var trackedExceptions = new ConcurrentQueue<Exception>();
foreach (var @delegate in multicastDelegate.GetInvocationList())
{
var async = @delegate.Method
.GetCustomAttributes(typeof(AsyncStateMachineAttribute), false)
.Any();
bool waitFlag = false;
var completed = new Action(() =>
{
if (Interlocked.Decrement(ref count) == 0)
{
lock (tcs)
{
assignedExceptions.AddRange(trackedExceptions);
if (!trackedExceptions.Any())
{
tcs.SetResult(true);
}
else if (trackedExceptions.Count == 1)
{
tcs.SetException(assignedExceptions[0]);
}
else
{
tcs.SetException(new AggregateException(assignedExceptions));
}
}
}
waitFlag = true;
});
var failed = new Action<Exception>(e =>
{
trackedExceptions.Enqueue(e);
});
if (async)
{
var context = new EventHandlerSynchronizationContext(completed, failed);
SynchronizationContext.SetSynchronizationContext(context);
}
try
{
@delegate.DynamicInvoke(args);
}
catch (TargetParameterCountException e)
{
throw;
}
catch (TargetInvocationException e) when (e.InnerException != null)
{
// When exception occured inside Delegate.Invoke method all exceptions are wrapped in
// TargetInvocationException.
failed(e.InnerException);
}
catch (Exception e)
{
failed(e);
}
if (!async)
{
completed();
}
while (forceOrdering && !waitFlag)
{
await Task.Yield();
}
if (stopOnFirstError && trackedExceptions.Any())
{
lock (tcs)
{
if (!assignedExceptions.Any())
{
assignedExceptions.AddRange(trackedExceptions);
if (trackedExceptions.Count == 1)
{
tcs.SetException(assignedExceptions[0]);
}
else
{
tcs.SetException(new AggregateException(assignedExceptions));
}
}
}
break;
}
}
await tcs.Task;
}
private class EventHandlerSynchronizationContext : SynchronizationContext
{
private readonly Action _completed;
private readonly Action<Exception> _failed;
public EventHandlerSynchronizationContext(
Action completed,
Action<Exception> failed)
{
_completed = completed;
_failed = failed;
}
public override SynchronizationContext CreateCopy()
{
return new EventHandlerSynchronizationContext(
_completed,
_failed);
}
public override void Post(SendOrPostCallback d, object state)
{
if (state is ExceptionDispatchInfo edi)
{
_failed(edi.SourceException);
}
else
{
base.Post(d, state);
}
}
public override void Send(SendOrPostCallback d, object state)
{
if (state is ExceptionDispatchInfo edi)
{
_failed(edi.SourceException);
}
else
{
base.Send(d, state);
}
}
public override void OperationCompleted() => _completed();
}
}
Toggle between what TaskThatIsReallyImportantAsync
does. You can either safely print to the console, or have it throw an exception so you can experiment with the happy vs bad path. This alone isn’t the focus of the article, but it allows you to try different situations.
Toggle the behavior of RaiseEventAsync
, which will show you the not-so-great behavior of async void compared to our solution! Mix this with the previous option to see how we can improve our ability to catch exceptions.
This solution is something I heavily borrowed from Oleg Karasik. In fact,
We can use reflection to check for AsyncStateMachineAttribute
to see if our handler is truly marked as async or not.
GetInvocationList
allows us to get the entire chain of handled registered to an event.
DynamicInvoke
allows us to invoke the delegate without the compiler screaming about types.
forceOrdering
: This boolean flag can force each handler to run in the order that they were registered or if it’s disabled, the handlers can run asynchronously with each other.
stopOnFirstError
: This boolean flag can prevent the code from firing off the subsequent event handlers if one of them raises an exception.
In the end, we are able to set information on a TaskCompletionSource
instance that indicates completion or an exception being tracked.