Menu

C# async await: Task, Task<T>, WhenAll und typische Fallen

Mit async und await kann C#-Code auf langsame Operationen warten, ohne einen Thread zu blockieren. Lerne, wie eine async-Methode läuft, Task und Task<T>, unabhängige Operationen mit Task.WhenAll gleichzeitig ausführen, Exceptions in async-Code und warum async void und .Result Bugs und Deadlocks verursachen.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Die meisten langsamen Operationen in einem Programm sind Wartezeiten: darauf, dass ein Webserver antwortet, eine Datenbank Zeilen liefert, eine Datei gelesen wird. Mit async und await kann eine Methode während einer solchen Wartezeit pausieren, ohne einen Thread festzuhalten, und dann dort weitermachen, wo sie aufgehört hat. Der Code liest sich trotzdem von oben nach unten wie gewöhnlicher Code.

Eine erste async-Methode

Eine async-Methode gibt Task (kein Ergebnis) oder Task<T> (ein Ergebnis vom Typ T) zurück. Darin wartet await auf einen anderen Task und gibt dir sein Ergebnis.

Ausgabe:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync sagt return 2.50m, und der Compiler verpackt das in den Task<decimal>, den die Methode zurückgibt. await packt ihn wieder aus. Task.Delay ist die async-Version von Thread.Sleep: Es ist nach Ablauf der Zeit fertig, ohne in der Zwischenzeit einen Thread zu blockieren.

Seit C# 7.1 kann Main selbst async sein, und so würdest du dieses Programm in modernem .NET schreiben:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

Die ausführbaren Beispiele auf dieser Seite rufen eine async-Methode RunAsync aus einem normalen Main mit .GetAwaiter().GetResult() auf, was der Compiler für ein async Main erzeugt. In einer Konsolen-App ist das sicher; der Abschnitt zu Deadlocks erklärt, warum derselbe Aufruf in UI-Code gefährlich ist.

Was bei einem await passiert

Eine async-Methode läuft synchron bis zu ihrem ersten await auf einen unfertigen Task. An dieser Stelle gibt sie ihrem Aufrufer einen Task zurück, und der Rest der Methode wird zu einer Fortsetzung, die läuft, wenn der abgewartete Task fertig ist.

Ausgabe:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

„Download: starting“ wird ausgegeben, bevor DownloadAsync zurückkehrt, weil alles bis zum ersten await auf dem Thread des Aufrufers läuft. Dann gibt die Methode einen unfertigen Task zurück, der Aufrufer macht weiter, und „Download: finished“ erscheint erst, wenn die Verzögerung vorbei ist. Eine async-Methode aufzurufen startet sie; sie abzuwarten ist der Weg, auf das Ergebnis zu warten.

async erzeugt keinen Thread. Während die Methode pausiert, wartet überhaupt kein Thread auf sie, deshalb kann ein Server tausende Anfragen auf eine Datenbank warten lassen und dabei nur eine Handvoll Threads verwenden. Für CPU-lastige Arbeit, die auf einem anderen Thread laufen soll, nimm Task.Run, behandelt unter Tasks.

Operationen gleichzeitig mit Task.WhenAll ausführen

Einen Aufruf nach dem anderen abzuwarten ist sequenziell: Jeder startet erst, nachdem der vorherige fertig ist. Wenn die Operationen nicht voneinander abhängen, starte sie alle und warte sie dann gemeinsam mit Task.WhenAll ab:

Beispielausgabe:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

Die sequenzielle Version braucht die Summe der drei Wartezeiten, die nebenläufige ungefähr so lange wie die langsamste. Task.WhenAll gibt die Ergebnisse in derselben Reihenfolge wie die übergebenen Tasks zurück, egal welcher zuerst fertig war. Es funktioniert auch mit einer Liste, der üblichen Form für „hol jedes Element“: await Task.WhenAll(ids.Select(id => FetchAsync(id))).

Task.WhenAny ist das Gegenstück, das fertig ist, sobald der erste Task fertig ist, nützlich für Timeouts und „die erste Antwort gewinnt“.

Exceptions in async-Code

Eine in einer async-Methode geworfene Exception wird im zurückgegebenen Task gespeichert und erneut geworfen, wenn der Task abgewartet wird, ein gewöhnliches try/catch um das await funktioniert also:

Ausgabe:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

Der Aufruf von LoadProfileAsync(-1) hat nicht geworfen: Die Exception lebt im Task, bis etwas ihn abwartet. Ein Task, den nie jemand abwartet, verbirgt seine Exception vollständig, ein weiterer Grund, immer abzuwarten, was du startest. Bei WhenAll wirft await die erste Exception erneut, während die Property Exception des kombinierten Tasks (eine AggregateException) alle enthält. ok.Result zu lesen ist dort in Ordnung, weil ok bereits fertig ist.

async void: nur für Event-Handler

Eine async-Methode kann auch void zurückgeben. Vermeide das überall außer bei Event-Handlern:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

Bei async void gibt es keinen Task zum Abwarten, der Aufrufer macht also weiter, bevor die Arbeit erledigt ist, und eine darin geworfene Exception wird direkt im Synchronisationskontext (oder im Thread-Pool) ausgelöst, wo kein try/catch eines Aufrufers sie erreicht. In einer Konsolen- oder Server-App beendet das meist den Prozess. Fang in einem async void Event-Handler alles selbst ab.

