C# async/await-Praxis-Entscheidungstabelle – Task.Run und ConfigureAwait

· · C#, async/await, .NET, Design

C# async / await verwenden wir tagtäglich, aber was in der Praxis eher zu Unsicherheit führt, ist nicht die Syntax selbst, sondern welche Schreibweise in welcher Situation zu wählen ist. Besonders häufig gesucht werden Entscheidungsfragen wie: Wann verwendet man Task.Run? Wo setzt man ConfigureAwait(false)? Darf man fire-and-forget einsetzen?

  • Eine I/O-Wartezeit unnötig mit Task.Run umhüllen
  • Unabhängige Vorgänge trotzdem einzeln, seriell mit await abarbeiten
  • fire-and-forget leichtfertig einsetzen und dabei Ausnahmen und den Zeitpunkt der Beendigung aus dem Blick verlieren
  • ConfigureAwait(false) überall gleich setzen
  • ValueTask nur wählen, weil es „leichtgewichtiger klingt“

Statt das im Einzelnen auswendig zu lernen, verirrt man sich seltener, wenn man damit beginnt, zuerst die Art der Verarbeitung zu erkennen.

Dieser Artikel geht überwiegend von allgemeiner C#-/.NET-Anwendungsentwicklung ab .NET 6 aus und ordnet die Schreibweisen rund um async / await in der Reihenfolge, die Entscheidungen am leichtesten macht.

Dabei denken wir zum Beispiel an folgende Arten von Entwicklung:

  • Desktop-Anwendungen wie WinForms / WPF
  • ASP.NET Core Webanwendungen / APIs
  • Worker / Hintergrunddienste
  • Konsolenanwendungen
  • wiederverwendbare Klassenbibliotheken

Der in diesem Artikel gezeigte Code ist als vollständiges, kompilier- und lauffähiges Beispielset (eine Bibliothek, eine Konsolen-Demo und Unittests, die jedes Muster der Entscheidungstabelle prüfen) auf GitHub veröffentlicht.

csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)

So lesen Sie diesen Artikel

Da der Artikel recht lang ist, hier zuerst Einstiegspunkte nach Zweck.

Zweck Wo Sie lesen sollten
Nur die Entscheidungstabelle sehen Tabelle und Diagramm in 3.1. Das ist der Kern dieses Artikels
Die Schreibweise jedes Musters kennenlernen Ab 3.2. Entspricht 1:1 den Zeilen der Tabelle in 3.1
Den eigenen Code überprüfen Die Antipattern-Tabelle in 5.
Die Review-Kriterien im Team angleichen Die Checkliste in 6.
Nur das Fazit 1.

Inhaltsverzeichnis

  1. Das Fazit zuerst (in einem Satz)
  2. Begriffe in diesem Artikel
    • 2.1. Begriffe, die Sie zuerst unterscheiden sollten
    • 2.2. Häufig vorkommende Begriffe
  3. Die Entscheidungstabelle, die Sie zuerst ansehen sollten
    • 3.1. Der Gesamtüberblick
    • 3.2. Bei I/O-Wartezeiten: die async-API direkt awaiten
    • 3.3. Bei hoher CPU-Last: den Einsatzort von Task.Run wählen
    • 3.4. Bei mehreren unabhängigen Vorgängen: Task.WhenAll
    • 3.5. Um das zuerst Fertige zu verwenden: Task.WhenAny
    • 3.6. Bei vielen Elementen mit begrenztem Parallelitätsgrad: Parallel.ForEachAsync oder SemaphoreSlim
    • 3.7. Um der Reihe nach zu verarbeiten: Channel<T>
    • 3.8. Um in festen Intervallen zu laufen: PeriodicTimer
    • 3.9. Bei sequenziell eintreffenden Daten: IAsyncEnumerable<T>
    • 3.10. Um asynchron freizugeben: await using
    • 3.11. Für gegenseitigen Ausschluss über await hinweg: SemaphoreSlim
    • 3.12. await je nach UI-Code, Anwendungscode oder Bibliothek unterschiedlich schreiben
  4. Grundlegende Schreibregeln
    • 4.1. Rückgabewert zuerst als Task / Task<T>
    • 4.2. async void nur für Event-Handler
    • 4.3. CancellationToken entgegennehmen und nach unten weiterreichen
    • 4.4. Asynchrone APIs bis zum Ende asynchron durchziehen
    • 4.5. Beim Erzeugen von Tasks mit LINQ mit ToArray / ToList festlegen
  5. Häufige Antipatterns
  6. Checkliste für Reviews
  7. Grobe Entscheidungshilfe
  8. Zusammenfassung
  9. Quellen

1. Das Fazit zuerst (in einem Satz)

  • async / await ist eine Schreibweise, die den Thread während des Wartens nicht blockiert – kein Mechanismus, der automatisch alles beschleunigt oder von sich aus auf einen anderen Thread verlagert
  • Unterscheiden Sie zunächst, ob die Verarbeitung eine I/O-Wartezeit oder eine CPU-Berechnung ist
  • Bei einer I/O-Wartezeit ist es die Grundregel, die async-API direkt zu awaiten
  • Bei einer CPU-Berechnung überlegen Sie, wo diese Berechnung laufen soll. In der UI kann Task.Run hilfreich sein, aber bei der Anfrageverarbeitung von ASP.NET Core vermeidet man grundsätzlich, Task.Run sofort zu awaiten
  • Bei mehreren unabhängigen Vorgängen zieht man Task.WhenAll einem seriellen await zuerst in Betracht
  • Sind es viele Elemente, sollte man nicht alles gleichzeitig über Task.WhenAll abfeuern, sondern eine Obergrenze für die Parallelität festlegen
  • fire-and-forget wirkt einfach, ist aber schwer zu verwalten. Wenn die Lebensdauer wirklich vom Aufrufer abgekoppelt werden soll, ist es stabiler, die Arbeit an eine verwaltete Stelle wie einen Channel oder einen HostedService abzugeben
  • Als Rückgabewert zuerst Task / Task<T>. ValueTask wählt man erst, nachdem eine Messung die Notwendigkeit gezeigt hat
  • ConfigureAwait(false) ist in allgemeinem Bibliothekscode wirkungsvoll, aber in UI- und anwendungsseitigem Code genügt zunächst ein gewöhnliches await
  • async void wird außerhalb von Event-Handlern nicht verwendet

