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).