Why is `async void` Wrong and What To Do Instead?

Rus
Publication date: 2022-08-21

C# is a language known for its feature-richness and maybe even being a bit over the top with its syntax sugar. And one of its signature features is definitely seamless asynchronous code integration with the async and await keywords.

One lesser known feature that was introduced in the same version of C# is async void. An async void method is similar to a regular async (e.g. async Task), but returns nothing, a void. The main motivation behind this feature was an ability to subscribe such a method to a .NET event: event handlers weren't upgraded to properly support methods with non-void return types. For example, consider this:

async void ProcessEvent(object sender, EventArgs args)
{
    await DoThis();
    await DoThat();
}

event EventHandler Foo;
// …
Foo += ProcessEvent;

Looks simple enough (even though people have started using this for purposes other than mere event handling), what are the downsides?

Observability

First of all, even if the caller cares, they now cannot observe the method's execution. They cannot await a result of a call to an async void method.

async void was designed for "fire-and-forget" calls, so this is not a problem in most cases, but still: sometimes you want to observe the result of such a call, and yet you cannot.

Exception Handling

Now this is very problematic, so let's start from the beginning.

The async methods were introduced in C# 5.0, released together with .NET Framework 4.5. Before .NET Framework 4.5, Tasks had a problem with unhandled exceptions: if a Task was failed with an exception, then if would rethrow it on finalization, and the runtime would crash afterwards (because an exception is thrown on the finalizer thread).

It is still possible to enable this behavior on modern .NET Framework versions with a runtime configuration option:

<configuration>
    <runtime>
        <ThrowUnobservedTaskExceptions enabled="true"/>
    </runtime>
</configuration>

Also, I should note that it's possible to write async C# code while targeting older runtime versions, where the default behavior is to throw the exception (and thus eventually terminate the runtime).

  1. So, the first problem with async void is: any exception thrown from such a method may terminate some versions of .NET Framework, either by default or if configured accordingly. Modern defaults are tuned for safer execution, so you'll rarely encounter this problem, but still, it's possible.
  2. Our second problem comes from the same direction: if the exception is not thrown, then what? How will we know that it happened at all? By default, an exception thrown from an async void method is unobserved, and nobody will know about it. It's possible to subscribe to TaskScheduler::UnobservedTaskException event and observe the exceptions, but that's a global solution to a local problem, and should be done carefully.
  3. The third problem aptly comes from the third-party code. Namely, third-party task schedulers. For example, consider that you're writing an Avalonia application. Avalonia message loop is pretty fragile (at least in 0.10.18), and the whole application will crash if any exception is thrown during the message processing. And async void method throwing an exception will crash the Avalonia's message scheduler. Any similarly-written task scheduler will behave the same way: it may crash on unobserved task exception, including the one from an async void method.

The Solution

All of these points create a dangerous situation. In most cases, if you're careful enough, you may use async void and get away with it, but that becomes more tedious more and more code you write. Forgot a try … catch block in an async void method? Goodbye, the app will crash. Forgot or incorrectly handled a TaskScheduler::UnobservedTaskException? The exception will go unobserved.

There's a solution to this madness: just never use async void, and you'll have much fewer problems to care about. You still occasionally need "fire-and-forget" tasks, of course: being it in an event handler or whatever. In this case, create a NoAwait extension method which will catch and log your exceptions. Consider this rewritten snippet from the beginning of the post:

void ProcessEvent(object sender, EventArgs args) // note no `async`
{
    async Task Go()
    {
        await DoThis();
        await DoThat();
    }
    Go().NoAwait();
}

event EventHandler Foo;
// …
Foo += ProcessEvent;

Your NoAwait() should use your logging framework to report an exception. But for the sake of simplicity, here's an example of such method:

void NoAwait(this Task task)
{
    task.ContinueWith(t =>
    {
        if (t.IsFaulted) Console.Error.WriteLine(t.Exception);
    }, TaskContinuationOptions.ExecuteSynchronously);
}

An example from the production code you may find here.

Last but not least, this solution is easy to automate. There are (or at least it's easy to create) compiler diagnostics that guard you from async void and ignored tasks (without continuations or await), but it would be harder to, say, make you wrap all the code inside an async void method into a try … catch block.