Kurz gesagt: Das Wichtigste rund um async / await ist, nicht in „erst mal Task.Run“, „erst mal fire-and-forget“ oder „erst mal ValueTask“ zu verfallen.

Betrachten Sie zunächst:

  1. Worauf wartet dieser Vorgang eigentlich?
  2. Wer besitzt die Lebensdauer dieses Vorgangs?
  3. Wo wird die Anzahl gleichzeitiger Ausführungen gesteuert?

Wenn Sie diese drei Punkte im Blick haben, verringert sich die Unsicherheit erheblich.

2. Begriffe in diesem Artikel

2.1. Begriffe, die Sie zuerst unterscheiden sollten

Trennt man diese beiden Begriffe von Anfang an, führt das deutlich seltener zu Verwirrung.

Begriff Bedeutung hier
I/O-bound Verarbeitung, bei der das Warten auf einen externen Abschluss im Mittelpunkt steht – HTTP, DB, Dateien, Sockets
CPU-bound Verarbeitung, bei der die CPU-Berechnung selbst im Mittelpunkt steht – Kompression, Bildverarbeitung, Hash-Berechnung, aufwendige Transformationen

Besonders wirkungsvoll ist async / await bei I/O-Wartezeiten: Während des Wartens kann der Thread anderer Arbeit zurückgegeben werden. CPU-Berechnung dagegen ist keine „Wartezeit“, sondern tatsächliche Rechenzeit, weshalb hier auf welchem Thread sie läuft und wie der Parallelitätsgrad festgelegt wird zum eigentlichen Thema werden.

2.2. Häufig vorkommende Begriffe

Begriff Bedeutung hier
Blocking Den Thread während des Wartens auf den Abschluss weiterhin belegen
fire-and-forget Eine Startmethode, bei der der Aufrufer nicht auf den Abschluss wartet
SynchronizationContext Der Mechanismus, der festhält, „wo die Fortsetzung von await läuft“. Näheres siehe die Ergänzung unten
backpressure Ein Mechanismus, der die schreibende Seite warten lässt, wenn zu schnell eingespeist wird, um ein übermäßiges Anwachsen zu verhindern
IHostedService Der Mechanismus, mit dem der generische Host von .NET beim Start StartAsync und beim Stoppen StopAsync aufruft. Der Einstiegspunkt für dauerhaft laufende Prozesse, die sich an der Lebensdauer der Anwendung orientieren
BackgroundService Eine abstrakte Klasse, die IHostedService implementiert. Durch das Überschreiben nur einer einzigen Methode, ExecuteAsync(CancellationToken), lässt sich eine dauerhafte Schleife schreiben. Registriert wird sie mit AddHostedService<T>() (3.7)

Wenn Sie Channel<T> verwenden, ist BackgroundService der Ort, an dem die konsumierende Seite (Consumer) sitzt. In 3.7 behandeln wir die Form „in eine Queue einreihen, ein dedizierter Consumer verarbeitet der Reihe nach“ – und BackgroundService ist genau das, was die Lebensdauer dieses Consumers passend zum Start und Stopp der Anwendung verwaltet.

Ergänzung zu SynchronizationContext

Die Frage rund um ConfigureAwait(false) (3.12) läuft letztlich auf das Verständnis genau dieses einen Begriffs hinaus.

  • Bei der Ausführung der Fortsetzung (der Continuation) erfasst await den SynchronizationContext zum Zeitpunkt des Wartebeginns und kehrt zu ihm zurück, um dort fortzufahren (ist kein SynchronizationContext gesetzt, wird geprüft, ob ein von der Voreinstellung abweichender TaskScheduler verwendet wird)
  • WinForms / WPF besitzen einen SynchronizationContext, der die Verarbeitung an den UI-Thread zurückgibt. Deshalb können Sie nach dem await ganz normal auf Steuerelemente zugreifen
  • ASP.NET Core besitzt keinen SynchronizationContext. Es gibt also keinen „Ort, zu dem zurückgekehrt wird“, und die Fortsetzung nach await läuft einfach auf einem beliebigen freien Thread des Thread-Pools weiter
  • ConfigureAwait(false) ist die Angabe, dass die Fortsetzung ausgeführt werden darf, ohne zu diesem erfassten Kontext zurückzukehren

Daraus ergibt sich das Fazit von 3.12: „In UI-Code ist es natürlicher, es nicht zu setzen“, „im Anwendungscode von ASP.NET Core macht es kaum einen Unterschied, ob man es setzt oder nicht“, „in allgemeinen Bibliotheken, bei denen unklar ist, in welchem Kontext sie ausgeführt werden, lohnt es sich, es zu setzen“. Den ausführlichsten Hintergrund dazu liefert der ConfigureAwait FAQ unter 9. Quellen.

