Dlaczego nie czeka na Task.WhenAll zgłasza wyjątek AggregateException?

105

W tym kodzie:

private async void button1_Click(object sender, EventArgs e) {
    try {
        await Task.WhenAll(DoLongThingAsyncEx1(), DoLongThingAsyncEx2());
    }
    catch (Exception ex) {
        // Expect AggregateException, but got InvalidTimeZoneException
    }
}

Task DoLongThingAsyncEx1() {
    return Task.Run(() => { throw new InvalidTimeZoneException(); });
}

Task DoLongThingAsyncEx2() {
    return Task.Run(() => { throw new InvalidOperation();});
}

Spodziewałem WhenAllsię, że utworzę i wyrzucę AggregateException, ponieważ co najmniej jedno z zadań, na które oczekiwał, rzuciło wyjątek. Zamiast tego otrzymuję pojedynczy wyjątek zgłoszony przez jedno z zadań.

Nie WhenAllzawsze tworzy AggregateException?

Michael Ray Lovett
źródło
8
WhenAll ma tworzyć AggregateException. Gdybyś użył Task.Waitzamiast awaitw swoim przykładzie, złapałbyśAggregateException
Peter Ritchie
2
+1, właśnie to próbuję zrozumieć, zaoszczędź mi godzin na debugowaniu i wyszukiwaniu w Google.
kennyzx
Po raz pierwszy od kilku lat potrzebowałem wszystkich wyjątków Task.WhenAlli wpadłem w tę samą pułapkę. Więc spróbowałem zagłębić się w szczegóły dotyczące tego zachowania.
noseratio

Odpowiedzi:

78

Nie pamiętam dokładnie gdzie, ale gdzieś przeczytałem, że z nowymi słowami kluczowymi async / await rozpakowują one AggregateExceptionw rzeczywisty wyjątek.

Tak więc w bloku catch otrzymujesz rzeczywisty wyjątek, a nie zagregowany. Pomaga nam to pisać bardziej naturalny i intuicyjny kod.

Było to również potrzebne do łatwiejszej konwersji istniejącego kodu do korzystania z async / await, gdzie duża część kodu oczekuje określonych wyjątków, a nie zagregowanych wyjątków.

-- Edytować --

Rozumiem:

An Async Primer autorstwa Billa Wagnera

Bill Wagner powiedział: (w When Exceptions Happen )

... Gdy używasz await, kod wygenerowany przez kompilator rozpakowuje AggregateException i zgłasza bazowy wyjątek. Wykorzystując await, unikasz dodatkowej pracy związanej z obsługą typu AggregateException używanego przez Task.Result, Task.Wait i inne metody Wait zdefiniowane w klasie Task. To kolejny powód, dla którego warto używać await zamiast podstawowych metod Task ....

decyklon
źródło
3
Tak, wiem, że nastąpiły pewne zmiany w obsłudze wyjątków, ale najnowsza dokumentacja dla Task.WhenAll stan „Jeśli którekolwiek z dostarczonych zadań zakończy się w stanie błędu, zwrócone zadanie również zakończy się w stanie błędu, w którym jego wyjątki będą zawierać agregacja zestawu nieopakowanych wyjątków z każdego z dostarczonych zadań ".... W moim przypadku oba moje zadania kończą się z błędem ...
Michael Ray Lovett
4
@MichaelRayLovett: Nigdzie nie przechowujesz zwróconego zadania. Założę się, że kiedy spojrzysz na właściwość Exception tego zadania, otrzymasz AggregateException. Ale w swoim kodzie używasz await. To sprawia, że ​​AggregateException ma zostać rozpakowany do rzeczywistego wyjątku.
decyclone
3
Też o tym myślałem, ale pojawiły się dwa problemy: 1) Nie potrafię wymyślić, jak zapisać zadanie, więc mogę je sprawdzić (np. „Task myTask = await Task.WhenAll (...)” nie Wydaje się, że nie działa. i 2) Myślę, że nie widzę, jak await może kiedykolwiek reprezentować wiele wyjątków jako jeden wyjątek .. który wyjątek powinien zgłosić? Wybierz przypadkowo?
Michael Ray Lovett,
2
Tak, kiedy przechowuję zadanie i sprawdzam je w try / catch of the await, widzę, że jego wyjątkiem jest AggregatedException. Więc dokumenty, które przeczytałem, mają rację; Task.WhenAll opakowuje wyjątki w AggregateException. Ale potem czekaj na ich rozpakowanie. Czytam teraz twój artykuł, ale nie widzę jeszcze, jak await może wybrać pojedynczy wyjątek z AggregateExceptions i rzucić ten jeden z drugim…
Michael Ray Lovett
3
Przeczytaj artykuł, dzięki. Ale nadal nie rozumiem, dlaczego await reprezentuje AggregateException (reprezentujący wiele wyjątków) jako tylko jeden wyjątek. Jak to jest kompleksowa obsługa wyjątków? .. Wydaje mi się, że gdybym chciał wiedzieć dokładnie, które zadania zrzuciły wyjątki, a które z nich, musiałbym zbadać obiekt Task utworzony przez Task.WhenAll ??
Michael Ray Lovett
59