Ein verwandter Fehler ist, eine async-Methode ohne await aufzurufen. Der Compiler warnt (CS4014), und die Methode läuft im Hintergrund, ohne dass jemand ihr Ergebnis oder ihre Fehler beobachtet.

Deadlocks durch .Result und .Wait()

Mit .Result oder .Wait() auf einen Task zu blockieren ist die Stelle, an der async-Code am häufigsten schiefgeht. In einer Anwendung mit Synchronisationskontext (WinForms, WPF, MAUI, klassisches ASP.NET) ist der Ablauf:

  1. Der UI-Thread ruft GetDataAsync().Result auf und blockiert, während er auf den Task wartet.
  2. In GetDataAsync ist ein await fertig. Standardmäßig muss seine Fortsetzung in dem Kontext laufen, in dem sie begonnen hat: dem UI-Thread.
  3. Der UI-Thread ist in Schritt 1 blockiert, die Fortsetzung läuft also nie, der Task wird nie fertig, und Schritt 1 endet nie.

Die App friert ohne Exception ein. Konsolen-Apps und ASP.NET Core haben keinen solchen Kontext, deshalb funktioniert derselbe Code in einem Testprogramm und hängt in einer Desktop-App. Die Lösungen:

  • Nimm await die ganze Aufrufkette hinauf, statt zu blockieren („async all the way“). Event-Handler können dafür async void sein.
  • In Bibliothekscode, der nicht in den Kontext des Aufrufers zurückkehren muss, schreibe await SomethingAsync().ConfigureAwait(false);. Die Fortsetzung läuft dann im Thread-Pool statt im erfassten Kontext, und die Bibliothek spart sich einen unnötigen Threadwechsel. Einen blockierenden Aufrufer schützt das nur, wenn jedes await in der Kette dasselbe tut, betrachte es also als gute Praxis für Bibliotheken, nicht als Lösung für .Result.

Anwendungscode in ASP.NET Core braucht kein ConfigureAwait(false), weil es keinen Kontext gibt, in den er zurückkehren müsste.

Häufige Fehler

  • Unabhängige Aufrufe einzeln abwarten. Starte sie, dann await Task.WhenAll(...).
  • async void-Methoden. Gib Task zurück; behalte async void für Event-Handler.
  • .Result und .Wait() in UI- oder klassischem ASP.NET-Code. Sie verursachen Deadlocks; warte stattdessen mit await.
  • await vergessen. Die Arbeit läuft unbeobachtet, und ihre Exceptions verschwinden.
  • I/O in Task.Run verpacken. Eine async-API wie File.ReadAllTextAsync oder HttpClient.GetStringAsync gibt den Thread bereits frei; Task.Run fügt nur einen Umweg über den Thread-Pool hinzu.
  • Annehmen, dass async „läuft auf einem anderen Thread“ bedeutet. Es bedeutet „kann pausieren, ohne zu blockieren“. Nimm Task.Run für CPU-lastige Arbeit.

Häufig gestellte Fragen

Wie funktionieren async und await in C#?

Eine als async markierte Methode darf await verwenden. Erreicht die Methode ein await auf einen noch nicht fertigen Task, kehrt sie sofort zu ihrem Aufrufer zurück und gibt ihm einen Task, der den Rest der Arbeit darstellt. Wenn die abgewartete Operation fertig ist, macht die Methode nach dem await weiter. Während des Wartens ist kein Thread blockiert.

Was ist der Unterschied zwischen Task und Task<T>?

Task steht für eine Operation, die keinen Wert erzeugt, die async-Version einer void-Methode. Task<T> steht für eine, die ein T erzeugt: await auf einen Task<int> gibt dir das int. Eine als async Task<int> deklarierte Methode schreibt einfach return 42;, und der Compiler verpackt das in den Task.

Wie führe ich mehrere async-Operationen gleichzeitig aus?

Starte sie zuerst alle und warte sie dann gemeinsam ab: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. await GetUserAsync(); await GetOrdersAsync(); zu schreiben führt sie nacheinander aus, die Gesamtzeit ist also die Summe statt der längsten.

Warum ist async void in C# schlecht?

Eine async void-Methode lässt sich nicht abwarten, der Aufrufer weiß also nicht, wann sie fertig ist, und eine darin geworfene Exception kann der Aufrufer nicht abfangen: Sie wird im Synchronisationskontext ausgelöst und lässt den Prozess meist abstürzen. Gib stattdessen Task zurück. async void existiert nur für Event-Handler, deren Signatur void verlangt.

Warum verursacht .Result oder .Wait() einen Deadlock?

In einer App mit Synchronisationskontext (WinForms, WPF, klassisches ASP.NET) blockiert .Result den UI- oder Request-Thread, während die Fortsetzung der abgewarteten Methode darauf wartet, genau auf diesem Thread zu laufen. Jeder wartet ewig auf den anderen. Nimm durchgehend await statt zu blockieren und in Bibliothekscode ConfigureAwait(false).

Kann Main in C# async sein?

Ja, seit C# 7.1: static async Task Main() oder static async Task<int> Main(). Top-Level-Anweisungen (C# 9) können await direkt verwenden. In älterem Code ist die Entsprechung, MainAsync().GetAwaiter().GetResult() aus einem normalen Main aufzurufen, was dort sicher ist, weil eine Konsolen-App keinen Synchronisationskontext hat.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S