Besonders wichtig ist, dass Asynchronität und Parallelität zwei verschiedene Dinge sind.

  • Asynchronität: eine Frage der Art des Wartens
  • Parallelität: eine Frage des gleichzeitigen Fortschreitens

Vermischt man diese beiden, will man Task.Run plötzlich überall einsetzen. Das ist die erste Weggabelung.

3. Die Entscheidungstabelle, die Sie zuerst ansehen sollten

3.1. Der Gesamtüberblick

Beginnt man mit dieser Tabelle, ergibt sich meist schon die grobe Richtung.

Situation Zuerst verwenden Worauf achten
Warten auf HTTP / DB / Dateien u. Ä. die async-API direkt awaiten Nicht mit Task.Run umhüllen
Schwere Berechnung, die die UI nicht blockieren soll Task.Run CPU-Berechnung vom UI-Thread wegnehmen
Anfrageverarbeitung in ASP.NET Core einfaches await Task.Run nicht sofort awaiten
Wenige unabhängige asynchrone Vorgänge Task.WhenAll Erst alle starten, dann gemeinsam warten
Nur das zuerst Fertige verwenden Task.WhenAny An das Abbrechen der übrigen und das Einsammeln von Ausnahmen denken
Viele Elemente, Obergrenze gewünscht Parallel.ForEachAsync / SemaphoreSlim Parallelitätsgrad explizit angeben
Hintergrundverarbeitung, die der Reihe nach laufen soll Channel<T> An begrenzte Queues und backpressure denken
Asynchrone Verarbeitung in festen Intervallen PeriodicTimer Ein Timer, ein Consumer einhalten
Ergebnisse nach und nach verarbeiten IAsyncEnumerable<T> / await foreach Fortfahren, ohne auf den vollständigen Abschluss zu warten
Asynchrones Freigeben nötig await using IAsyncDisposable verwenden
Gegenseitiger Ausschluss über await hinweg SemaphoreSlim.WaitAsync Release unbedingt in try/finally
Allgemeiner Bibliothekscode ConfigureAwait(false) erwägen Nicht von UI-/anwendungsspezifischem Kontext abhängen
JaNeinJaUI-Ereignis / DesktopASP.NET-Core-AnfrageWorker / HintergrundNeinWarten, bis alle fertig sindDas zuerst Fertige verwendenViele ElementeDer Reihe nach verarbeitenFeste IntervalleSequenzieller StreamDie gewünschte VerarbeitungWird auf externes I/O gewartet?Die async-API direkt awaitSchwere CPU-Berechnung?Wo soll sie laufen?Task.Run erwägenNicht mit Task.Run umhüllenbei Bedarf an einen separaten Worker oder eine Queue abgebenVor Ort ausführen oderden Parallelitätsgrad explizit angebenMehrere Aufgaben behandeln?Task.WhenAllTask.WhenAnyParallel.ForEachAsyncoder SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

Im Folgenden betrachten wir die einzelnen Muster der Reihe nach.

3.2. Bei I/O-Wartezeiten: die async-API direkt awaiten

Das ist das grundlegendste Muster.

Bei HTTP, DB, Datei-Lese-/Schreibzugriffen und Ähnlichem prüfen Sie zuerst, ob eine async-Version der API existiert. Gibt es sie, ist es die Grundregel, sie direkt zu awaiten.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

Vermeiden möchte man hier, bereits asynchrones I/O mit Task.Run zu umhüllen.

// Schlechtes Beispiel
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

Das wirft die I/O-Wartezeit lediglich an einen anderen Thread weiter und macht den Code schwerer durchschaubar, ohne einen Vorteil zu bringen.

  • Bei I/O-Wartezeiten braucht man kein Task.Run
  • Zuerst nach einer async-API suchen
  • Erhält man ein Token, gibt man es direkt nach unten weiter

Das ist hier ziemlich bewährter Standard.

3.3. Bei hoher CPU-Last: den Einsatzort von Task.Run wählen

Task.Run lohnt sich, wenn Sie eine CPU-Berechnung vom aktuellen Thread wegverlagern wollen.

Führt man zum Beispiel in einem UI-Event-Handler eine schwere Berechnung direkt aus, friert der Bildschirm ein. In diesem Fall ist Task.Run naheliegend.

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

Wichtig ist hier allerdings, von wo aus Sie aufrufen.

  • UI wie WinForms / WPF: Es gibt Situationen, in denen Task.Run wirkt
  • Anfrageverarbeitung in ASP.NET Core: Vermeiden Sie grundsätzlich, Task.Run sofort zu awaiten
  • Worker / Hintergrundverarbeitung: entweder direkt vor Ort verarbeiten oder den Parallelitätsgrad gezielt entwerfen

Fügt man in die Anfrageverarbeitung von ASP.NET Core eine einzelne Schicht Task.Run ein und awaitet sie sofort, führt das meist nur zu zusätzlicher Planungsarbeit.

Das ist eine leicht missverständliche Stelle, daher die Gründe im Einzelnen. Es ist nicht so, dass „Task.Run sinnlos ist, weil es ohnehin im Thread-Pool läuft“ (das Laufen im Thread-Pool selbst gilt genauso für die Hintergrundverarbeitung einer UI-Anwendung). Die entscheidenden Punkte sind diese zwei:

  • Der Durchsatz steigt nicht. Die Gesamtmenge der CPU-Berechnung ändert sich nicht – der Ort, an dem sie läuft, verschiebt sich lediglich auf einen anderen Thread des Thread-Pools. Die Zahl der gleichzeitig verarbeitbaren Anfragen wächst dadurch nicht
  • Auch keine Freigabe der Wartezeit. Dass Task.Run in einer UI-Anwendung wirkt, liegt daran, dass es einen einzigen speziellen Thread gibt, der freigehalten werden muss (den UI-Thread). Auf der Serverseite gibt es diesen einen Thread nicht. Der ursprüngliche Thread wird zwar tatsächlich freigegeben, dafür ist aber ein anderer Thread für dieselbe Zeit mit der Berechnung belegt – unterm Strich also null

