6

Goal:

I am confused by the behavior I am seeing with exceptions in my .Net Core library. The goal of this question is to understand why it is doing what I am seeing.

Executive Summary

I thought that when an async method is called, the code in it is executed synchronously until it hits the first await. If that is the case, then, if an exception is thrown during that "synchronous code", why is it not propagated up to the calling method? (As a normal synchronous method would do.)

Example Code:

Given the following code in a .Net Core Console Application:

static void Main(string[] args)
{
    Console.WriteLine("Hello World!");

    try
    {
        NonAwaitedMethod();
    }
    catch (Exception e)
    {
        Console.WriteLine("Exception Caught");
    }

    Console.ReadKey();
}

public static async Task NonAwaitedMethod()
{
    Task startupDone = new Task(() => { });
    var runTask = DoStuff(() =>
    {
        startupDone.Start();
    });
    var didStartup = startupDone.Wait(1000);
    if (!didStartup)
    {
        throw new ApplicationException("Fail One");
    }

    await runTask;
}

public static async Task DoStuff(Action action)
{
    // Simulate starting up blocking
    var blocking = 100000;
    await Task.Delay(500 + blocking);
    action();
    // Do the rest of the stuff...
    await Task.Delay(3000);
}

}

Scenarios:

  1. When run as is, this code will throw an exception, but, unless you have a break point on it, you will not know it. The Visual Studio Debugger nor the Console will give any indication that there was an issue (aside from a one line note in the Output screen).

  2. Swap the return type of NonAwaitedMethod from Task to void. This will cause the Visual Studio Debugger to now break on the exception. It will also be printed out in the console. But notably, the exception is NOT caught in the catch statement found in Main.

  3. Leave the return type of NonAwaitedMethod as void, but take off the async. Also change the last line from await runTask; to runTask.Wait(); (This essentially removes any async stuff.) When run, the exception is caught in the catch statement in the Main method.

So, to summarize:

| Scenario   | Caught By Debugger | Caught by Catch |  
|------------|--------------------|-----------------|  
| async Task | No                 | No              |  
| async void | Yes                | No              |  
| void       | N/A                | Yes             |  

Questions:

I thought that because the exception was thrown before an await was done, that it would execute synchronously up to, and through the throwing of the exception.

Hence my question of: Why does neither scenario 1 or 2 get caught by the catch statement?

Also, why does swapping from Task to void return type cause the exception to get caught by the Debugger? (Even though I am not using that return type.)

