TL;DR
This is because async void shouldn't be used! async void is only there to make legacy code work (e.g. event handlers in WindowsForms and WPF).
Technical details
This is because of how the C# compiler generates code for the async methods.
You should know that behind async/await there's a state machine (IAsyncStateMachine implementation) generated by the compiler.
When you declare an async method, a state machine struct will be generated for it. For your ex() method, this state machine code will look like:
void IAsyncStateMachine.MoveNext()
{
try
{
throw new Exception();
}
catch (Exception exception)
{
this.state = -2;
this.builder.SetException(exception);
}
}
Note that this.builder.SetException(exception); statement. For a Task-returning async method, this will be an AsyncTaskMethodBuilder object. For a void ex() method, it will be an AsyncVoidMethodBuilder.
The ex() method body will be replaced by the compiler with something like this:
private static Task ex()
{
ExAsyncStateMachine exasm;
exasm.builder = AsyncTaskMethodBuilder.Create();
exasm.state = -1;
exasm.builder.Start<ExAsyncStateMachine>(ref exasm);
return exasm.builder.Task;
}
(and for the async void ex(), there will be no last return line)
The method builder's Start<T> method will call the MoveNext method of the state machine. The state machine's method catches the exception in its catch block. This exception should normally be observed on the Task object - the AsyncTaskMethodBuilder.SetException method stores that exception object in the Task instance. When we drop that Task instance (no await), we don't see the exception at all, but the exception itself isn't thrown anymore.
In the state machine for async void ex(), there's an AsyncVoidMethodBuilder instead. Its SetException method looks different: since there's no Task where to store the exception, it has to be thrown. It happens in a different way, however, not just a normal throw:
AsyncMethodBuilderCore.ThrowAsync(exception, synchronizationContext);
The logic inside that AsyncMethodBuilderCore.ThrowAsync helper decides:
- If there's a
SynchronizationContext (e.g. we're on a UI thread of a WPF app), the exception will be posted on that context.
- Otherwise, the exception will be queued on a
ThreadPool thread.
In both cases, the exception won't be caught by a try-catch block that might be set up around the ex() call (unless you have a special SynchronizationContext that can do this, see e.g. Stephen Cleary's AsyncContext).
The reason is simple: when we post a throw action or enqueue it, we then simply return from the ex() method and thus leave the try-catch block. Then, the posted/enqueued action is executed (either on the same or on a different thread).