Was bleibt, sind die Kosten für das Einreihen in die Queue und den Thread-Wechsel sowie eine zusätzliche Stufe an Unübersichtlichkeit darüber, „auf welchem Thread gerade gelaufen wird“. Deshalb vermeidet man es.

Für ASP.NET Core ist es daher sinnvoller, so zu denken:

  • Bei I/O-Wartezeiten: einfaches await
  • Bei kurzer CPU-Verarbeitung: direkt vor Ort ausführen
  • Bei langen Vorgängen oder Arbeiten, die von der Lebensdauer der Anfrage abgekoppelt werden sollen: an eine Queue oder einen HostedService abgeben

Ruft man eine API, die es nur synchron gibt, aus der UI auf, kann man Task.Run zugunsten der UI-Reaktionsfähigkeit einsetzen. Das ist jedoch keine „asynchrone I/O“, sondern belegt lediglich einen einzelnen Thread, um das Problem zu umgehen. Auf der Serverseite, etwa bei ASP.NET Core, lässt sich dieser Ausweg grundsätzlich kaum skalieren.

3.4. Bei mehreren unabhängigen Vorgängen: Task.WhenAll

Obwohl mehrere unabhängige asynchrone Vorgänge vorliegen, taucht immer wieder Code auf, der auf sie so einzeln nacheinander wartet.

// Eigentlich unabhängig, aber seriell geschrieben
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

Hängen diese nicht voneinander ab, ist es naheliegender, erst alle zu starten und am Ende gemeinsam zu warten.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

Der Knackpunkt ist ToArray(). LINQ wird verzögert ausgewertet, sodass nach einem bloßen Select unter Umständen noch gar nicht enumeriert wurde. Legt man das einmal mit ToArray() oder ToList() fest, starten zu diesem Zeitpunkt alle Tasks.

Task 3Task 2Task 1AufruferTask 3Task 2Task 1AufruferStartStartStartawait Task.WhenAll(...)FertigFertigFertig

Dieses Muster passt, wenn

  • die Anzahl klein bis mittel ist,
  • Sie auf alle zusammen warten wollen,
  • ein gleichzeitiger Lauf ohne Obergrenze unproblematisch ist.

Bei vielen Elementen ist es sicherer, wie im folgenden 3.6 eine Obergrenze für die Parallelität zu setzen.

3.5. Um das zuerst Fertige zu verwenden: Task.WhenAny

Wenn Sie zum Beispiel unter mehreren Spiegelservern (Mirrors) den zuerst antwortenden verwenden möchten, ist Task.WhenAny naheliegend.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    List<Task<byte[]>> pending = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToList();

    var failures = new List<Exception>();

    try
    {
        while (pending.Count > 0)
        {
            Task<byte[]> finished = await Task.WhenAny(pending);
            pending.Remove(finished);

            try
            {
                byte[] data = await finished;   // Nur bei Erfolg wird diese Stelle verlassen
                cts.Cancel();                   // Erst wenn der Gewinner feststeht, den Rest stoppen
                return data;
            }
            catch (Exception ex)
            {
                // Wenn der Aufrufer abgebrochen hat, ist das kein "Fehlschlag eines Mirrors".
                // Lässt man das hier unbeachtet durch, sammeln sich die Abbrüche aller Tasks
                // als Fehlschläge an und werden am Ende zu einer AggregateException,
                // sodass sie sich nicht mehr von einer echten Störung unterscheiden lässt
                cancellationToken.ThrowIfCancellationRequested();

                // Dieser Mirror war nichts. Da die übrigen noch Hoffnung haben, geht es weiter
                failures.Add(ex);
            }
        }
    }
    finally
    {
        cts.Cancel();   // Auch wenn per Ausnahme verlassen wird, verbleibende Downloads stoppen

        try
        {
            await Task.WhenAll(pending);
        }
        catch
        {
            // Abbrüche oder Fehlschläge der Nicht-Gewinner einsammeln
        }
    }

    throw new AggregateException("Der Abruf ist bei allen Mirrors fehlgeschlagen.", failures);
}