Vaccano
  • 78,325
  • 149
  • 468
  • 850
  • 1
    Does this answer your question? [Catch an exception thrown by an async void method](https://stackoverflow.com/questions/5383310/catch-an-exception-thrown-by-an-async-void-method) – Pavel Anikhouski Apr 01 '20 at 20:19
  • @PavelAnikhouski - I feel that question and its answers are more about things that happen after the await in the child method. The core of my question is that (as I understood it), the code executes synchronously up until the first await. If the calling method is not awaiting and if the child method it an exception before it hits any awaits, why does it not get propagated up (as a normal synchronous method would)? – Vaccano Apr 01 '20 at 20:44
  • @Vaccano Your understandings are correct. And your code does execute synchronously on the same thread where the caller method runs (before first await). It is just when you use `async`, the compiler wraps your method with a special type, and keep the exception for you until you call `await` at which point the exception is propagated up. – weichch Apr 01 '20 at 21:18
  • As a side note, according to the [guidelines](https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap#naming-parameters-and-return-types) the asynchronous methods should have the suffix `Async`. So the `NonAwaitedMethod` method should be named `NonAwaitedMethodAsync`, and the `DoStuff` should be named `DoStuffAsync`. This does not apply to `async void` methods. It applies only to asynchronous methods that return awaitable types like `Task`. – Theodor Zoulias Apr 02 '20 at 07:16

3 Answers3

9

exception was thrown before an await was done, that it would execute synchronously

Thought this is fairly true, but it doesn't mean you could catch the exception.

Because your code has async keyword, which turns the method into an async state machine i.e. encapsulated / wrapped by a special type. Any exception thrown from async state machine will get caught and re-thrown when the task is awaited (except for those async void ones) or they go unobserved, which can be caught in TaskScheduler.UnobservedTaskException event.

If you remove async keyword from the NonAwaitedMethod method, you can catch the exception.

A good way to observe this behavior is using this:

try
{
    NonAwaitedMethod();

    // You will still see this message in your console despite exception
    // being thrown from the above method synchronously, because the method
    // has been encapsulated into an async state machine by compiler.
    Console.WriteLine("Method Called");
}
catch (Exception e)
{
    Console.WriteLine("Exception Caught");
}

So your code is compiled similarly to this:

try
{
    var stateMachine = new AsyncStateMachine(() =>
    {
        try
        {
            NonAwaitedMethod();
        }
        catch (Exception ex)
        {
            stateMachine.Exception = ex;
        }
    });

    // This does not throw exception
    stateMachine.Run();
}
catch (Exception e)
{
    Console.WriteLine("Exception Caught");
}

why does swapping from Task to void return type cause the exception to get caught

If the method returns a Task, the exception is caught by the task.

If the method is void, then the exception gets re-thrown from an arbitrary thread pool thread. Any unhandled exception thrown from thread pool thread will cause the app to crash, so chances are the debugger (or maybe the JIT debugger) is watching this sort of exceptions.

If you want to fire and forget but properly handle the exception, you could use ContinueWith to create a continuation for the task:

NonAwaitedMethod()
    .ContinueWith(task => task.Exception, TaskContinuationOptions.OnlyOnFaulted);

Note you have to visit task.Exception property to make the exception observed, otherwise, task scheduler still will receive UnobservedTaskException event.

Or if the exception needs to be caught and processed in Main, the correct way to do that is using async Main methods.

weichch
  • 9,306
  • 1
  • 13
  • 25
  • Most of what you say makes sense. I am still confused on the part with `task.Exception` and `ContinueWith`. I tried throwing an exception from the `Action` of `ContinueWith`, but it still did not get caught in the `Main` method's `catch` statement. Is that just not going to happen with this setup? (I am able to do what I need from inside the ContinueWith, but I am still curious). – Vaccano Apr 01 '20 at 21:18
  • @Vaccano Yes, because `ContinueWith` creates another `Task`, and if you throw *another exception* from there, the new exception is caught in the return task. If you need to catch the exception in your `Main` method, the correct way to do that is changing your `Main` method to `async void Main`, and `await NonAwaitedMethod()`. – weichch Apr 01 '20 at 21:21
6

if an exception is thrown during that "synchronous code", why is it not propagated up to the calling method? (As a normal synchronous method would do.)

Good question. And in fact, the early preview versions of async/await did have that behavior. But the language team decided that behavior was just too confusing.

It's easy enough to understand when you have code like this:

if (test)
  throw new Exception();
await Task.Delay(TaskSpan.FromSeconds(5));

But what about code like this:

await Task.Delay(1);
if (test)
  throw new Exception();
await Task.Delay(TaskSpan.FromSeconds(5));

Remember that await acts synchronously if its awaitable is already completed. So has 1 millisecond gone by by the time the task returned from Task.Delay is awaited? Or for a more realistic example, what happens when HttpClient returns a locally cached response (synchronously)? More generally, the direct throwing of exceptions during the synchronous part of the method tends to result in code that changes its semantics based on race conditions.

So, the decision was made to unilaterally change the way all async methods work so that all exceptions thrown are placed on the returned task. As a nice side effect, this brings their semantics in line with enumerator blocks; if you have a method that uses yield return, any exceptions will not be seen until the enumerator is realized, not when the method is called.

Regarding your scenarios:

  1. Yes, the exception is ignored. Because the code in Main is doing "fire and forget" by ignoring the task. And "fire and forget" means "I don't care about exceptions". If you do care about exceptions, then don't use "fire and forget"; instead, await the task at some point. The task is how async methods report their completion to their callers, and doing an await is how calling code retrieves the results of the task (and observe exceptions).
  2. Yes, async void is an odd quirk (and should be avoided in general). It was put in the language to support asynchronous event handlers, so it has semantics that are similar to event handlers. Specifically, any exceptions that escape the async void method are raised on the top-level context that was current at the beginning of the method. This is how exceptions also work for UI event handlers. In the case of a console application, exceptions are raised on a thread pool thread. Normal async methods return a "handle" that represents the asynchronous operation and can hold exceptions. Exceptions from async void methods cannot be caught, since there is no "handle" for those methods.
  3. Well, of course. In this case the method is synchronous, and exceptions travel up the stack just like normal.

On a side note, never, ever use the Task constructor. If you want to run code on the thread pool, use Task.Run. If you want to have an asynchronous delegate type, use Func<Task>.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Good to know. If the behavior was kept from the preview version, it would be indeed confusing, and hard to write exception handling code. – weichch Apr 01 '20 at 21:38
  • @StephenCleary - I went back to this code, and swapped out `new Task(()=>{})` for `Task.Run(()=>{})`. (Because of your comment to not use the `Task` constructor.) When I do that, the Task starts right away, instead of being started in the `DoStuff` action. In fact, it throws an error because when it hits the call to `StartupDone.Start()`. (It says that it is already complete.) Is this one of the scenarios your blog post alludes to as being the "right time" to use the `Task` constructor? (Because I don't want the task scheduled right away.) – Vaccano Apr 22 '20 at 23:09
  • @Vaccano: As noted in my blog, there is only one reason to use the `Task` constructor: "if you are doing dynamic task parallelism [you're not] **and** need to construct a task that can run on any thread [you don't], **and** leave that scheduling decision up to another part of the code [you don't], **and** for whatever reason cannot use `Func` instead [no one does], then (and only then) you should use a task constructor." If you want to delay task execution, use `Func`. – Stephen Cleary Apr 23 '20 at 01:40
  • I am back looking at this again, and I can't seem to make it work. Rather than drag it out in the comments, I asked another question. If you feel up to it, it is here: https://stackoverflow.com/questions/61488316/switch-new-task-for-functask – Vaccano Apr 28 '20 at 19:23
1

The async keyword indicates that the compiler should transform the method to an async state machine, which is not configurable regarding the handling of the exceptions. If you want the sync-part-exceptions of the NonAwaitedMethod method to be thrown immediately, there is no other option than removing the async keyword from the method. You can have the best of both worlds by moving the async part into an async local function:

public static Task NonAwaitedMethod()
{
    Task startupDone = new Task(() => { });
    var runTask = DoStuff(() =>
    {
        startupDone.Start();
    });
    var didStartup = startupDone.Wait(1000);
    if (!didStartup)
    {
        throw new ApplicationException("Fail One");
    }

    return ImlpAsync(); async Task ImlpAsync()
    {
        await runTask;
    };
}

Instead of using a named function, you could also use an anonymous one:

return ((Func<Task>)(async () =>
{
    await runTask;
}))();
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104