Тут є багато хороших відповідей, але я все одно хотів би опублікувати свою заяву, оскільки я щойно стикався з тією ж проблемою та проводив деякі дослідження. Або перейдіть до версії TLDR нижче.
Проблема
Очікування task
поверненого Task.WhenAll
лише викидає перше виняток із AggregateException
збереженого в task.Exception
, навіть коли кілька завдань мають збій.
В даний час документи дляTask.WhenAll
кажуть:
Якщо якесь із поставлених завдань виконується у стані несправності, повернене завдання також виконується у стані несправності, де його винятки містять сукупність набору нерозгорнутих винятків з кожного із поставлених завдань.
Що правильно, але це нічого не говорить про згадану поведінку "розгортання", коли очікується повернене завдання.
Я припускаю, що документи не згадують про це, оскільки така поведінка не є специфічною дляTask.WhenAll
.
Це просто Task.Exception
тип, AggregateException
і для await
продовжень він завжди розгортається як перший внутрішній виняток, за задумом. Це чудово для більшості випадків, оскільки зазвичай Task.Exception
складається лише з одного внутрішнього винятку. Але враховуйте цей код:
Task WhenAllWrong()
{
var tcs = new TaskCompletionSource<DBNull>();
tcs.TrySetException(new Exception[]
{
new InvalidOperationException(),
new DivideByZeroException()
});
return tcs.Task;
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
// task.Exception is an AggregateException with 2 inner exception
Assert.IsTrue(task.Exception.InnerExceptions.Count == 2);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(task.Exception.InnerExceptions[1], typeof(DivideByZeroException));
// However, the exception that we caught here is
// the first exception from the above InnerExceptions list:
Assert.IsInstanceOfType(exception, typeof(InvalidOperationException));
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
}
Тут екземпляр AggregateException
розгортається до свого першого внутрішнього винятку InvalidOperationException
точно так само, як і у нас Task.WhenAll
. Ми могли б не спостерігати, DivideByZeroException
якби не пройшли task.Exception.InnerExceptions
безпосередньо.
Стівен Туб від Microsoft пояснює причину такої поведінки у відповідній проблемі GitHub :
Я намагався сказати, що це було глибоко обговорено багато років тому, коли вони спочатку були додані. Спочатку ми зробили те, що ви пропонуєте, із завданням, повернутим із WhenAll, що містить один AggregateException, який містив усі винятки, тобто task.Exception повертає оболонку AggregateException, яка містить інший AggregateException, який потім містить фактичні винятки; тоді, коли його очікували, внутрішній AggregateException буде розповсюджений. Вагомий відгук, який ми отримали, що змусив нас змінити дизайн, полягав у тому, що а) переважна більшість таких випадків мала досить однорідні винятки, наприклад, поширення всього в сукупності було не так важливо, б) розповсюдження сукупності потім порушило очікування щодо вилову для конкретних типів винятків, і в) для випадків, коли хтось хотів сукупність, вони могли зробити це явно за допомогою двох рядків, як я писав. Ми також вели обговорення щодо того, якою може бути поведінка await щодо завдань, що містять численні винятки, і саме тут ми потрапили.
Ще одна важлива річ, на яку слід звернути увагу, - така поведінка розгортання є поверхневою. Тобто, він розгорне лише перший виняток AggregateException.InnerExceptions
і залишить його там, навіть якщо це випадково інший екземпляр AggregateException
. Це може внести ще один шар плутанини. Наприклад, давайте змінимось WhenAllWrong
так:
async Task WhenAllWrong()
{
await Task.FromException(new AggregateException(
new InvalidOperationException(),
new DivideByZeroException()));
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
// now, task.Exception is an AggregateException with 1 inner exception,
// which is itself an instance of AggregateException
Assert.IsTrue(task.Exception.InnerExceptions.Count == 1);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(AggregateException));
// And now the exception that we caught here is that inner AggregateException,
// which is also the same object we have thrown from WhenAllWrong:
var aggregate = exception as AggregateException;
Assert.IsNotNull(aggregate);
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
Assert.IsInstanceOfType(aggregate.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(aggregate.InnerExceptions[1], typeof(DivideByZeroException));
}
Рішення (TLDR)
Отже, повертаючись до того await Task.WhenAll(...)
, що я особисто хотів, це мати можливість:
- Отримайте єдиний виняток, якщо було лише одне;
- Отримайте,
AggregateException
якщо декілька винятків були спільно викликані одним або кількома завданнями;
- Уникайте збереження
Task
єдиного для перевірки його Task.Exception
;
- Розмножуються статус скасування правильно (
Task.IsCanceled
), а що - щось на зразок цього не робитиме , що: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }
.
Для цього я зібрав таке розширення:
public static class TaskExt
{
/// <summary>
/// A workaround for getting all of AggregateException.InnerExceptions with try/await/catch
/// </summary>
public static Task WithAggregatedExceptions(this Task @this)
{
// using AggregateException.Flatten as a bonus
return @this.ContinueWith(
continuationFunction: anteTask =>
anteTask.IsFaulted &&
anteTask.Exception is AggregateException ex &&
(ex.InnerExceptions.Count > 1 || ex.InnerException is AggregateException) ?
Task.FromException(ex.Flatten()) : anteTask,
cancellationToken: CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
scheduler: TaskScheduler.Default).Unwrap();
}
}
Тепер наступне працює так, як я хочу:
try
{
await Task.WhenAll(
Task.FromException(new InvalidOperationException()),
Task.FromException(new DivideByZeroException()))
.WithAggregatedExceptions();
}
catch (OperationCanceledException)
{
Trace.WriteLine("Canceled");
}
catch (AggregateException exception)
{
Trace.WriteLine("2 or more exceptions");
// Now the exception that we caught here is an AggregateException,
// with two inner exceptions:
var aggregate = exception as AggregateException;
Assert.IsNotNull(aggregate);
Assert.IsInstanceOfType(aggregate.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(aggregate.InnerExceptions[1], typeof(DivideByZeroException));
}
catch (Exception exception)
{
Trace.WriteLine($"Just a single exception: ${exception.Message}");
}
AggregateException
. Якби ви використалиTask.Wait
замість того, щобawait
у вашому прикладі, ви впіймали бAggregateException