Bei diesem Code kommt es auf die Reihenfolge an, weil der Abbruch erst ausgelöst wird, „nachdem der Gewinner feststeht“.

  • Task.WhenAny liefert den zuerst abgeschlossenen Task zurück, nicht den zuerst erfolgreichen. Schlägt der schnellste Mirror mit einem 404 oder einem Verbindungsabbruch fehl, kommt genau dieser als „Gewinner“ zurück
  • Bricht man hier ab, bevor man das Ergebnis geprüft hat, stoppt man die noch lebenden übrigen Mirrors selbst und wirft anschließend die Ausnahme des fehlgeschlagenen „Gewinners“ erneut. Das ist der schlimmste Fehler, bei dem der ganze Sinn mehrerer Mirrors verloren geht
  • Deshalb wird jeder abgeschlossene Task einzeln herausgenommen und awaitet, und nur bei Erfolg werden die übrigen abgebrochen. Bei einem Fehlschlag wird dieser Task aus den Kandidaten entfernt und auf den nächsten Abschluss gewartet
  • Cancel() stellt nur die Anfrage, ohne zu warten, bis die Gegenseite tatsächlich stoppt. Deshalb wird im finally auf den Rest gewartet und werden Abbruch- oder Fehlerausnahmen dort beobachtet. Lässt man das weg, bleiben auf der Task-Seite Ausnahmen zurück, die niemand beobachtet
  • Schlagen alle fehl, werden die einzelnen Fehlschläge gebündelt geworfen. Würde man nur die Ausnahme des ersten Tasks werfen, ginge die Information verloren, „welcher Mirror wie versagt hat“
  • Nur der Abbruch durch den Aufrufer wird nicht als Fehlschlag gezählt, sondern unverändert nach außen weitergeworfen. Bricht cancellationToken ab, enden alle Tasks mit einer OperationCanceledException. Sammelt man diese in failures, wird daraus am Ende eine AggregateException, sodass ein Abbruch oder Timeout durch den Nutzer als „Ausfall aller Mirrors“ protokolliert und erneut versucht wird. Deshalb wird am Anfang des catch ThrowIfCancellationRequested() aufgerufen, und der Abbruch wird unverändert als OperationCanceledException zurückgegeben

Wichtig ist hier, dass WhenAny nur einen einzigen Gewinner zurückgibt. Die übrigen Vorgänge laufen, wenn nichts unternommen wird, einfach weiter.

Sie müssen also vorher entscheiden,

  • ob der Rest abgebrochen werden soll,
  • ob Sie die Ausnahmen beobachten möchten.

Task.WhenAny ist praktisch, erfordert aber etwas mehr Entwurfsaufwand als WhenAll. Am klarsten bleibt es, wenn Sie es nur wählen, wenn „nur das erste Ergebnis genügt“.

3.6. Bei vielen Elementen mit begrenztem Parallelitätsgrad: Parallel.ForEachAsync oder SemaphoreSlim

Task.WhenAll lässt alle erzeugten Tasks gleichzeitig laufen. Bei einer großen Anzahl von Elementen steigen dadurch HTTP-Verbindungen, DB-Verbindungen, Speicherverbrauch und die Last auf externe Dienste auf einen Schlag an.

In solchen Fällen ist es stabiler, festzulegen, wie viele gleichzeitig laufen dürfen.

Parallel.ForEachAsync macht diese Absicht sehr gut lesbar.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

Dieses Muster passt, wenn

  • die Anzahl groß ist,
  • jedes Element unabhängig verarbeitet wird,
  • ein gleichzeitiger Lauf aller Elemente aber vermieden werden soll.

Wer freier steuern möchte, kann alternativ SemaphoreSlim verwenden – zum Beispiel für eine Regel wie „an eine bestimmte externe API höchstens 4 gleichzeitig“.

Kurz gesagt:

  • Bei wenigen Elementen: Task.WhenAll
  • Bei großen Mengen: Parallel.ForEachAsync oder SemaphoreSlim

Mit dieser Aufteilung liegt man selten grob daneben.

3.7. Um der Reihe nach zu verarbeiten: Channel<T>

Manchmal möchten Sie Arbeit, die „nicht sofort fertig sein muss, aber sicher verarbeitet werden soll“, vom Aufrufer abkoppeln. Etwa E-Mail-Versand, Log-Weiterleitung, Webhook-Nachbearbeitung oder Dateikonvertierung.

Wirft man das einfach mit Task.Run los, bleibt Folgendes unklar:

  • Wo werden Ausnahmen beobachtet?
  • Wird beim Beenden darauf gewartet?
  • Wie viel wird aufgenommen, wenn die Menge zunimmt?

Diese Art von Arbeit lässt sich leichter verwalten, wenn man sie in eine Queue einreiht und ein dedizierter Consumer sie der Reihe nach verarbeitet.

JaNeinproducerWriteAsyncIst in der Queue noch Platz?Kommt in den ChannelWartet, bis Platz frei wirdconsumer ruft ReadAsyncDer Reihe nach await und verarbeiten

Mit Channel<T> lässt sich die producer-/consumer-Form recht unkompliziert schreiben.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

BoundedChannelFullMode.Wait bedeutet in diesem Beispiel: Ist die Queue voll, lässt man die schreibende Seite warten. Das ist backpressure.

Bei ASP.NET Core ist es naheliegend, eine solche Queue in Kombination mit einem BackgroundService zu konsumieren. Verglichen mit „echtem fire-and-forget“ lassen sich Ausnahmen, das Beenden, der Parallelitätsgrad und Obergrenzen so deutlich leichter handhaben.

3.8. Um in festen Intervallen zu laufen: PeriodicTimer

Für asynchrone Verarbeitung in festen Intervallen ist PeriodicTimer sehr gut lesbar.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

Die Vorzüge dieser Schreibweise sind:

  • Der Ablauf lässt sich leichter verfolgen als bei Callback-basierten Timern
  • Sie lässt sich await-basiert schreiben
  • Beim Stoppen kann man CancellationToken ganz natürlich verwenden

Als Hinweis: PeriodicTimer wird unter der Annahme verwendet, dass für einen einzelnen Timer nicht gleichzeitig mehrere WaitForNextTickAsync-Aufrufe abgesetzt werden. Dauert die Verarbeitung länger als das Intervall, muss diese Verzögerung als Entwurfsfrage behandelt werden. Der Timer parallelisiert nicht von sich aus, um aufzuholen.

3.9. Bei sequenziell eintreffenden Daten: IAsyncEnumerable<T>

