Multiple exceptions yet again... this time with a resolution
I've had a wonderful day with Mads Torgersen, and amongst other things, we discussed multiple exceptions and the way that the default awaiter for Task<T> handles an AggregateException by taking the first exception and discarding the rest.
I now have a much clearer understanding of why this is the case, and also a workaround for the cases where you really want to avoid that truncation.
Why truncate in the first place?
(I'll use the term "truncate" throughout this post to mean "when an AggregatedException with at least one nested exception is caught by EndAwait, throw the first nested exception instead". It's just a shorthand.)
Yesterday's post on multiple exceptions showed what you got if you called Wait() on a task returned from an async method. You still get an AggregateException, so why bother to truncate it?
Let's consider a slightly different situation: where we're awaiting an async method that throws an exception, and you want to be able to catch some specific exception that will be thrown by that asynchronous method. Imagine we used my NaiveAwaiter class. That would mean we would have to catch AggregateException, check whether one of those exceptions was actually present, and then handle that. There'd then be an open question about what to do if there were other exceptions as well... but that would be a relatively rare case. (Remember, we're talking about multiple "top level" exceptions within the AggregateException - not just one exception nested in another, nested in another etc.)
With the current awaiter behaviour, you can catch the exception exactly as you would have done in synchronous code. Here's an example:
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
public class BangException : Exception
{
public BangException(string message) : base(message) {}
}
public class Test
{
public static void Main()
{
FrobAsync().Wait();
}
private static async Task FrobAsync()
{
Task fuse = DelayedThrow(500);
try
{
await fuse;
}
catch (BangException e)
{
Console.WriteLine("Caught it! ({0})", e.Message);
}
}
static async Task DelayedThrow(int delayMillis)
{
await TaskEx.Delay(delayMillis);
throw new BangException("Went bang after " + delayMillis + "ms");
}
}
Nice and clean exception handling... assuming that the task we awaited asynchronously didn't have multiple exceptions. (Note the improved DelayedThrow method, by the way. Definitely cleaner than my previous version.)
This aspect of "the async code looks like the synchronous code" is the important bit. One of the key aims of the language feature is to make it easy to write asynchronous code as if it were synchronous - because that's what we're used to, and what we know how to reason about. We're fairly used to the idea of catching one exception... not so much on the "multiple things can go wrong at the same time" front.
So that handles the primary case where we really expect to only have one exception (if any) because we're only performing one job.
What about cases where multiple exceptions are somewhat expected?
Let's go back to the case where we really to propagate multiple exceptions. I think it's reasonable that this should be an explicit opt-in, so let's think about an extension method. For the sake of simplicity I'll use Task - in real life we'd want Task<T> as well, of course. So for example, this line:
await TaskEx.WhenAll(t1, t2);
would become this:
await TaskEx.WhenAll(t1, t2).PreserveMultipleExceptions();
(Yes, the name is too long... but you get the idea.)
Now, there are two ways we could make this work:
- We could make the extension method return something which had a GetAwaiter method, returning something which in turn had BeginAwait and EndAwait methods. This means making sure we get all of the awaiter code right, of course - and the returned value has little meaning outside an await expression.
- We could wrap the task in another task, and use the existing awaiter code. We know that the EndAwait extension method associated with Task (and Task<T>) will go into a single level of AggregateException - but I don't believe it will do any more than that. So if it's going to strip one level of exception aggregation off, all we need to do is add another level.
According to Mads, the latter of these is easier. Let's see if he's right.
We need an extension method on Task, and we're going to return Task too. How can we implement that?
- We can't await the task, because that will strip the exception before we get to it.
- We can't write an async task but call Wait() on the original task, because that will block immediately - we still want to be async.
- We can use a TaskCompletionSource<T> to build a task. We don't care about the actual result, so we'll use TaskCompletionSource<object>. This will actually build a Task<object>, but we'll return it as a Task anyway, and use a null result if it completes with no exception. (This was Mads' suggestion.)
So, we know how to build a Task, and we've been given a Task - how do we hook the two together? The answer is to ask the original task to call us back when it completes, via the ContinueWith method. We can then set the result of our task accordingly. Without further ado, here's the code:
public static Task PreserveMultipleExceptions(this Task originalTask)
{
var tcs = new TaskCompletionSource<object>();
originalTask.ContinueWith(t => {
switch (t.Status) {
case TaskStatus.Canceled:
tcs.SetCanceled();
break;
case TaskStatus.RanToCompletion:
tcs.SetResult(null);
break;
case TaskStatus.Faulted:
tcs.SetException(originalTask.Exception);
break;
}
}, TaskContinuationOptions.ExecuteSynchronously);
return tcs.Task;
}
This was thrown together in 5 minutes (in the middle of a user group talk by Mads) so it's probably not as robust as it might be... but the idea is that when the original task completes, we just piggy-back on the same thread very briefly to make our own task respond appropriately. Now when some code awaits our returned task, we'll add an extra wrapper of AggregateException on top, ready to be unwrapped by the normal awaiter.
Note that the extra wrapper is actually added for us really, really easily - we just call TaskCompletionSource<T>.SetException with the original task's AggregateException. Usually we'd call SetException with a single exception (like a BangException) and the method automatically wraps it in an AggregateException - which is exactly what we want.
So, how do we use it? Here's a complete sample (just add the extension method above):
using System;
using System.Threading.Tasks;
public class BangException : Exception
{
public BangException(string message) : base(message) {}
}
public class Test
{
public static void Main()
{
FrobAsync().Wait();
}
public static async Task FrobAsync()
{
try
{
Task t1 = DelayedThrow(500);
Task t2 = DelayedThrow(1000);
Task t3 = DelayedThrow(1500);
await TaskEx.WhenAll(t1, t2, t3).PreserveMultipleExceptions();
}
catch (AggregateException e)
{
Console.WriteLine("Caught {0} aggregated exceptions", e.InnerExceptions.Count);
}
catch (Exception e)
{
Console.WriteLine("Caught non-aggregated exception: {0}", e.Message);
}
}
static async Task DelayedThrow(int delayMillis)
{
await TaskEx.Delay(delayMillis);
throw new BangException("Went bang after " + delayMillis + "ms");
}
}
The result is what we were after:
Caught 3 aggregated exceptions
The blanket catch (Exception e) block is there so you can experiment with what happens if you remove the call to PreserveMultipleExceptions - in that case we get the original behaviour of a single BangException being caught, and the others discarded.
Conclusion
So, we now have answers to both of my big questions around multiple exceptions with async:
- Why is the default awaiter truncating exceptions? To make asynchronous exception handling look like synchronous exception handling in the common case.
- What can we do if that's not the behaviour we want? Either write our own awaiter (whether that's invoked explicitly or implicitly via "extension method overriding" as shown yesterday) or wrap the task in another one to wrap exceptions.
I'm happy again. Thanks Mads :)