Wiem, że jest to pytanie, na które już udzielono odpowiedzi, ale wybrana odpowiedź tak naprawdę nie rozwiązuje problemu OP, więc pomyślałem, że opublikuję to.

To rozwiązanie zapewnia zagregowany wyjątek (tj. Wszystkie wyjątki, które zostały zgłoszone przez różne zadania) i nie blokuje (przepływ pracy jest nadal asynchroniczny).

async Task Main()
{
    var task = Task.WhenAll(A(), B());

    try
    {
        var results = await task;
        Console.WriteLine(results);
    }
    catch (Exception)
    {
        if (task.Exception != null)
        {
            throw task.Exception;
        }
    }
}

public async Task<int> A()
{
    await Task.Delay(100);
    throw new Exception("A");
}

public async Task<int> B()
{
    await Task.Delay(100);
    throw new Exception("B");
}

Kluczem jest zapisanie odwołania do zadania zagregowanego, zanim zaczekasz, a następnie możesz uzyskać dostęp do jego właściwości Exception, która przechowuje Twój AggregateException (nawet jeśli tylko jedno zadanie zgłosiło wyjątek).

Mam nadzieję, że to jest nadal przydatne. Wiem, że miałem dzisiaj ten problem.

Richiban
źródło
Doskonała jasna odpowiedź, to powinna być wybrana przez IMO.
bytedev
3
+1, ale czy nie możesz po prostu umieścić throw task.Exception;wewnątrz catchbloku? (Wprawia mnie w zakłopotanie, gdy widzę pusty haczyk, gdy wyjątki są faktycznie obsługiwane.)
AnorZaken,
@AnorZaken Absolutnie; Nie pamiętam, dlaczego pierwotnie tak to napisałem, ale nie widzę żadnych wad, więc przeniosłem to w bloku catch. Dzięki
Richiban,
Jedną drobną wadą tego podejścia jest to, że status anulowania ( Task.IsCanceled) nie jest poprawnie propagowany. To może być rozwiązuje przy użyciu pomocnika wewnętrzny jak ten .
noseratio
35

Możesz przejść przez wszystkie zadania, aby sprawdzić, czy więcej niż jedno zgłosiło wyjątek:

private async Task Example()
{
    var tasks = new [] { DoLongThingAsyncEx1(), DoLongThingAsyncEx2() };

    try 
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception ex) 
    {
        var exceptions = tasks.Where(t => t.Exception != null)
                              .Select(t => t.Exception);
    }
}

private Task DoLongThingAsyncEx1()
{
    return Task.Run(() => { throw new InvalidTimeZoneException(); });
}

private Task DoLongThingAsyncEx2()
{
    return Task.Run(() => { throw new InvalidOperationException(); });
}
jgauffin
źródło
2
to nie działa. WhenAllkończy działanie po pierwszym wyjątku i zwraca go. patrz: stackoverflow.com/questions/6123406/waitall-vs-whenall
jenson-button-event
15
Poprzednie dwa komentarze są nieprawidłowe. Kod faktycznie działa i exceptionszawiera oba zgłoszone wyjątki.
Tobias
DoLongThingAsyncEx2 () musi zgłosić nowy InvalidOperationException () zamiast nowego InvalidOperation ()
Artemious
9
Aby rozwiać wszelkie wątpliwości, przygotowałem obszerne skrzypce, które, mam nadzieję, pokazują dokładnie, jak wygląda ta obsługa: dotnetfiddle.net/X2AOvM . Możesz zobaczyć, że awaitpowoduje rozpakowanie pierwszego wyjątku, ale wszystkie wyjątki są rzeczywiście nadal dostępne za pośrednictwem tablicy Tasks.
nuclearpidgeon
13

Pomyślałem, że rozszerzę odpowiedź @ Richiban, aby powiedzieć, że możesz również obsłużyć AggregateException w bloku catch, odwołując się do niego z zadania. Na przykład:

async Task Main()
{
    var task = Task.WhenAll(A(), B());

    try
    {
        var results = await task;
        Console.WriteLine(results);
    }
    catch (Exception ex)
    {
        // This doesn't fire until both tasks
        // are complete. I.e. so after 10 seconds
        // as per the second delay

        // The ex in this instance is the first
        // exception thrown, i.e. "A".
        var firstExceptionThrown = ex;

        // This aggregate contains both "A" and "B".
        var aggregateException = task.Exception;
    }
}

public async Task<int> A()
{
    await Task.Delay(100);
    throw new Exception("A");
}

public async Task<int> B()
{
    // Extra delay to make it clear that the await
    // waits for all tasks to complete, including
    // waiting for this exception.
    await Task.Delay(10000);
    throw new Exception("B");
}
Daniel Šmon
źródło
11

Myślisz o Task.WaitAll- rzuca AggregateException.

WhenAll po prostu zgłasza pierwszy wyjątek z listy wyjątków, które napotka.

Mohit Datta
źródło
3
To jest nieprawidłowe, zadanie zwrócone z WhenAllmetody ma Exceptionwłaściwość AggregateExceptionzawierającą wszystkie wyjątki zgłoszone w pliku InnerExceptions. To, co się tutaj dzieje, to awaitrzucanie pierwszego wewnętrznego wyjątku zamiast AggregateExceptionsamego siebie (jak powiedział decyklon). Wywołanie metody zadania Waitzamiast czekania powoduje zgłoszenie oryginalnego wyjątku.
Şafak Gür
5

Wiele dobrych odpowiedzi tutaj, ale nadal chciałbym opublikować mój rant, ponieważ właśnie natknąłem się na ten sam problem i przeprowadziłem badania. Lub przejdź do wersji TLDR poniżej.

Problem

Oczekiwanie na taskzwrócone przez Task.WhenAlltylko zgłasza pierwszy wyjątek AggregateExceptionprzechowywanego w task.Exception, nawet jeśli wystąpił błąd wielu zadań.

Te obecne docs dlaTask.WhenAll słownie:

Jeśli którekolwiek z dostarczonych zadań zakończy się w stanie błędu, zwrócone zadanie również zakończy się w stanie Niepowodzenie, w którym jego wyjątki będą zawierać agregację zestawu nieopakowanych wyjątków z każdego z podanych zadań.

Co jest poprawne, ale nie mówi nic o wspomnianym wcześniej zachowaniu „rozpakowywania”, kiedy oczekuje się zwróconego zadania.

Przypuszczam, że doktorzy nie wspominają o tym, ponieważ to zachowanie nie jest specyficzne dlaTask.WhenAll .

Jest to po prostu Task.Exceptiontyp AggregateExceptioni dla awaitkontynuacji zawsze zostaje rozpakowane jako pierwszy wewnętrzny wyjątek, zgodnie z projektem. Jest to świetne w większości przypadków, ponieważ zwykle Task.Exceptionskłada się tylko z jednego wewnętrznego wyjątku. Ale rozważ ten kod:

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]);
}

W tym przypadku wystąpienie polecenia AggregateExceptionzostaje rozpakowane do pierwszego wewnętrznego wyjątku InvalidOperationExceptionw dokładnie taki sam sposób, w jaki moglibyśmy go mieć Task.WhenAll. Moglibyśmy nie obserwować DivideByZeroException, gdybyśmy nie przeszli task.Exception.InnerExceptionsbezpośrednio.

Stephen Toub z Microsoftu wyjaśnia przyczynę tego zachowania w powiązanym problemie z GitHub :

Chodzi mi o to, że zostało to szczegółowo omówione lata temu, kiedy zostały one pierwotnie dodane. Pierwotnie zrobiliśmy to, co sugerujesz, z Task zwróconym z WhenAll zawierającym pojedynczy AggregateException, który zawierał wszystkie wyjątki, tj. Task.Exception zwrócił opakowanie AggregateException, które zawierało inny AggregateException, który następnie zawierał rzeczywiste wyjątki; wtedy, gdy był oczekiwany, zostanie propagowany wewnętrzny wyjątek AggregateException. Silna informacja zwrotna, którą otrzymaliśmy, która skłoniła nas do zmiany projektu, była taka, że ​​a) zdecydowana większość takich przypadków miała dość jednorodne wyjątki, takie że propagowanie wszystkiego w sumie nie było tak ważne, b) propagowanie agregatu, a następnie przełamanie oczekiwań dotyczących połowów dla określonych typów wyjątków, oraz c) w przypadkach, w których ktoś chciał zagregować, mógł to zrobić wyraźnie z dwoma wierszami, tak jak napisałem. Przeprowadziliśmy również obszerne dyskusje na temat tego, jakie powinno być zachowanie await w odniesieniu do zadań zawierających wiele wyjątków i na tym wylądowaliśmy.

Jeszcze jedna ważna rzecz, na którą należy zwrócić uwagę, to zachowanie podczas rozpakowywania jest płytkie. Oznacza to, że tylko rozpakuje pierwszy wyjątek AggregateException.InnerExceptionsi pozostawi go tam, nawet jeśli jest to wystąpienie innego AggregateException. Może to dodać kolejną warstwę zamieszania. Na przykład zmieńmy w WhenAllWrongten sposób:

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));
}

Rozwiązanie (TLDR)

Wracając do await Task.WhenAll(...)tego, czego osobiście chciałem, to móc:

  • Uzyskaj pojedynczy wyjątek, jeśli tylko jeden został zgłoszony;
  • Uzyskaj, AggregateExceptionjeśli więcej niż jeden wyjątek został zgłoszony łącznie przez jedno lub więcej zadań;
  • Unikaj konieczności zapisywania Taskjedynego w celu sprawdzenia jego Task.Exception;
  • Propagować status anulowania prawidłowo ( Task.IsCanceled), a coś takiego nie zrobi, że: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }.

Przygotowałem do tego następujące rozszerzenie:

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();
    }    
}

Teraz działa tak, jak chcę:

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}");
}
noseratio
źródło
2
Fantastyczna odpowiedź
wypada
-3

To działa dla mnie

private async Task WhenAllWithExceptions(params Task[] tasks)
{
    var result = await Task.WhenAll(tasks);
    if (result.IsFaulted)
    {
                throw result.Exception;
    }
}
Alexey Kulikov
źródło
1
WhenAllto nie to samo co WhenAny. await Task.WhenAny(tasks)zakończy się po zakończeniu dowolnego zadania. Jeśli więc masz jedno zadanie, które kończy się natychmiast i kończy się powodzeniem, a inne zajmuje kilka sekund, zanim zgłosi wyjątek, zostanie ono zwrócone natychmiast bez żadnego błędu.
StriplingWarrior
Wtedy linia rzutu nigdy nie zostanie trafiona tutaj - WhenAll rzuciłby wyjątek
1919