Manchmal möchten Sie die Daten in der Reihenfolge ihres Eintreffens verarbeiten, statt alles zuerst in einer List<T> zu sammeln.

  • Eine paginierte API der Reihe nach lesen
  • Zeilen einer Datei nach und nach lesen
  • Streaming-Ergebnisse direkt durchreichen

In diesen Fällen sind IAsyncEnumerable<T> und await foreach naheliegend.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

Diese Form passt, wenn

  • Sie nicht warten möchten, bis alles vollständig ist,
  • Sie die Elemente einzeln nacheinander verarbeiten möchten,
  • Sie nicht alles im Speicher sammeln möchten.

Ob der Rückgabewert Task<List<T>> oder IAsyncEnumerable<T> sein soll, entscheidet sich am leichtesten danach, ob das Ergebnis erst vollständig verwendet wird oder in der Reihenfolge des Eintreffens.

3.10. Um asynchron freizugeben: await using

Typen, die bei der Freigabe asynchrone Vorgänge wie Flush oder das Beenden einer Verbindung benötigen, implementieren IAsyncDisposable. In diesem Fall verwenden Sie statt using ein await using.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

Die wichtigen Punkte sind:

  • Bei IAsyncDisposable verwenden Sie await using
  • Dass das „Öffnen“ synchron, das „Schließen“ aber asynchron ist, kommt ganz normal vor

Das hilft, den Bruch zu vermeiden, dass „das Schreiben async war, aber nur die abschließende Freigabe synchron ist“.

3.11. Für gegenseitigen Ausschluss über await hinweg: SemaphoreSlim

In Code, der einen await überspannt, gibt es Situationen, in denen SemaphoreSlim an die Stelle von lock tritt.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

Wichtig sind zwei Punkte:

  • Der Eintritt erfolgt über WaitAsync
  • Release wird unbedingt in finally aufgerufen

Für Situationen wie „nur ein einzelner Zugriff gleichzeitig“ oder „externe API-Aufrufe auf höchstens 3 gleichzeitig begrenzen“ ist SemaphoreSlim durchaus praxistauglich.

3.12. await je nach UI-Code, Anwendungscode oder Bibliothek unterschiedlich schreiben

ConfigureAwait(false) ist nichts, das man einfach immer und überall setzen sollte.

Die grobe Aufteilung sieht so aus.

UI / Anwendungscodeawait someAsync()Setzt im ursprünglichen Kontext fortAllgemeine Bibliothekawait someAsync().ConfigureAwait(false)Setzt keine Rückkehr zu einem bestimmten Kontext voraus
  • UI / Anwendungscode
    • Zunächst genügt ein gewöhnliches await
    • Folgt nach dem await eine UI-Aktualisierung oder eine vom anwendungsseitigen Kontext abhängige Verarbeitung, ist es natürlicher, ConfigureAwait(false) nicht zu setzen
  • Anwendungscode in ASP.NET Core
    • Normalerweise genügt ein gewöhnliches await
    • ConfigureAwait(false) muss nicht als durchgängige Konvention erzwungen werden
  • Allgemeiner Bibliothekscode
    • Hängt er nicht von UI oder Anwendungsmodellen ab, ist ConfigureAwait(false) eine starke Option

Kurz gesagt:

  • Anwendungsseitiger Code: plain await
  • Allgemeine Bibliotheken: ConfigureAwait(false) erwägen

Wer sich das merkt, kommt in der Praxis kaum in Schwierigkeiten.

4. Grundlegende Schreibregeln

4.1. Rückgabewert zuerst als Task / Task<T>

Beim Rückgabewert von async-Methoden denken Sie zunächst in dieser Reihenfolge.

Rückgabewert Erste Überlegung
Task Standard für async-Methoden ohne Rückgabewert
Task<T> Standard für async-Methoden, die einen Wert zurückgeben
ValueTask / ValueTask<T> Erst wählen, nachdem eine Messung die Notwendigkeit gezeigt hat

ValueTask sieht praktisch aus, ist aber nicht immer besser als Task. Es ist ein Struct und hat daher Kopierkosten sowie Einschränkungen bei der Verwendung.

Besonders wichtig ist, dass ein ValueTask grundsätzlich nur ein einziges Mal awaitet werden soll. Es eignet sich nicht dafür, leichtfertig in einer lokalen Variablen gehalten und mehrfach awaitet zu werden.

Für den alltäglichen Anwendungscode genügt daher zunächst Task / Task<T>.

Außerdem ist es übersichtlicher, Methodennamen mit dem Suffix Async zu versehen.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

Gibt es wie oben nichts zu awaiten, ist es naheliegender, Task.CompletedTask oder Task.FromResult zurückzugeben, statt async gewaltsam anzuhängen.

4.2. async void nur für Event-Handler

Die Grundregel ist, async void außerhalb von Event-Handlern zu vermeiden.

Der Grund ist einfach:

  • Der Aufrufer kann nicht awaiten
  • Auf den Abschluss kann nicht gewartet werden
  • Die Ausnahmebehandlung wird schwieriger
  • Es lässt sich schlechter testen

Nur Event-Handler benötigen void, deshalb wird es ausschließlich dort verwendet.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "Gespeichert.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "Abgebrochen.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "Speicherfehler");
    }
}

In Event-Handlern ist es wichtig, sich bewusst zu machen, dass man selbst bis dahin schreibt, die Ausnahme intern abzufangen und an die UI zurückzumelden.

4.3. CancellationToken entgegennehmen und nach unten weiterreichen

