Curious Case of Exceptions in Async Methods — Part 2

Anand Gupta
5 min readJul 2, 2020

--

In my previous post we discussed how the exception handling behavior of a method changes when add the async modifier to a void returning method that throws exception even if does not have any await statement or if exception occurs before the first await statement. In this post we will discuss the case when async modifier is added to a method returning Task (the same applies to async method returning Task<T> or any other ‘awaitable’ type — async methods are not constraint to return Task / Task <T>; something I might write about in future).

Let us now consider the scenario I outlined with an example:

Example Task returning method throws exception without await statement:

The below code shows an async method that returns Task, has no await statement and throws an exception. This code is almost identical to the one I presented in my previous post for the async void returning method. The only difference is that ThrowsAsync returns Task instead of void.

Async Task returning method that throws Exception

In the above code, the exception is thrown in ThrowsAsync method by the same thread that called it in a synchronous manner (please note, there is no await statement in ThrowsAsync method but even if we did and the code was throwing the exception before the first occurrence of the await method, our discussion still holds).

What would you expect the output for the above code?

Will it catch the exception in the catch block of Do method ?

Turns out, the output will depend on the version of .NET framework you are running.

If you are running .NET framework 4.5 and above (and with default settings), the output will be that the exception will never be caught, because the exception will be swallowed by the runtime!

You will see the output as below :

Throwing…

If you are running .NET framework 4.0, you will still see the above output, but at some indeterminate point in time in future, when the garbage collector runs, an exception will be thrown. That exception is called UnobservedTaskException

UnobservedTaskException mentioned above is actually an AggregateException that wraps the actual exception, but for the rest of the post I will use the term UnobservedTaskException to emphasize that this AggregateException is for an unobserved task

This exception indicates that the Task has exception that has not been observed (I will explain later in the post, what is meant by ‘observing’ a Task exception). This exception (if left unhandled / unobserved), will be rethrown as unhandled exception and terminate the process.

As mentioned previously, if you are running the above code under .NET framework 4.5 and above (with default settings), you will not see the exception, since it is swallowed. However, please note that the UnobservedTaskException is still thrown even in this case, it is just that it is not rethrown as unhandled exception even if left unhandled as it is being swallowed by runtime (under default settings).

To verify that the UnobservedTaskException is indeed still being thrown when garbage collector runs, we can add handler for UnobservedTaskException event and add code to explicitly perform garbage collection (including waiting for finalization).

Ensure Release mode with default settings for .NET Core for the code below. But if you want the same behavior in Debug mode for .NET Core, simply wrap the call to ThrowsAsync() inside a helper method (say, ThrowsAsyncHelper() — perhaps I will write separate post to explain why is there a difference in Debug and Release mode in this case).

For .NET Framework 4.5 and above, the below will work as described for both Debug and Release mode.

Unobserved Task Exception on garbage collection by finalizer thread

But please note that adding this handler does not ensure that we have handled the UnobservedTaskException itself. (It is a good place to perform any logging, etc.)

The output will be as below (for .NET Framework 4.5+ and .NET Core with default settings) — the program does not terminate:

Throwing…
GC..
Unobserved Task Exception : A Task’s exception(s) were not observed either by Waiting on the Task or accessing its Exception property. As a result, the unobserved exception was rethrown by the finalizer thread. (Boom!)

The output will be as below (for .NET 4.0) — the program will terminate with unhandled exception:

Throwing…
GC..
Unobserved Task Exception : A Task’s exception(s) were not observed either by Waiting on the Task or accessing its Exception property. As a result, the unobserved exception was rethrown by the finalizer thread.

Unhandled Exception: System.AggregateException: A Task’s exception(s) were not observed either by Waiting on the Task or accessing its Exception property. As a result, the unobserved exception was rethrown by the finalizer thread. — -> System.Exception: Boom!
at MyPlaygroundConsole.Program.<ThrowsAsync>d__9.MoveNext() in Program.cs:line 169
— — End of inner exception stack trace — -
at System.Threading.Tasks.TaskExceptionHolder.Finalize()
Press any key to continue . . .

Personally, I do not prefer this change in behavior of UnobservedTaskException introduced since .NET 4.5 (as I would rather prefer my application crash so I can take remediation actions instead of continuing to run with corrupt state), although I do appreciate the reasons that may have led the .NET team to make this choice.

Fortunately, the .NET team has provided a runtime config setting if we want to revert to .NET 4.0 behavior for UnobservedTaskException when running on .NET 4.5 and above (but not for .NET Core unfortunately), such that the UnobservedTaskException is not swallowed and will be rethrown at the time of garbage collection as unhandled exception.

We can control this setting like below in app.config (or web.config) in case of .NET framework 4.5 and above (but not .NET Core).

Please note the setting :

<ThrowUnobservedTaskExceptions enabled=”true”/>

Config setting to control ThrowUnobservedTaskExceptions flag in .NET 4.5+

For .NET Core, there is no such switch anymore — it is hardcoded to swallow the exception always. You may refer to the .NET Core source code if you are curious. But if we still want the behavior (probably because you do not want to continue to run the program with corrupt state) — similar to .NET 4.0 so that UnobservedTaskExceptions are thrown — we will have to explicitly rethrow the exception in the event handler code of UnobservedTaskException event as below :

Rethrow Unobserved Task Exception in .NET Core

If we want to actually observe the UnobservedTaskException, we will need to observe it by calling SetObserved() on the UnobservedTaskExceptionEventArgs of the event handler. Doing this will ensure that the exception is now no longer rethrown by the finalizer thread regardless of the ThrowUnobservedTaskExceptions setting and the .NET Framework version.

Observe Unobserved Task Exceptions

Finally, another way to observe the exception and ensure you catch the exception right where you are calling ThrowsAsync in the catch block (similar to how a regular non-async Task returning ThrowsAsync method would behave), we can call the Wait() method on the task returned by ThrowsAsync. This will ensure the Exception thrown by async ThrowsAsync method has been observed. But be aware that calling Wait() is a blocking operation (somewhat counter-intuitive to motivations for using async / await in the first place!)

Observe Task Exceptions with Wait() blocking call

Conclusion

The exception handling behavior of async method has several subtleties and pitfalls to be aware of. In this post we discussed the idiosyncrasies involved with async methods returning Tasks (even when there are no await statements, although same rules apply if there are await statements). In particular we discussed the difference in behavior across .NET framework versions and config settings available for us to control the behavior.

If you are interested to explore subtleties surrounding async methods with void return, I encourage you to read the part 1 of this blog series.

--

--