The danger of async void
You know those innocent little errors that end up bringing production to its knees?
Well, let me tell you the story of how I managed to do just that… thanks to async void
.
Yes, it was a moment of glory - or not really.
First of all, you should be aware that the use of async void
is notorious and strongly discouraged, especially in ASP.NET. David Fowler (Architect on the .NET Team) discusses this in his Async Guidance.
What is async void
and why did I use it despite the warnings?
When writing asynchronous methods, the method is markes as async
and its return type is usually either Task
or Task<T>
. But it’s also possible to return void
instead of a task. This happens when trying to use synchronous code you don’t own (or can’t change) while making asynchronous operations.
In my case, I was registering an asynchronous callback as a synchronous method to handle configuration changes:
public void Register(IOptionsMonitor<MyServiceOptions> options)
{
// The callback is supposed to be synchronous
options.Onchange(async () => { /* ... */ });
}
The .NET API doesn’t allow a normal asynchronous delegate (one that returns a task) to be passed here. It’s a known issue (filed by David Fowler) that has been reporterd a few years ago, in the .NET runtime’s GitHub repository.
David speaks of two potential workarounds: either use async void
or block the thread. In my work context (I work in a high QPS and low-latency environment), blocking the thread is not an option.
So, I opted for async void
, thinking it would be fine…
Spoiler alert: it wasn’t.
What happened?
So, I had a asynchronous callback registered to this synchronous OnChange
method and because of a bug, a change of configuration threw an exception. This immediately caused all apps running the code to crash, and the bug prevented them to restart properly. Fortunately, the configuration change could be easily reverted and the apps were able to restart 😰
The bug was quite severe in itself, but it’s the async void
callback that caused the discontinuity of service.
Why throwing an exception in a async void
method makes the app crash?
That’s complicated because it’s not always true. For example, it does not crash console or GUI apps, but it does for ASP.NET apps, by default. The reason is because there is no SynchronizationContext
in ASP.NET.
What is the SynchronizationContext
and what does it have to do with async void
?
The SynchronizationContext
ensures that an async method that started on the UI thread, will continue its execution on the same one once the async call has been awaited. This has the advantage of avoiding multi-threading issues, at the cost of lower performance (and sometimes other issues). In ASP.NET, there is no UI thread and there is no need to continue the execution on the same thread: ASP.NET internals have been made to deal with multi-threading. On the other hand, we want to maximize the performance of our apps, hence, there is no need for the SynchronizationContext
.
The obvious difference between async void
and async Task
is their return type: In the former method, there is none. This is a major difference for exception handling.
In a async Task
case, when an exception occurs in the async part, it can’t be handled immediatly because it’s not happening in the same context of execution. Instead, the exception is stored in the Task
instance and will rethrown once the async call is awaited and the execution of the caller is resumed.
In a async void
case, there is no Task
. Instead, the exception is stored in the SynchronizationContext
as a fallback. But in ASP.NET (with no SynchronizationContext
), the application can’t do anything with the exceptions, and crashes.
It’s quite surprising at the first. At least, it was surpising to me that the application decides to crash by itself. But diving in the .NET internals proved it.
Let’s do it again, together.
Diving into .NET internals
Before that, you must know that async/await is one the high-level feature that has no meaning in the .NET CLR, but does in C#. To be able to execute async code on the CLR, the compiler has to translate it into “low-level” code. This step is called “lowering”. It is completely transparent to the developer, but if we want to understand what’s going on, we have to be able to see it.
To do so, I use a quite handy online tool called sharplab.io.
Lowering a “normal” async method
using System.Threading.Tasks;
public class MyClass {
public async Task NormalAsync() {
await Task.Yield();
}
}
See the lowered version here.
I won’t describe the whole generated part, it might be the subject of another article one day. Let’s focus on our issue.
The main points of focus, are:
- The async method body is converted in and replaced by a state machine
public Task NormalAsync()
{
<NormalAsync>d__0 stateMachine = new <NormalAsync>d__0();
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.<>4__this = this;
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
- An
AsyncTaskMethodBuilder
instance is created and assigned to the state machine
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
- The same instance is responsible of dealing with the exception when one occurs
catch (Exception exception)
{
<>1__state = -2;
<>t__builder.SetException(exception);
return;
}
From there, we can read the source of the .NET CLR:
AsyncTaskMethodBuilder.cs,119:
public void SetException(Exception exception) => AsyncTaskMethodBuilder<VoidTaskResult>.SetException(exception, ref m_task);
Nothing interesting, let’s follow the call.
AsyncTaskMethodBuilderT.cs,509:
internal static void SetException(Exception exception, ref Task<TResult>? taskField)
{
/* ... */
bool successfullySet = exception is OperationCanceledException oce ?
task.TrySetCanceled(oce.CancellationToken, oce) :
task.TrySetException(exception);
/* ... */
}
You can follow these calls, but to keep things short, I’d say it’s enough to prove that the exception is stored in the task when it arises during the task execution.
Lowering an async void method
Let’s lowering this code:
using System.Threading.Tasks;
public class MyClass {
public async void AsyncVoid() {
await Task.Yield();
}
}
See the lowered version here.
The main difference between the two codes is this line:
stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
Check the diff here.
So the type of the class changed. Let’s see if the behavior changed as well for handling the exception.
AsyncVoidMethodBuilder.cs,111:
public void SetException(Exception exception)
{
/* ... */
SynchronizationContext? context = _synchronizationContext;
if (context != null)
{
// If we captured a synchronization context, Post the throwing of the exception to it
// and decrement its outstanding operation count.
try
{
Task.ThrowAsync(exception, targetContext: context);
}
finally
{
NotifySynchronizationContextOfCompletion(context);
}
}
else
{
// Otherwise, queue the exception to be thrown on the ThreadPool. This will
// result in a crash unless legacy exception behavior is enabled by a config
// file or a CLR host.
Task.ThrowAsync(exception, targetContext: null);
}
// The exception was propagated already; we don't need or want to fault the builder, just mark it as completed.
_builder.SetResult();
}
Of course it does!
First, it checks if there is a SynchronizationContext
with a null check.
Then, if there isn’t and there is no special config to handle this case, the app crashes (I’ll let follow the call to see how)🤯.
How to prevent the app from crashing?
We saw that I had to alternatives to chose from, either:
- Block the thread
- Use
async void
As we saw earlier, the first option is not an option. And following the investigation we’ve done the second option isn’t an option either.
Fortunately, there is a third option, use Task.Run()
to execute the task:
public void Register(IOptionsMonitor<MyServiceOptions> options)
{
// This time the callback is synchronous
options.Onchange(() => {
Task.Run(async () => { /* but this part is asynchronous */ });
});
}
It’s not perfect, though: as we aren’t awaiting the async call, we won’t be able to observe the exceptions and thus it won’t be possible to take action if one occurs.
This can be worked around with a continuation task:
public void Register(IOptionsMonitor<MyServiceOptions> options)
{
// This time the callback is synchronous
options.Onchange(() => {
Task.Run(async () => { /* but this part is asynchronous */ })
.ContinueWith(
task => { /* do something, like logging */},
TaskContinuationOptions.OnlyOnFaulted
);
});
}
Conclusion
Moral of the story: async void
is quite dangerous, there are ways to avoid it and it’s largely preferable to do so than to take the risk of crashing the app.
I learned a great deal during the investigation and I cantt recommend you enough to dive into .NET internals and really understand how things work. There are a few features that are high-level and lowering them is always a great way to learn.
I can’t recommend you to break your apps (🤪) but when you do so (and you will probably do and we all do), it’s a great opportunity to deepen your knowledge, so take it!
I hope this article made you learn a few things, and will keep you from crashing the prod!