Bei abbrechbaren Operationen nehmen Sie ein CancellationToken entgegen und reichen es unverändert nach unten weiter.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

Ein häufiges Muster ist, dass das Token auf der oberen Ebene entgegengenommen, aber nicht nach unten weitergereicht wird. Das führt tendenziell zu Code, der „abbrechbar aussieht, aber mittendrin nicht stoppt“.

Auch Timeouts bedeuten je nachdem etwas anderes: „nur das Warten begrenzen“ oder „auch die eigentliche Verarbeitung stoppen“.

  • Nur das Warten begrenzen: WaitAsync
  • Auch die eigentliche Verarbeitung stoppen: CancellationTokenSource.CancelAfter zusammen mit der Weitergabe des Tokens

Dieser Unterschied führt später leicht zu Fehlern, daher lohnt es sich, ihn von Anfang an festzulegen.

4.4. Asynchrone APIs bis zum Ende asynchron durchziehen

Verwenden Sie async / await, ist es naheliegender, so weit wie möglich bis zum Ende asynchron durchzuziehen.

Als grobe Richtschnur für Ersetzungen:

Verlockende Schreibweise Ersetzen durch
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

Besonders in UI-Code und ASP.NET Core wird schwer nachvollziehbar, wo es hakt, wenn sich synchrone Wartemuster einmischen.

Da sich im heutigen C# auch async Task Main() verwenden lässt, gibt es selbst in Konsolenanwendungen kaum noch Gründe, etwas gewaltsam zu synchronisieren.

4.5. Beim Erzeugen von Tasks mit LINQ mit ToArray / ToList festlegen

Kombinieren Sie Task.WhenAll oder Task.WhenAny mit LINQ, ist es sicherer, einmal mit ToArray() oder ToList() festzulegen.

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

Der Grund ist die verzögerte Auswertung von LINQ. Code zu lesen in der Annahme, „das läuft doch schon alles“, obwohl noch gar nicht enumeriert wurde, ist eine unauffällige, aber gefährliche Falle.

  • Wollen Sie auf alles zusammen warten: ToArray()
  • Wollen Sie unterwegs entfernen oder austauschen: ToList()

Merkt man sich das, fällt die Unterscheidung leicht.

5. Häufige Antipatterns

Antipattern Was daran problematisch ist Erste Ersetzung
Task.Run(async () => await IoAsync()) Wirft eine I/O-Wartezeit unnötig erneut weiter await IoAsync()
Task.Result / Wait() Blockiert den Thread. Anfällig für Stockungen await
Thread.Sleep() in einen async-Ablauf mischen Belegt den Thread auch während des Wartens Task.Delay()
async void in einer gewöhnlichen Methode Kann nicht awaitet werden, Ausnahmen schwer zu verwalten Task / Task<T>
Serielles await, wo Task.WhenAll angebracht wäre Unnötig langsam Erst alles starten, dann WhenAll
Große Mengen auf einmal per WhenAll abfeuern Last schnellt hoch Parallel.ForEachAsync / SemaphoreSlim
Versuchen, mit lock einen await zu überspannen Passt nicht zum Zweck SemaphoreSlim.WaitAsync
fire-and-forget mit reinem Task.Run abhandeln Ausnahmen, Stopp und Obergrenzen bleiben unklar Channel<T> / BackgroundService
ConfigureAwait(false) mechanisch in UI-Code einsetzen UI-Aktualisierungen nach dem await brechen leicht plain await
ValueTask zum Standard machen Der Nutzen steht oft in keinem Verhältnis zur Komplexität zuerst Task

Aus dieser Tabelle kommen in der Praxis besonders häufig diese drei vor:

  1. Task.Run bei I/O
  2. Serielles await bei eigentlich unabhängigen Vorgängen
  3. Fire-and-forget ohne Lebensdauerverwaltung

Schon das Beheben dieser drei Punkte verbessert die Übersichtlichkeit des Codes erheblich.

6. Checkliste für Reviews

Bei Code-Reviews rund um async/await prüfen Sie von oben nach unten etwa Folgendes.

  • Lässt sich zuerst in Worten sagen, ob der Vorgang I/O-bound oder CPU-bound ist?
  • Sind noch Task.Result / Task.Wait() / Thread.Sleep() übrig geblieben?
  • Ist eine I/O-Wartezeit mit Task.Run umhüllt?
  • Werden unabhängige Vorgänge unnötig seriell awaitet?
  • Umgekehrt: Wird eine große Menge unbegrenzt per WhenAll gestartet?
  • Wird ein entgegengenommenes CancellationToken auch tatsächlich nach unten weitergereicht?
  • Kommt async void außerhalb von Event-Handlern vor?
  • Ist bei eingesetztem fire-and-forget festgelegt, wer Ausnahmen, das Beenden und Obergrenzen verwaltet?
  • Steht bei Verwendung von SemaphoreSlim Release in einem finally?
  • Gibt es bei Verwendung von ValueTask einen gemessenen Grund, und wird von einem einmaligen await ausgegangen?
  • Passt das Vorhandensein bzw. Fehlen von ConfigureAwait(false) zur Art des Codes?
    • UI / Anwendungscode: plain await
    • Allgemeine Bibliothek: ConfigureAwait(false) erwägen

Diese Checkliste eignet sich auch gut, um im Team die Review-Kriterien anzugleichen.

7. Grobe Entscheidungshilfe

Die Übersicht der Entscheidungen ist in der Entscheidungstabelle in 3.1 gebündelt. Statt dieselbe Tabelle hier noch einmal abzudrucken, ist es leichter zu finden, bei Bedarf dorthin zurückzukehren – deshalb steht hier keine eigene Tabelle.

  • Möchten Sie situationsabhängig sehen, „was Sie zuerst verwenden sollten“ → die Entscheidungstabelle in 3.1
  • Möchten Sie die Schreibweise jedes einzelnen Musters sehen → 3.2 bis 3.12 (entsprechen 1:1 den Zeilen der Tabelle in 3.1)

Es gibt genau eine Entscheidung, die nicht in der Tabelle von 3.1 enthalten ist: der Rückgabetyp. Das ist keine Frage der Situation, sondern des Methodenentwurfs, deshalb ist sie in 4.1 zusammengefasst. Kurz gesagt: Wählen Sie zunächst Task / Task<T>, und entscheiden Sie sich für ValueTask erst, nachdem eine Messung die Notwendigkeit gezeigt hat.

8. Zusammenfassung

Best Practices für async / await bestehen weniger darin, viele einzelne Techniken auswendig zu lernen, als vielmehr darin, den Typ passend zur Art der Verarbeitung zu wählen – dieses Ordnungsprinzip wirkt in der Praxis.

Die Reihenfolge der Betrachtung sieht grob so aus:

  1. I/O-Wartezeit von CPU-Berechnung trennen
  2. Bei I/O: die async-API direkt awaiten
  3. Bei CPU-Berechnung: entscheiden, wo sie laufen soll
  4. Bei mehreren Vorgängen: WhenAll / WhenAny / eine Parallelitätsobergrenze wählen
  5. Um von der Lebensdauer der Anfrage abzukoppeln: eine Queue statt reinem fire-and-forget verwenden
  6. Rückgabewerte, Abbruch, Ausnahmen, gegenseitigen Ausschluss und den Umgang mit Kontexten aufeinander abstimmen

Gerade weil die Schreibweise von async / await selbst so knapp ist, wird bei nachlässigem Einsatz die Strategie schwer erkennbar. Umgekehrt gilt:

  • I/O als I/O behandeln
  • CPU als CPU behandeln
  • Hintergrundverarbeitung als Hintergrundverarbeitung mit verwalteter Lebensdauer

Schon diese drei Trennungen machen den Code deutlich lesbarer.

9. Quellen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Wann sollte man in C# Task.Run verwenden?
Task.Run lohnt sich, wenn Sie eine CPU-Berechnung vom aktuellen Thread wegverlagern wollen. Führt man zum Beispiel in einem UI-Event-Handler von WinForms/WPF eine schwere Berechnung direkt aus, friert der Bildschirm ein – hier ist es naheliegend, sie mit Task.Run vom UI-Thread wegzunehmen. Die Anfrageverarbeitung von ASP.NET Core läuft dagegen ohnehin schon im ThreadPool, sodass Task.Run gefolgt von einem sofortigen await meist nur zusätzliche Planungsarbeit erzeugt – deshalb vermeidet man das grundsätzlich. Für lange Vorgänge oder Arbeiten, die von der Lebensdauer der Anfrage abgekoppelt werden sollen, ist es sinnvoller, sie an eine Queue oder einen HostedService abzugeben.
Darf man I/O-Vorgänge nicht mit await Task.Run() umhüllen?
Bei I/O-Wartezeiten wie HTTP, Datenbank oder Datei-Lese-/Schreibzugriffen ist es die Grundregel, die async-Version der API direkt zu awaiten – ein Umhüllen mit Task.Run ist nicht nötig. Bereits asynchrones I/O mit Task.Run zu umhüllen, wirft die I/O-Wartezeit lediglich an einen anderen Thread weiter und macht den Code schwerer durchschaubar, ohne einen Vorteil zu bringen. Ruft man aus der UI heraus eine API auf, die es nur synchron gibt, kann man Task.Run aus Gründen der Reaktionsfähigkeit einsetzen – das ist jedoch keine asynchrone I/O, sondern belegt lediglich einen ganzen Thread, um das Problem zu umgehen, weshalb sich dieser Ausweg auf der Serverseite kaum skalieren lässt.
Wo sollte man ConfigureAwait(false) einsetzen?
In UI- oder Anwendungscode genügt zunächst ein gewöhnliches await. Folgt nach dem await eine UI-Aktualisierung oder eine Verarbeitung, die vom anwendungsseitigen Kontext abhängt, ist es natürlicher, ConfigureAwait(false) nicht zu setzen. Auch im Anwendungscode von ASP.NET Core reicht in der Regel ein gewöhnliches await – man muss es nicht als strikte Konvention durchsetzen. ConfigureAwait(false) ist vor allem in allgemeinem Bibliothekscode wirkungsvoll, der nicht von UI- oder Anwendungsmodellen abhängt. Wer sich merkt: „Auf der Anwendungsseite plain await, in allgemeinen Bibliotheken ConfigureAwait(false) erwägen“, kommt in der Praxis kaum in Schwierigkeiten.
Warum sollte man async void außerhalb von Event-Handlern vermeiden?
Weil sich async void vom Aufrufer nicht awaiten lässt, die Fertigstellung nicht abgewartet werden kann, die Ausnahmebehandlung schwieriger wird und sich der Code auch schlechter testen lässt. Normale Methoden geben grundsätzlich Task oder Task<T> zurück. Nur Event-Handler benötigen wegen ihrer Signatur void, weshalb es ausschließlich dort verwendet wird – und dann ist es wichtig, sich bewusst zu machen, dass man selbst im Handler per try/catch die Ausnahme abfängt und bis zur UI zurückmeldet.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog