Start Debugging

Fix: AggregateException "One or more errors occurred" beim Warten auf Task.WhenAll in C#

await Task.WhenAll wirft nur einen der Fehler erneut. Speichern Sie den WhenAll-Task in einer Variablen und lesen Sie Exception.InnerExceptions, um alle Fehler zu sehen.

Wenn mehrere Tasks in einem Task.WhenAll fehlschlagen, endet der zurückgegebene Task fehlerhaft mit einer AggregateException, deren Meldung “One or more errors occurred” lautet. Das await packt sie jedoch aus und wirft genau eine der inneren Exceptions erneut. Alle anderen Fehler werden stillschweigend verworfen und erreichen Ihren catch-Block nie. Der Fix besteht darin, den von Task.WhenAll zurückgegebenen Task in einer lokalen Variablen zu behalten, ihn innerhalb eines try zu erwarten und im catch whenAll.Exception.InnerExceptions zu lesen. Wenn Sie den Typ AggregateException wörtlich in einem catch sehen, blockieren Sie mit .Wait() oder .Result, statt zu warten, und das ist ein eigenes, schlimmeres Problem. Verifiziert unter .NET 11 (Microsoft.NET.Sdk 11.0.0, C# 14), das Laufzeitverhalten gemessen unter .NET 10.0.5; der relevante Laufzeitcode ist auf den Branches release/10.0 und main byteidentisch.

Der Fehler im Kontext

Blockierendes Warten auf den WhenAll-Task liefert die Hülle direkt:

Unhandled exception. System.AggregateException: One or more errors occurred. (Connection refused) (The operation has timed out.)
 ---> System.Net.Http.HttpRequestException: Connection refused
   at OrderSync.FetchAsync(String url)
   --- End of inner exception stack trace ---
   at System.Threading.Tasks.Task.ThrowIfExceptional(Boolean includeTaskCanceledExceptions)
   at System.Threading.Tasks.Task.Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken)

Mit await erhalten Sie überhaupt keine AggregateException, sondern nur eine der inneren Exceptions:

Unhandled exception. System.Net.Http.HttpRequestException: Connection refused
   at OrderSync.FetchAsync(String url)
   at OrderSync.SyncAllAsync()

Beides ist dieselbe zugrunde liegende Situation. Diese zwei Erscheinungsformen sind der Grund, warum Suchanfragen zu diesem Fehler auf widersprüchliche Ratschläge stoßen.

Warum await alle Fehler bis auf einen verbirgt

Task.WhenAll ist so dokumentiert, dass es im Zustand Faulted endet, “wobei seine Exceptions die Aggregation der Menge ausgepackter Exceptions aus jedem der übergebenen Tasks enthalten”. Diese Aggregation liegt in der Eigenschaft Exception des zurückgegebenen Tasks und enthält tatsächlich jeden Fehler.

Der Verlust passiert eine Ebene darüber. await ist so spezifiziert, dass es die Exception eines Tasks ausgepackt erneut wirft, sodass Sie bei einem einzelnen fehlgeschlagenen Task HttpRequestException statt AggregateException fangen. Dieses Auspacken ist der richtige Standard: Nahezu jede asynchrone API erzeugt höchstens einen Fehler, und catch (AggregateException ae) { ae.InnerException ... } um jedes await wäre unerträglich. Task.WhenAll ist die wichtigste API, bei der diese Annahme bricht, und der Awaiter hat keine Möglichkeit zu signalisieren, dass es vier waren. Er nimmt eine Exception Dispatch Info aus der Liste und wirft sie erneut. Das wurde als dotnet/runtime#31494 und erneut als dotnet/runtime#47605 angesprochen, mit der Bitte um ein optionales await, das die gesamte Aggregation weitergibt. Keines davon wurde ausgeliefert, also bleibt der Workaround unten die Antwort.

Die Folgerung betrifft Ihre catch-Klauseln: Nach await Task.WhenAll(...) greift ein catch (AggregateException) nie. Wenn Sie eines geschrieben haben, ist es toter Code, und die echte Exception zieht daran vorbei.

Minimale Reproduktion

// .NET 11, C# 14
static async Task FailAsync(string message)
{
    await Task.Delay(10);
    throw new InvalidOperationException(message);
}

try
{
    await Task.WhenAll(FailAsync("first"), FailAsync("second"), FailAsync("third"));
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);   // prints one message, not three
}

Drei Fehler gehen hinein, einer kommt heraus. Nichts im catch-Block kann die anderen beiden wiederherstellen, denn die einzige Referenz auf die Aggregation war die temporäre Variable, die Task.WhenAll zurückgab und die await verbraucht hat.

Fix 1: den WhenAll-Task behalten und InnerExceptions lesen

Das ist der Fix für die überwiegende Mehrheit der Fälle, und die einzige Änderung ist eine lokale Variable:

// .NET 11, C# 14
Task whenAll = Task.WhenAll(FailAsync("first"), FailAsync("second"), FailAsync("third"));

try
{
    await whenAll;
}
catch
{
    // whenAll.Exception is the AggregateException the await threw away
    foreach (Exception inner in whenAll.Exception!.InnerExceptions)
    {
        _logger.LogError(inner, "Sync step failed");
    }
    throw;
}

whenAll.Exception ist genau dann nicht null, wenn whenAll.Status == TaskStatus.Faulted gilt, und die Sammlung InnerExceptions enthält einen Eintrag pro fehlgeschlagenem Task, jeweils mit unversehrtem ursprünglichem Stack Trace. Das leere catch mit einem throw erhält das bisherige Verhalten für Aufrufer (sie sehen weiterhin eine einzelne ausgepackte Exception) und gibt Ihnen zugleich volle Genauigkeit im Log.

Zwei Details machen das mechanisch anwendbar. Erstens: Legen Sie den Aufruf Task.WhenAll(...) nicht in das try. Es wirft das await, nicht der Aufruf, aber die Zuweisung außerhalb zu halten macht die Variable im catch sichtbar. Zweitens: Verwenden Sie catch oder catch (Exception), nicht catch (AggregateException), aus dem im vorherigen Abschnitt genannten Grund.

Fix 2: den WhenAll-Task gar nicht erst fehlschlagen lassen

Wenn Ihr Fan-out ein Batch ist, bei dem Teilfehler normal sind, besteht der sauberere Entwurf darin, Exceptions gar nicht aus den einzelnen Tasks entkommen zu lassen. Kapseln Sie jede Arbeitseinheit so, dass sie ihr Ergebnis zurückgibt, statt zu werfen:

// .NET 11, C# 14
static async Task<(int Id, Exception? Error)> RunSafeAsync(int id, Func<Task> work)
{
    try
    {
        await work();
        return (id, null);
    }
    catch (Exception ex)
    {
        return (id, ex);
    }
}

var results = await Task.WhenAll(orders.Select(o => RunSafeAsync(o.Id, () => SyncAsync(o))));

foreach (var (id, error) in results.Where(r => r.Error is not null))
{
    _logger.LogError(error, "Order {OrderId} failed", id);
}

Task.WhenAll läuft jetzt immer bis zum Ende durch, also gibt es keine Aggregation auszupacken, keinen Exception-Filter richtig zu treffen, und die Zuordnung zwischen jedem Fehler und dem verursachenden Element bleibt erhalten. Genau diese Zuordnung kann Fix 1 nicht liefern: InnerExceptions ist eine flache Liste von Exceptions ohne Rückverweis auf den Task, der sie erzeugt hat. Wenn Sie die Fehler wiederholen oder melden müssen, welche Datensätze abgelehnt wurden, nehmen Sie diese Form.

Der Preis ist, dass ein wirklich fataler Fehler sich nicht mehr von selbst fortpflanzt. Entscheiden Sie ausdrücklich, was passiert, wenn results Fehler enthält, sonst haben Sie einen stillen Fehlschlag gebaut.

Fix 3: die gesamte Aggregation absichtlich erneut werfen

Wenn der Aufrufer wirklich jeden Fehler sehen soll, werfen Sie die Aggregation erneut, statt await einen auswählen zu lassen. ExceptionDispatchInfo erhält die ursprünglichen Stack Traces:

// .NET 11, C# 14
using System.Runtime.ExceptionServices;

public static async Task WhenAllWithAggregateAsync(IEnumerable<Task> tasks)
{
    Task whenAll = Task.WhenAll(tasks);
    try
    {
        await whenAll;
    }
    catch
    {
        ExceptionDispatchInfo.Capture(whenAll.Exception!).Throw();
    }
}

Aufrufer dieses Helpers bekommen eine AggregateException mit jeder inneren Exception, und genau danach greifen Leute meist, wenn sie nach einem await ein catch (AggregateException) schreiben. Setzen Sie das an einer Grenze ein, an der eine einzelne logische Operation tatsächlich auf mehrere Arten gleichzeitig fehlgeschlagen ist, etwa bei einem Batch-Import, der alle Validierungsfehler melden muss. Machen Sie es nicht zum Standard: Es drängt die Behandlung von AggregateException in jeden Aufrufer, und genau dieses Ergonomieproblem sollte das Auspacken durch await beseitigen.

Welche Exception wirft await tatsächlich?

Hier liegen die meisten bestehenden Antworten falsch, auch die, die “die erste Exception” sagen. Es hängt davon ab, welche Überladung Sie aufgerufen haben, und der Unterschied ist deterministisch.

// .NET 10.0.5, C# 14 -- three tasks that fail at staggered times,
// slowest one first in argument order
static async Task FailAfterAsync(int ms, string message)
{
    await Task.Delay(ms);
    throw new InvalidOperationException(message);
}

static async Task<int> FailAfterIntAsync(int ms, string message)
{
    await Task.Delay(ms);
    throw new InvalidOperationException(message);
}

// non-generic overload -> Task
var nonGeneric = Task.WhenAll(
    FailAfterAsync(150, "index0-slow"),
    FailAfterAsync(80,  "index1-medium"),
    FailAfterAsync(10,  "index2-fast"));
// await throws:    index2-fast
// InnerExceptions: index2-fast, index1-medium, index0-slow

// generic overload -> Task<int[]>
var generic = Task.WhenAll(
    FailAfterIntAsync(150, "index0-slow"),
    FailAfterIntAsync(80,  "index1-medium"),
    FailAfterIntAsync(10,  "index2-fast"));
// await throws:    index0-slow
// InnerExceptions: index0-slow, index1-medium, index2-fast

Das nicht generische Task.WhenAll ordnet InnerExceptions nach Abschlusszeitpunkt. Das generische Task.WhenAll<TResult> ordnet sie nach Argumentposition. Beide werfen InnerExceptions[0]. Dieses Ergebnis war über wiederholte Läufe unter .NET 10.0.5 stabil.

Die Ursache ist im Laufzeit-Quellcode sichtbar. Beide Promises stehen in Task.cs. Die nicht generische WhenAllPromise behält das Eingabe-Array bewusst nicht; ihr Abschluss-Callback Invoke hängt jeden fehlgeschlagenen Task an eine Liste an, sobald er fertig ist, und läuft anschließend über diese Liste:

// dotnet/runtime, Task.WhenAllPromise.Invoke
if (failedOrCanceled is List<Task> list)
{
    foreach (Task task in list) { HandleTask(task); }
}

Die generische WhenAllPromise<T> behält das Array, weil sie die T[]-Ergebnisse in Reihenfolge liefern muss, und iteriert es per Index:

// dotnet/runtime, Task.WhenAllPromise<T>.Invoke
for (int i = 0; i < m_tasks.Length; i++)
{
    Task<T>? task = m_tasks[i];
    if (task.IsFaulted) { observedExceptions ??= new(); observedExceptions.AddRange(task.GetExceptionDispatchInfos()); }
    ...
}

Diese Abweichung trat in .NET 8 auf und wurde als dotnet/runtime#93504 gemeldet, nachdem der nicht generische Pfad aus Allokationsgründen neu geschrieben worden war. Sie wurde als “not planned” geschlossen und steht nicht in der Dokumentation der Breaking Changes. Praktisch heißt das: Schreiben Sie nie Code, der davon abhängt, welcher Fehler aus einem await Task.WhenAll auftaucht. Lesen Sie die ganze Liste, wie in Fix 1.

Abbrüche verschwinden, sobald irgendetwas fehlschlägt

Der andere stille Verlust ist der Abbruch. Wenn ein Task abgebrochen wird und ein anderer fehlschlägt, trägt der abgebrochene nichts bei:

// .NET 10.0.5
var mixed = Task.WhenAll(canceledTask, faultingTask);
try { await mixed; } catch (Exception ex) { /* InvalidOperationException */ }

// mixed.Status                          -> Faulted
// mixed.Exception.InnerExceptions.Count -> 1   (the cancellation is gone)

Beide Promise-Implementierungen führen canceledTask in einer separaten lokalen Variablen und rufen TrySetCanceled nur auf, wenn die Exception-Liste leer ist. Das entspricht der dokumentierten Regel: Fehlschlag schlägt Abbruch, und Abbruch schlägt Erfolg. Schlägt nichts fehl und wird mindestens ein Task abgebrochen, endet der WhenAll-Task als Canceled, seine Eigenschaft Exception ist null, und await wirft eine TaskCanceledException. Code, der whenAll.Exception!.InnerExceptions ohne Prüfung von Status aufruft, läuft genau dann in eine NullReferenceException, also sichern Sie ihn ab:

// .NET 11, C# 14
catch (Exception ex)
{
    if (whenAll.Exception is { } aggregate)
    {
        foreach (var inner in aggregate.InnerExceptions) _logger.LogError(inner, "Step failed");
    }
    else
    {
        _logger.LogWarning(ex, "Batch was canceled");
    }
    throw;
}

Einen echten Abbruch von einem als Abbruch verkleideten Timeout zu unterscheiden, ist eine eigene Falle, behandelt in warum HttpClient eine TaskCanceledException wirft.

Stolperfallen und Varianten

Das Denkmodell in einem Satz: Task.WhenAll sammelt jeden Fehler treu, und await ist der verlustbehaftete Schritt. Geben Sie dem zurückgegebenen Task einen Namen, dann geht nichts verloren.

Verwandte Artikel

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück