When we use "await" in C#, we can use the typical try/catch blocks that we're used to. That's a great thing, and it makes asynchronous programming a lot easier.
One limitation is that "await" only shows a single exception on a faulted Task. But we can use a little extra code to see all of the exceptions.
Let's take a closer look at what happens when we "await" a faulted Task and how we can take more control if we need it.
Background
Tasks in C# are very powerful. They can be chained together, run in parallel, and set up in parent/child relationships. With this power comes a bit of complexity. One part of that is that tasks generate AggregateExceptions -- basically a tree structure of exceptions.
When we "await" a Task, it unwraps the AggregateException to give us the inner exception. This makes our code easier to program, and often there will only be one inner exception. But when there are multiple exceptions, we lose visibility to them.
This topic came up in a virtual training class that I did this past week. We were looking at parallel processing that included exception handling. We didn't have time to dig into this in the class, so I did some experimentation after.
Note: the code for this article (and the rest of the virtual training class) is available on GitHub: https://github.com/jeremybytes/understanding-async-programming.
The projects that we'll use today are "People.Service" (the web service), "TaskAwait.Library" (a library with async methods), and "TaskException.UI.Console" (a console application).
If you're completely new to Task, you can check out the articles and videos listed here: I'll Get Back to You: Task, Await, and Asynchronous Methods in C#.
Parallel Code
For this sample code, we have a console application that calls a web service multiple times. It is called for each record we want to display (for this scenario, we might only want a handful of records rather than the entire collection).
Starting the Service
If you want to run the application yourself, you'll need to start the "People.Service" web service. To do this, navigate your favorite terminal to the "People.Service" folder and type "dotnet run".
This shows the service listening at "http://localhost:9874". The specific endpoint we're using here is "http://localhost:9874/people/3" (where "3" is the id of the record that we want).
Running in Parallel with Task
The "TaskException.UI.Console" project has a single "Program.cs" file. This is where we have our parallel code. Here is an excerpt from the "UseTaskAwaitException" method (starting on line 61 of the Program.cs file):
This section sets up a loop based on the IDs of the records we want to retrieve.
The loop starts with a call to the "GetPersonAsyncWithFailures" method that returns a task (we'll look at this method in a bit). Note the "WithFailures" is there because I created a separate method to throw arbitrary exceptions.
The resulting task is added to the a task list that holds a reference to all of the tasks.
Then we set up a continuation on that task. When the task is complete, it will output the record to the console. Note that this is marked with "OnlyOnRanToCompletion", so this continuation only runs on success; it will not run if an exception is thrown.
As a last step in the loop, we add the continuation task to our list of tasks.
Because nothing is "await"ed in this block of code, all of the tasks (9 altogether) are generated very quickly without blocking. So these will run in parallel.
Outside of the loop, we use "await Task.WhenAll(taskList)" to wait for everything to complete.
This will wait for all 9 tasks to complete before continuing with the rest of the method.
The Happy Path
When no exceptions are thrown, this outputs 9 records to the console. Here is a screenshot from the "Parallel.UI.Console" project (which calls a method that does *not* throw exceptions):
This shows us all 9 records. Now let's see what happens when there are exceptions.
Throwing Arbitrary Exceptions
As mentioned above, the method we're calling for testing throws some arbitrary exceptions. Our method is called "GetPersonAsyncWithFailures". Here is an excerpt from that (starting on line 75 of the TaskAwait.Library/PersonReader.cs file):
This method has two "if" statements that throw exceptions. If the "id" parameter is "2", then we throw an InvalidOperationException. If the "id" parameter is "5", we throw a "NotImplementedException".
These exceptions are completely arbitrary. We just need multiple calls to this method to fail so we can look at the aggregated exceptions.
"await Task.WhenAll" Shows One Exception
As a reminder, here's the code that we're working with (starting on line 61 of the Program.cs file in the "TaskException.UI.Console" project):
As noted above, the "foreach" loop will run the async method 9 times (with 9 different values). Two of these values will throw exceptions.
We don't need to look for these exceptions in the continuation since we marked it with "OnlyOnRanToCompletion" -- the continuation only runs for successful tasks.
The good news is that when we "await" a faulted Task (meaning, a Task that throws an exception), the exception is automatically thrown in our current context so that we can use a normal "try/catch" block.
When we use "await Task.WhenAll(tasklist)", it will throw an exception if any of the tasks are faulted.
Since we have 2 faulted tasks here, that's exactly what happens.
Here's a bit more of the "UseTaskAwaitException" method (starting on line 76 of the Program.cs file);
The top of this block shows the "await Task.WhenAll" call. Then we have catch blocks to handle cancellation or exceptions. The "OutputException" method shows several of the properties of the exception on the console (the details are in the same "Program.cs" file if you're interested).
Let's run this method to see what happens. To run the application, navigate (using your favorite terminal) to the "TaskException.UI.Console" folder and type "dotnet run".
This will bring up the console application menu:
The code we've seen so far is part of option #1 "Use Task (parallel - await Exception)". If we press "1", then we get the following output:
The output shows us 7 records (since 2 of the 9 failed). And it shows us the "InvalidOperationException" that is thrown for ID 2.
But it does *not* show us the other exception that was thrown.
We'll dig the exception out of the Task directly and look at its values. Then it will be a little more clear what "await" is doing here.
Getting All of the Exceptions
As mentioned at the beginning, Task is very powerful. Because of the various ways that we can chain tasks or run them in parallel, Task uses what's known as an "AggregateException". This contains all of the exceptions that happen in relation to the task.
To see this we will do something a little different with the "Task.WhenAll" method call.
"Task.WhenAll" returns a Task (and this is why we can "await" it). But we can also set up our own continuation to see the details of what happened.
We have a separate method where we've done just that. Let's look at the loop on the "UseTaskAggregateException" method (starting on line 106 of the Program.cs file):
The "foreach" loop is that same as the prior method, but the "await" is different.
The continuation that is set up here is marked as "OnlyOnFaulted", so it will only run if at least one of the Tasks throws an exception. The "task.Exception" property has the AggregateException that is mentioned above.
The "OutputException" method show different parts of the exception. The details are in the "Program.cs" file if you're interested. Otherwise, we can look at the output to see what is in the exception.
Note: we are still using "await", but we are not awaiting the "WhenAll". Instead, we are awaiting the continuation task on "WhenAll". This will still pause our method until all of the tasks are complete (including this final continuation). It can get a bit confusing when mixing "await" with directly using Task.
If we re-run the console application and choose "2" this time (for "Use Task (parallel) - view AggregateException"), we get the following output:
The output shows the 7 successful records, but the exception is different from what we saw earlier.
Instead of the "InvalidOperationException", we get an "AggregateException". The exception message shows us that "One or more errors occurred", and it also concatenates the inner exception messages.
Also note that there is an "InnerException" property that has the "InvalidOperationException". This is the exception that is shown when used "await" above.
But an AggregateException also has an "InnerExceptions" property (which is a collection). If we iterate over this, we find both of our exceptions: the "InvalidOperationException" and the "NotImplementedException".
When we use a continuation to check a Task exception directly, we get more details than when we use "await". The good news is that we can iterate over these inner exceptions and pass them along to our logging system.
Changing the Order
I was wondering if the exception that "await" shows for this code is deterministic. And it turns out that it is.
As an experiment, I reversed the order of the task list before passing it to "WhenAll". Here's the code for the "await" block:
With the reverse in place, we get a different output:
The output shows us the other exception that was generated - the NotImplementedException for ID 5.
We can also reverse the task list before sending it to the continuation:
The output shows us a bit better what is happening:
By looking directly at the AggregateException, we can see that the "InnerException" is now the one related to ID 5 (which is shown when we "await" things above).
Looking at the "InnerExceptions" collection, we see that the order is reversed. The ID 5 exception is first, and the ID 2 exception is second.
So when it comes to awaiting "Task.WhenAll", it appears that the exception we ultimately get is based on the order of the Tasks that are passed to the "WhenAll" method. Fun, huh?
Is This Important?
So how much do we need to care about this? The typical answer I would give is "probably not much". Tasks are extremely powerful, but we often do not need that power. And that's why "await" exists.
"await" gives us the 95% scenario. A common scenario I have is that I need to call an asynchronous method, wait for it to finish, do something with the result, and handle exceptions. If I only need to wait for one method at a time, then "await" works perfectly for this.
When we need to step outside of this scenario, it's good to understand how to use Task more directly. By understanding the process of setting up continuations and unwrapping AggregateExceptions, we can take more control of the situation when we need it.
Often we only care that "an exception happened". This lets us know that our application is in a potentially unstable state, and we need to handle things accordingly. We do not care about all of the details of what went wrong, and "await" is all we need. Whether we need to dig deeper depends on the needs of the users and the application.
If you want more information about using Task and await, you can check out the articles and video series on my website: I'll Get Back to You: Task, Await, and Asynchronous Methods in C#.
Happy Coding!
No comments:
Post a Comment