WPF/WinForms: async und der UI-Thread auf einem Blatt

· · C#, async/await, .NET, WPF, WinForms, UI, Threads

Bei der Verwendung von async / await in WPF / WinForms ist das Verwirrendste zu welchem Thread nach dem await zurückgekehrt wird und wann die UI berührt werden darf. Vor allem wenn Dispatcher, BeginInvoke, ConfigureAwait(false) und .Result / .Wait() sich vermischen, werden die Ursachen für eingefrorene Oberflächen und Cross-Thread-Ausnahmen schwer erkennbar.

Dieser Artikel behandelt ausschließlich die Beziehung zwischen dem UI-Thread von WPF / WinForms und async / await. Für die allgemeinen Entscheidungskriterien zu async / await insgesamt siehe den verwandten Artikel C# async/await-Praxis-Entscheidungstabelle – Task.Run und ConfigureAwait.

Die Stellen, an denen es in der Praxis wirklich wehtut, sind ungefähr diese:

  • Unklar, wo die Fortsetzung nach await läuft
  • Unklar, ob nach einem Task.Run die UI berührt werden darf
  • Unsicherheit, wo ConfigureAwait(false) gesetzt werden soll
  • Die Oberfläche friert bei .Result / .Wait() / .GetAwaiter().GetResult() ein
  • WPFs Dispatcher und WinForms’ Invoke / BeginInvoke / InvokeAsync vermischen sich im Kopf

WPF und WinForms sind beide auf den UI-Thread zentrierte Modelle. Deshalb hilft es bei der Klärung von async / await weniger, sich philosophisch mit der Frage „Was ist eigentlich Asynchronität?“ zu beschäftigen, sondern klarzustellen, was gerade mit dem UI-Thread und der Nachrichtenschleife geschieht.

Dieser Artikel geht vor allem von WPF / WinForms-Anwendungen ab .NET 6 aus und behandelt in einer praxistauglichen Reihenfolge, wohin die Ausführung nach await zurückkehrt, den Dispatcher, ConfigureAwait(false) sowie die Gründe, warum .Result / .Wait() blockieren.

Zu beachten: Control.InvokeAsync in WinForms gibt es erst ab .NET 9. In älteren WinForms-Versionen verwenden Sie grundsätzlich BeginInvoke / Invoke.

Der in diesem Artikel gezeigte Code ist außerdem als vollständiges, bau- und lauffähiges Beispielpaket (eine UI-unabhängige Bibliothek, WPF- / WinForms-Beispiele sowie Unittests, die den Rückkehrort von await und das Deadlock-Szenario nachbilden) auf GitHub veröffentlicht.

wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)

Inhaltsverzeichnis

  1. Zuerst das Fazit (in einem Satz)
  2. Erst einmal auf einen Blick
    • 2.1. Das Gesamtbild
    • 2.2. Die erste Entscheidungstabelle
  3. In diesem Artikel verwendete Begriffe
    • 3.1. Der UI-Thread und die Nachrichtenschleife
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. Typische Muster
    • 4.1. Plain await in einem UI-Ereignishandler
    • 4.2. Task.Run nur für schwere CPU-Berechnungen
    • 4.3. ConfigureAwait(false) bedeutet „keine erzwungene Rückkehr“, nicht „garantiert keine Rückkehr“
    • 4.4. Warum .Result / .Wait() / .GetAwaiter().GetResult() blockieren
  5. Wann Dispatcher / Invoke verwenden
  6. Häufige Antipatterns
  7. Checkliste für Reviews
  8. Grobe Entscheidungshilfe
  9. Zusammenfassung
  10. Referenzen

1. Zuerst das Fazit (in einem Satz)

  • Bei einem plain await in einem WPF- / WinForms-UI-Ereignishandler dürfen Sie davon ausgehen, dass die Fortsetzung nach await grundsätzlich zum UI-Thread zurückkehrt
  • Task.Run dient dazu, CPU-Berechnungen vom UI-Thread wegzuverlagern – nicht dazu, I/O-Wartezeiten einzupacken
  • Auch bei await Task.Run(...) in einem UI-Handler kehrt die Fortsetzung normalerweise zum UI-Thread zurück, sofern dieses await ein plain await ist
  • ConfigureAwait(false) bedeutet, dass dieses await die Rückkehr zum eingefangenen UI-Kontext nicht erzwingt. Die UI in der anschließenden Fortsetzung direkt zu berühren ist gefährlich
  • .Result / .Wait() / .GetAwaiter().GetResult() blockieren den UI-Thread. Muss die Fortsetzung von await zur UI zurückkehren, blockiert es ziemlich zuverlässig
  • Für eine explizite Rückkehr zur UI in WPF: Dispatcher.InvokeAsync
  • Für eine explizite Rückkehr zur UI in WinForms: klassisch BeginInvoke, ab .NET 9 passt InvokeAsync gut zum async-Ablauf
  • Die erste Richtlinie: die äußerste UI-Schicht bei plain await belassen, in allgemeinen Bibliotheken ConfigureAwait(false) erwägen und die Rückkehr zur UI nur dort explizit machen, wo es nötig ist

Kurz gesagt reicht es in WPF / WinForms, diese drei Dinge im Blick zu behalten:

  1. Auf welchem Thread gerade ausgeführt wird
  2. Wohin die Fortsetzung von await zurückkehrt
  3. Wer die Verantwortung für die Rückkehr zur UI trägt

Damit wird der Überblick deutlich klarer.

2. Erst einmal auf einen Blick

2.1. Das Gesamtbild

Am schnellsten erfassen Sie das Gesamtbild mit diesem Diagramm.

UI-Ereignishandler(WPF / WinForms)plain awaitI/O-APIFängt den UI-SynchronizationContext einSetzt sich nach await auf dem UI-Thread fortUI-Updates lassen sich direkt schreibenawait Task.Run(...)schwere CPU-VerarbeitungDie eigentliche Berechnung läuft im ThreadPoolSetzt sich nach await auf dem UI-Thread fortawait SomeAsync().ConfigureAwait(false)Erzwingt keine Rückkehr zur UIFortsetzung auf beliebigem ThreadDirektes UI-Update gefährlichDispatcher / Invoke erforderlichSomeAsync().Result / Wait()GetAwaiter().GetResult()Blockiert den UI-ThreadFortsetzung kann nicht zur UI zurückkehrenHänger / Deadlock / mindestens ein Einfrieren

In der Praxis begegnen Ihnen im Wesentlichen diese vier Muster.

  1. Plain await in einem UI-Ereignishandler
  2. Task.Run in einem UI-Ereignishandler, um CPU-Arbeit auszulagern
  3. Den Rückkehrort mit ConfigureAwait(false) entfernen
  4. Den UI-Thread mit .Result / .Wait() blockieren

2.2. Die erste Entscheidungstabelle

Situation Was während der Wartezeit läuft Fortsetzung nach await Darf die UI direkt berührt werden? Erste Wahl
await SomeIoAsync() in einem UI-Handler Wartet auf den Abschluss der I/O. Der UI-Thread selbst kann zur Nachrichtenschleife zurückkehren Im Wesentlichen der UI-Thread Ja plain await
await Task.Run(...) in einem UI-Handler Schwere CPU-Arbeit läuft im ThreadPool Im Wesentlichen der UI-Thread Ja Task.Run nur für CPU
await x.ConfigureAwait(false) in einem UI-Handler Der Rückkehrort ist nicht an die UI gebunden Ein beliebiger Thread Nein In UI-Code grundsätzlich vermeiden
x.Result / x.Wait() auf dem UI-Thread Der UI-Thread ist durch das Warten blockiert Die Fortsetzung kann von vornherein kaum laufen Nein Nicht verwenden
UI-Update nach einem Hintergrundthread oder ConfigureAwait(false) gewünscht Läuft auf einem anderen Thread als der UI Ist so, wie es ist, nicht die UI Nein Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
Eine allgemeine, UI-unabhängige Bibliothek schreiben Unabhängig von den Umständen des Aufrufers Erzwingt keine Rückkehr zur UI So gestalten, dass die UI nicht berührt wird ConfigureAwait(false) erwägen
Aus einem Konstruktor oder einer synchronen Eigenschaft heraus async aufrufen wollen Der UI-Thread gerät leicht ins Warten Der Startpfad blockiert leicht Nein Auf Loaded / Shown / InitializeAsync verlagern

Wichtig an dieser Tabelle ist, dass plain await in UI-Code tatsächlich Ihr Verbündeter ist. Der Feind ist nicht await selbst, sondern das synchrone Blockieren des UI-Threads.

Die eigentliche Entscheidung ist in dieser Tabelle bereits zusammengefasst. Die folgenden Kapitel teilen sich so auf: warum diese Tabelle so aussieht (Kapitel 3 und 4), wie Sie die Werkzeuge zur Rückkehr zur UI wählen (Kapitel 5) und wie Sie das bei Reviews erkennen (Kapitel 6 und 7).

3. In diesem Artikel verwendete Begriffe

3.1. Der UI-Thread und die Nachrichtenschleife

Die Oberfläche von WPF / WinForms funktioniert grundsätzlich so, dass es einen einzigen UI-Thread gibt, der Eingaben, Zeichnen und Ereignisverarbeitung abwickelt.

Die Aufgaben dieses UI-Threads sind ungefähr diese.

  • Nachrichten wie Tastendrücke, Tastatureingaben und Neuzeichnungen verarbeiten
  • Der einzige Thread sein, der Steuerelemente und UI-Objekte sicher berühren darf
  • Wird er mit zu viel Arbeit vollgestopft, stocken Bildschirmaktualisierung und Reaktion auf Eingaben

Der entscheidende Punkt hier: Die Aufgabe des UI-Threads ist es, schnell zu zirkulieren. Blockieren Sie ihn längere Zeit, stauen sich Maus, Tastatur und Neuzeichnen – für den Benutzer sieht das wie „eingefroren“ aus.

Dieses Bild lässt sich als Diagramm leichter im Kopf behalten und verwirrt weniger.

Benutzereingabe / NeuzeichnungsanforderungNachrichtenschleife des UI-ThreadsEreignishandler ausführenBildschirm aktualisierenLange synchrone VerarbeitungNachrichtenschleife läuft nicht mehr umBildschirm wirkt eingefroren

3.2. SynchronizationContext / Dispatcher / Invoke

Die hier häufig vorkommenden Begriffe lassen sich für die Praxis so unterscheiden.

Begriff Bedeutung hier
UI-Thread Der Thread, der die UI-Objekte erzeugt hat. Grundsätzlich der einzige, der die UI sicher berühren darf
Nachrichtenschleife Der Mechanismus, mit dem der UI-Thread Nachrichten der Reihe nach verarbeitet
SynchronizationContext Eine Abstraktion, um „die Verarbeitung an diesen Ausführungsort zurückzugeben“
Dispatcher Die Warteschlange von WPF für den UI-Thread
Invoke / BeginInvoke / InvokeAsync APIs, um Arbeit an den UI-Thread zu übergeben

Etwas genauer beschrieben, entscheidet sich der Rückkehrort so: Was await (entsprechend dem Standard ConfigureAwait(true)) einfängt, ist zunächst SynchronizationContext.Current. Nur wenn dieser null ist, wird TaskScheduler.Current betrachtet, und falls dieser nicht TaskScheduler.Default ist, kehrt die Fortsetzung zu diesem TaskScheduler zurück. Ist keines von beidem der Fall – also SynchronizationContext.Current ist null und TaskScheduler.Current ist der Standard –, läuft die Fortsetzung im ThreadPool. Auf dem UI-Thread von WPF / WinForms trifft der erste Fall zu, das heißt, der SynchronizationContext der UI ist gesetzt. In der Praxis genügt es daher, davon auszugehen, dass der SynchronizationContext der UI wirksam ist.

Die Zuordnung je Framework lässt sich am besten als Tabelle darstellen.

Framework Kontext auf UI-Seite Repräsentative APIs für die explizite Rückkehr zur UI
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync

Bei WPF steht der Dispatcher im Mittelpunkt. Bei WinForms stehen das Handle des Steuerelements und die Nachrichtenschleife im Mittelpunkt, wobei BeginInvoke / Invoke in Erscheinung treten.

In der Praxis verwechseln Sie Abstraktion und konkrete Umsetzung seltener, wenn Sie sich die Beziehung etwa in dieser Form merken.

Aktueller CodeSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync(.NET 9+)

4. Typische Muster

4.1. Plain await in einem UI-Ereignishandler

Das ist die einfachste Form.

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    LoadButton.IsEnabled = false;
    StatusText.Text = "Wird geladen …";

    try
    {
        string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
        PreviewTextBox.Text = text;
        StatusText.Text = "Fertig";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        LoadButton.IsEnabled = true;
    }
}

In diesem Code beginnt LoadButton_Click auf dem UI-Thread. Und da await File.ReadAllTextAsync(...) ein plain await ist, fängt es normalerweise den UI-Kontext zu diesem Zeitpunkt ein.

Daraus ergibt sich:

  • Während der Datei-I/O-Wartezeit wird der UI-Thread nicht belegt
  • Die Fortsetzung nach dem Abschluss des Lesevorgangs kehrt grundsätzlich zum UI-Thread zurück
  • PreviewTextBox.Text = text; lässt sich direkt so schreiben

Hier ist kein zusätzlicher Dispatcher nötig. Wenn Sie in einem UI-Handler lediglich ein plain await gemacht haben, können Sie die UI normalerweise direkt berühren.

Diese Handler-Methode ist ein async void, weil die Signatur von UI-Ereignishandlern void verlangt – hier ist das ausnahmsweise erlaubte async void. Genau deshalb gibt es einen klaren Grund, try / catch innerhalb der Methode zu platzieren. Bei async Task landet eine Ausnahme im zurückgegebenen Task und kann vom Aufrufer beim await abgefangen werden. Bei async void gibt es diesen Task nicht; eine nach außen durchgereichte Ausnahme wird an den SynchronizationContext zurückgeworfen, unter dem der Handler gestartet wurde – also an den UI-Thread. Eine unbehandelte Ausnahme auf dem UI-Thread landet bei WPF in Application.DispatcherUnhandledException, bei WinForms in Application.ThreadException, und wenn sie dort nicht behandelt wird, stürzt die Anwendung ab.

Mit anderen Worten: In einem async void-Handler ist es die Grundregel, die Ausnahme innerhalb des Handlers abzufangen. Wird sie wie im obigen Beispiel „in eine Statusanzeige verwandelt und im finally die Schaltfläche zurückgesetzt“, schließt sich das als UI-Verhalten natürlich ab. Der globale Auffangmechanismus der Anwendung (DispatcherUnhandledException und Ähnliches) ist ausschließlich als letztes Sicherheitsnetz gedacht.

Auch in WinForms ist die Sichtweise dieselbe. Solange Sie im Click-Handler ein plain await machen, kehrt die Fortsetzung grundsätzlich zur UI-Seite zurück.

Als Diagramm sieht der Ablauf so aus.

UI SynchronizationContextAsynchrone I/OUI-ThreadUI SynchronizationContextAsynchrone I/OUI-ThreadKehrt während der Wartezeit zur Nachrichtenschleife zurückClick-Handler startetawait ReadAllTextAsyncReservierung, die Fortsetzung zur UI zurückzugebenI/O abgeschlossenFortsetzung auf dem UI-Thread wieder aufnehmenTextBox / Label aktualisieren

4.2. Task.Run nur für schwere CPU-Berechnungen

Task.Run zahlt sich aus, wenn schwere CPU-Berechnung vom UI-Thread entfernt werden soll.

private async void HashButton_Click(object sender, RoutedEventArgs e)
{
    HashButton.IsEnabled = false;
    ResultText.Text = "Wird berechnet …";

    try
    {
        byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);

        string hash = await Task.Run(() =>
        {
            using SHA256 sha256 = SHA256.Create();
            byte[] digest = sha256.ComputeHash(data);
            return Convert.ToHexString(digest);
        });

        ResultText.Text = hash;
    }
    catch (Exception ex)
    {
        ResultText.Text = ex.Message;
    }
    finally
    {
        HashButton.IsEnabled = true;
    }
}

Was in diesem Code passiert, ist ungefähr dies.

  1. Der Ereignishandler beginnt auf dem UI-Thread
  2. Die I/O-Wartezeit von File.ReadAllBytesAsync läuft asynchron ab
  3. Nur die schwere Hash-Berechnung wird per Task.Run an den ThreadPool ausgelagert
  4. Die Fortsetzung von await Task.Run(...) ist ein plain await, kehrt also zum UI-Thread zurück
  5. ResultText.Text = hash; lässt sich direkt so schreiben

Mit anderen Worten: Nur das Innere von Task.Run läuft auf einem anderen Thread. Es geht nicht dauerhaft über den await hinaus „an einen Ort, der nicht mehr die UI ist“.

Auf einen Blick betrachtet ist das schwer misszuverstehen.

ThreadPoolAsynchrone I/OUI-ThreadThreadPoolAsynchrone I/OUI-ThreadDie Fortsetzung von await Task.Run(...) setzt sich auf der UI fortawait ReadAllBytesAsyncPlain await, setzt sich also auf der UI fortSchwere CPU-Verarbeitung per Task.Run auslagernBerechnetes Ergebnis zurückgebenErgebnis im Bildschirm anzeigen

Hier gibt es zwei Dinge, auf die zu achten ist.

  • I/O-Wartezeiten nicht in Task.Run verpacken
  • Task.Run nicht als „Asynchronisierung“, sondern als Mittel zum Schaffen einer „Auslagerungsstelle für die CPU“ betrachten

Ein Code wie Task.Run(async () => await File.ReadAllTextAsync(...)) reicht eine I/O-Wartezeit lediglich unnötig erneut an den ThreadPool weiter und bringt wenig Nutzen.

4.3. ConfigureAwait(false) bedeutet „keine erzwungene Rückkehr“, nicht „garantiert keine Rückkehr“

Das ist der Punkt, der am häufigsten missverstanden wird.

Zunächst: ConfigureAwait(false) eignet sich für allgemeinen Bibliothekscode, der nicht von der UI oder einem bestimmten Anwendungsmodell abhängt.

public sealed class DocumentRepository
{
    public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
    {
        string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
        return text.Replace("\r\n", "\n", StringComparison.Ordinal);
    }
}

Diese Methode berührt die UI nicht. Sie funktioniert gleichermaßen in WPF, WinForms, ASP.NET Core oder einem Worker. Für Code dieser Art ist es naheliegend, ConfigureAwait(false) zu setzen.

Und der Aufruf auf UI-Seite kann ein plain await sein.

private readonly DocumentRepository _repository = new();

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    OpenButton.IsEnabled = false;
    StatusText.Text = "Wird geladen …";

    try
    {
        string text = await _repository.LoadNormalizedTextAsync(
            PathTextBox.Text,
            CancellationToken.None);

        PreviewTextBox.Text = text;
        StatusText.Text = "Fertig";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        OpenButton.IsEnabled = true;
    }
}

Wichtig ist hier, dass das ConfigureAwait(false) innerhalb der Bibliothek das await des Aufrufers nicht zwangsweise ebenfalls auf false setzt.

Es entsteht also folgende Trennung:

  • Innerhalb der Bibliothek kehrt die Ausführung nicht zur UI zurück
  • Wenn der UI-Handler sie mit einem plain await aufruft, kehrt die Fortsetzung beim Aufrufer zur UI zurück

Umgekehrt ist es gefährlich, wenn der UI-Handler selbst so geschrieben wird.

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    string text = await _repository.LoadNormalizedTextAsync(
        PathTextBox.Text,
        CancellationToken.None).ConfigureAwait(false);

    PreviewTextBox.Text = text;
}

In diesem Fall wird die Fortsetzung dieses await in OpenButton_Click nicht gezwungen, zur UI zurückzukehren. Daher kann PreviewTextBox.Text = text; zu einem Cross-Thread-Zugriff werden.

Es gibt noch einen weiteren, unscheinbaren, aber wichtigen Punkt. ConfigureAwait(false) garantiert keinen Wechsel zum ThreadPool: Wenn dieses await ohne Warten sofort abgeschlossen wird, kann die Fortsetzung einfach auf dem aktuellen Thread weiterlaufen. Es als „geht immer auf einen anderen Thread“ oder „ab hier ist es nie mehr die UI“ zu lesen, ist ein Rezept für Fehler. Die Bedeutung ist ausschließlich diese: Die Fortsetzung dieses await wird nicht gezwungen, zum ursprünglichen UI-Kontext zurückzukehren – nicht mehr.

Als Diagramm sieht das so aus.

NeinJaawait in einem UI-HandlerConfigureAwait(false) setzen?Fortsetzung grundsätzlich auf dem UI-ThreadUI lässt sich leicht direkt aktualisierenFortsetzung nicht an die UI gebundenKann auf beliebigem Thread wieder aufgenommen werdenUI-Update benötigt Dispatcher / Invoke

4.4. Warum .Result / .Wait() / .GetAwaiter().GetResult() blockieren

Das ist der am häufigsten anzutreffende Unfall.

private void LoadButton_Click(object sender, RoutedEventArgs e)
{
    string text = LoadTextAsync().Result;
    PreviewTextBox.Text = text;
}

private async Task<string> LoadTextAsync()
{
    string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
    return text.ToUpperInvariant();
}

Auf den ersten Blick sieht es so aus, als würde hier nur synchron ein Ergebnis abgeholt, aber auf dem UI-Thread ist das gefährlich.

Der Ablauf als Diagramm sieht so aus.

UI SynchronizationContextAsynchrone I/OUI-ThreadUI SynchronizationContextAsynchrone I/OUI-ThreadAber die UI ist durch .Result blockiertDie Fortsetzung kann nicht laufen, kann also nicht abgeschlossen werdenLoadButton_Click startetLoadTextAsync() aufrufenGibt einen unabgeschlossenen Task zurückBlockiert wartend bei .ResultI/O abgeschlossen, will die Fortsetzung zur UI zurückgebenMöchte die Fortsetzung ausführen

In Worten gefasst, passiert Folgendes.

  1. Der UI-Thread ruft LoadTextAsync() auf
  2. Das await innerhalb von LoadTextAsync() fängt den UI-Kontext ein
  3. Der UI-Thread wartet bei .Result
  4. Die I/O wird abgeschlossen
  5. Die Fortsetzung von LoadTextAsync() möchte zum UI-Thread zurückkehren
  6. Aber der UI-Thread ist durch .Result blockiert
  7. Da die Fortsetzung nicht laufen kann, wird LoadTextAsync() nicht abgeschlossen
  8. .Result kehrt nie zurück

Mit anderen Worten: Die UI sagt „ich warte, bis du fertig bist“, und die asynchrone Seite sagt „ich kann erst fertig werden, wenn ich zur UI zurückkehren kann“ – beide warten aufeinander. Wirklich unangenehm.

Ein verbreiteter Irrtum ist die Annahme, GetAwaiter().GetResult() sei sicher. Doch das Wesentliche – das Blockieren des UI-Threads – bleibt dasselbe. Unterschiedlich ist im Wesentlichen nur, wie die Ausnahme verpackt wird.

In der UI ist es deshalb sicherer, diese drei als dieselbe Gefahr zu behandeln.

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

Aus demselben Grund ist es auch gefährlich, den Task der DispatcherOperation, den Dispatcher.InvokeAsync(...) von WPF zurückgibt, vom UI-Thread aus mit Task.Wait() abzuwarten. InvokeAsync reiht den übergebenen Delegaten lediglich in die Warteschlange des Dispatcher ein; tatsächlich läuft er erst, wenn der UI-Thread diese Warteschlange abarbeitet. Ist der UI-Thread durch Wait() angehalten, läuft die Warteschlange nicht ab, sodass dieser Task niemals abgeschlossen wird. Dasselbe gilt auch auf Seite der DispatcherOperation: Für DispatcherOperation.Wait() ist ausdrücklich dokumentiert, dass es eine InvalidOperationException auslöst, wenn ein auf demselben Thread laufender Vorgang abgewartet wird. Der blockierende Wartepfad selbst ist also gar nicht vorgesehen. Im UI-Kontext ist es die gesamte Richtung „synchron auf etwas Gepostetes warten“, die leicht zu Blockaden führt. Eine ausführliche Erklärung der Blockade-Mechanik findet sich gut lesbar unter Await, and UI, and deadlocks! Oh my!.

Führt das „garantiert“ zu einem Deadlock? Nicht unbedingt. Bei Code, dessen Fortsetzung zufällig nicht zur UI zurückkehrt, kann es sein, dass die UI ohne Deadlock einfach nur einfriert. Auch das ist jedoch schmerzhaft genug – lassen Sie es in der UI grundsätzlich sein.

5. Wann Dispatcher / Invoke verwenden

Vor diesem Hintergrund: In einem UI-Handler mit plain await sind normalerweise keine expliziten Dispatcher / Invoke nötig.

Nötig wird es zum Beispiel in diesen Fällen.

  • Die UI soll in der Fortsetzung nach ConfigureAwait(false) berührt werden
  • Innerhalb von Task.Run, oder wenn auch außerhalb bewusst so aufgebaut ist, dass es nicht zur UI zurückkehrt
  • Benachrichtigungen kommen von vornherein auf einem Nicht-UI-Thread an – Socket-Empfang, Timer, Ereignis-Callbacks
  • In einer Schicht, die UI und Nicht-UI absichtlich trennt, soll nur das abschließende UI-Update explizit gemacht werden

In WPF ist Dispatcher.InvokeAsync das repräsentative Mittel.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await Dispatcher.InvokeAsync(() =>
    {
        PreviewTextBox.Text = text;
        StatusText.Text = "Fertig";
    });
}

In WinForms ab .NET 9 fügt sich InvokeAsync sauber in den async-Ablauf ein.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await previewTextBox.InvokeAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Fertig";
    });
}

Im klassischen WinForms-Muster verwenden Sie BeginInvoke. Invoke ist ein synchrones Senden und lässt den Aufrufer warten. BeginInvoke postet und kehrt sofort zurück. In einem async-Ablauf passt in der Regel die nicht blockierende Seite besser.

Control.BeginInvoke gibt allerdings ein IAsyncResult zurück, das sich nicht direkt awaiten lässt. Wollen Sie in einer Umgebung ohne Control.InvokeAsync (.NET Framework 4.8, .NET 6 / 8 und Ähnliches) trotzdem im async-Ablauf bleiben, liegt es nahe, es mit einer TaskCompletionSource in einen Task zu verpacken.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public static class ControlUiExtensions
{
    // Damit das auch unter .NET Framework 4.8 unverändert funktioniert,
    // wird hier die generische TaskCompletionSource verwendet. Ab .NET 5
    // ließe sich das auch mit der nicht generischen Version schreiben.
    //
    // cancellationToken wurde bewusst nicht optional gemacht. Wird die
    // Steuerelementinstanz zerstört, nachdem BeginInvoke sie angenommen hat,
    // wird der geposteten Delegat verworfen, ohne ausgeführt zu werden, und
    // die TaskCompletionSource erhält weder Ergebnis noch Ausnahme. Ohne einen
    // Abbruchweg würde die await-Seite dann für immer warten
    public static Task InvokeOnUiAsync(
        this Control control, Action action, CancellationToken cancellationToken)
    {
        if (control is null)
        {
            throw new ArgumentNullException(nameof(control));
        }

        if (action is null)
        {
            throw new ArgumentNullException(nameof(action));
        }

        if (!control.IsHandleCreated)
        {
            throw new InvalidOperationException("Das Fensterhandle wurde noch nicht erstellt.");
        }

        if (!control.InvokeRequired)
        {
            action();
            return Task.CompletedTask;
        }

        // Damit die Fortsetzung der await-Seite nicht einfach auf dem
        // UI-Thread weiterläuft, wird hier ausdrücklich festgelegt,
        // dass die Fortsetzung asynchron fließt.
        var tcs = new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        // Abbruch und Ausführung kämpfen um dasselbe "einmalige Recht".
        // Mit Interlocked.Exchange kommt nur die Seite weiter, die zuerst
        // eine 1 schreiben konnte.
        // Bei einer Schreibweise, die erst das Flag prüft und dann action()
        // aufruft, bleibt der Pfad offen, dass unmittelbar nach der Prüfung
        // ein Abbruch eintritt: Die await-Seite hat den Abbruch erhalten und
        // beginnt bereits die nächste Operation, während der noch alte,
        // in der Warteschlange verbliebene Delegat später doch noch die
        // Oberfläche überschreibt
        int claimed = 0;   // 0 = noch offen / 1 = eine Seite hat es sich geholt

        // Wird abgebrochen, wird der Task auch dann abgeschlossen, wenn der
        // Delegat nicht ausgeführt wird. Die Registrierung wird beim
        // Abschluss des Task unbedingt wieder entfernt (sonst hält sie tcs
        // fest, solange das Token lebt). CancellationTokenRegistration.Dispose
        // ist threadsicher und darf von jedem Thread aus aufgerufen werden
        CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetCanceled(cancellationToken);
            }
        });

        tcs.Task.ContinueWith(
            _ => registration.Dispose(),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);

        try
        {
            control.BeginInvoke(new Action(() =>
            {
                // Zwischen dem Posten und dem Zeitpunkt, an dem der UI-Thread
                // tätig wird, kann inzwischen abgebrochen worden sein. Kann
                // hier das Recht nicht mehr erlangt werden, hat sich die
                // Abbruchseite bereits durchgesetzt – dann wird die Oberfläche
                // gar nicht erst berührt, und die Methode kehrt zurück
                if (Interlocked.Exchange(ref claimed, 1) != 0)
                {
                    return;
                }

                try
                {
                    action();
                    tcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }));
        }
        catch (Exception ex)
        {
            // BeginInvoke selbst kann eine Ausnahme werfen (etwa wenn das
            // Handle bereits fehlt). Wird das hier nicht abgeschlossen,
            // bleibt es wieder bei einem endlosen Warten.
            // Der Delegat läuft in diesem Fall nicht, daher wird auch hier
            // erst das Recht geholt und dann abgeschlossen
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetException(ex);
            }
        }

        return tcs.Task;
    }
}

Der Aufruf sieht auf der Aufruferseite fast genauso aus wie im InvokeAsync-Beispiel. Binden Sie das Token an die Lebensdauer des Formulars.

// Feld des Formulars. Wird beim Schließen abgebrochen
private readonly CancellationTokenSource _formClosing = new();

protected override void OnFormClosed(FormClosedEventArgs e)
{
    // Damit sich die await-Seite auch dann abschließen lässt, wenn ein
    // bereits gepostet Delegat verworfen wird, ohne ausgeführt zu werden
    _formClosing.Cancel();
    base.OnFormClosed(e);
}

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _formClosing.Token);

    string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);

    await previewTextBox.InvokeOnUiAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Fertig";
    }, linked.Token);
}

In dieser Form lassen sich auch Ausnahmen, die in der UI auftreten, im try / catch an der Stelle abfangen, an der awaitet wurde. Vier Punkte sind dabei festzuhalten.

  • Abbruch und Ausführung reichen nicht aus, wenn sie „erst das Flag prüfen, dann handeln“. Unmittelbar nachdem geprüft wurde, „ist schon abgebrochen worden?“, kann noch vor dem Aufruf von action() ein Abbruch eintreten. In diesem einen Augenblick wird tcs als abgebrochen markiert, und die wartende Aufruferseite geht weiter und beginnt die nächste Operation. Danach läuft der noch in der Warteschlange verbliebene alte Delegat und überschreibt die Oberfläche – die neue Anzeige wird von der alten überschrieben, ein Fehler, der sich nur schwer reproduzieren lässt. Genau deshalb lässt der obige Code Interlocked.Exchange um „das einmalige Recht“ konkurrieren; die Seite, die es nicht bekommt, kehrt zurück, ohne etwas zu tun
  • Ein BeginInvoke vor der Handle-Erstellung (vor Load) oder nach dem Schließen des Formulars löst eine Ausnahme aus. Behalten Sie die Lebensdauer der aufrufenden Seite im Blick
  • Wird das Steuerelement nach dem Posten zerstört, kann der Delegat verworfen werden, ohne ausgeführt zu werden. In diesem Fall erhält die TaskCompletionSource weder Ergebnis noch Ausnahme, sodass die wartende await-Seite auf unbestimmte Zeit wartet. Übergeben Sie deshalb wie im obigen Beispiel unbedingt ein an das Ende des Formulars gebundenes Token. Das Ergebnis eines Abbruchs steigt dann als OperationCanceledException auf
  • File.ReadAllTextAsync ist eine API ab .NET Core 2.0. Wollen Sie dieselbe Form unter .NET Framework 4.8 abbilden, ersetzen Sie es zum Beispiel durch StreamReader.ReadToEndAsync

Für die Unterscheidung reicht diese Grobeinteilung.

Ziel WPF WinForms
Synchron in die UI einreihen Dispatcher.Invoke Control.Invoke
Asynchron an die UI posten Dispatcher.InvokeAsync / Dispatcher.BeginInvoke Control.BeginInvoke / .NET 9+ Control.InvokeAsync
Sich sauber in async / await einfügen Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync, davor BeginInvoke

Als praktisches Gespür gilt:

  • Nicht nötig, wenn im UI-Handler nur plain await verwendet wird
  • Verwenden, wenn die UI von einem Ort berührt werden soll, der nicht die UI ist
  • Synchrones Invoke innerhalb von async-Abläufen nicht übermäßig vermehren

Allein damit lassen sich schon viele Unfälle vermeiden.

Im Zweifel genügt ein Entscheidungsdiagramm auf diesem Niveau.

JaNeinNeinJaJaLäuft diese Fortsetzung auf dem UI-Thread?Ja?Beim plain await bleiben und die UI aktualisierenSoll die UI berührt werden?Verarbeitung wie gewohnt fortsetzenWPF: Dispatcher.InvokeAsyncWinForms: BeginInvoke / InvokeAsync

6. Häufige Antipatterns

Antipattern Warum es wehtut Erste Ersatzlösung
LoadAsync().Result in einem UI-Handler Blockiert den UI-Thread. Anfällig für Deadlocks await LoadAsync()
LoadAsync().Wait() in einem UI-Handler Dasselbe. Die Nachrichtenschleife bleibt stehen await LoadAsync()
LoadAsync().GetAwaiter().GetResult() in einem UI-Handler Nur die Ausnahmedarstellung unterscheidet sich, das Blockieren ist gleich await LoadAsync()
ConfigureAwait(false) mechanisch an UI-Code anhängen UI-Updates nach await brechen leicht Plain await in der äußersten UI-Schicht
Task.Run(async () => await IoAsync()) Postet die I/O unnötig erneut await IoAsync()
Bibliothekscode hält Dispatcher oder Control direkt Vertieft die UI-Abhängigkeit. Schwer wiederzuverwenden Bibliothek liefert nur Daten, die UI-Seite übernimmt das Marshalling
Dispatcher.Invoke / Control.Invoke übermäßig in async-Abläufen verwenden Bildet leicht Ringe von Blockaden Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync erwägen
async in Konstruktoren oder Eigenschaften-Gettern synchronisieren Nährboden für Hänger beim Start Auf Loaded / Shown / InitializeAsync verlagern

Unter diesen sind besonders drei häufig anzutreffen.

  1. .Result / .Wait() auf dem UI-Thread
  2. ConfigureAwait(false) mechanisch an UI-Code anhängen
  3. Zuständigkeiten von Bibliothek und UI vermischen sich, sodass der Dispatcher tief eindringt

Allein das Entfernen dieser drei Muster beruhigt den Code schon erheblich.

7. Checkliste für Reviews

Der Inhalt entspricht der Entscheidungstabelle aus 2.2 und den Antipatterns aus Kapitel 6, ist hier aber als Fragen formuliert, die Sie beim Öffnen des Codes der Reihe nach prüfen.

  • Ist in UI-Ereignishandlern oder UI-Initialisierungspfaden noch .Result / .Wait() / .GetAwaiter().GetResult() übrig geblieben?
  • Wird Task.Run ausschließlich für CPU-Berechnung verwendet? Wird damit keine I/O verpackt?
  • Ist ConfigureAwait(false) mechanisch in UI-Code eingeflossen?
  • Schleppt umgekehrt eine allgemeine Bibliothek eine Abhängigkeit vom UI-Kontext mit sich?
  • Lässt sich an jeder Stelle, an der nach await direkt die UI berührt wird, tatsächlich belegen, dass sie sich im UI-Kontext befindet?
  • Werden dort, wo eine explizite Rückkehr zur UI nötig ist, Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync verwendet?
  • Vermehren sich synchrone Marshalling-Aufrufe wie Dispatcher.Invoke / Control.Invoke unnötig?
  • Wird async aus Konstruktoren, synchronen Eigenschaften oder synchronen Ereignissen gewaltsam synchronisiert?
  • Greift die Bibliotheksschicht direkt auf Window / Control / Dispatcher zu?

Diese Checkliste eignet sich auch gut dafür, im Team abzustimmen, „was zur Zuständigkeit der UI gehört“.

8. Grobe Entscheidungshilfe

Die situationsabhängige Wahl ist bereits in der Entscheidungstabelle aus 2.2 zusammengefasst; hier bleibt nur die Merkregel zum Mitnehmen.

  • Die äußerste UI-Schicht bleibt bei plain await. Dass sich nach await direkt die UI berühren lässt, liegt genau daran, dass diese Regel eingehalten wird
  • Task.Run ist die Auslagerungsstelle für die CPU. Es ist kein Werkzeug, um I/O-Wartezeiten zu verpacken
  • ConfigureAwait(false) ist ein Werkzeug für allgemeine Bibliotheken. Nicht mechanisch an UI-Code anhängen
  • Dispatcher / BeginInvoke / InvokeAsync nur, wenn die UI von einem Ort berührt werden muss, der nicht die UI ist
  • Die drei Wartemittel auf dem UI-Thread (.Result / .Wait() / .GetAwaiter().GetResult()) nicht verwenden. Wollen Sie synchronisieren, ziehen Sie stattdessen die gesamte Aufruferkette zu async hoch

Die Begründung finden Sie in Kapitel 4, die Wahl bei Dispatcher / Invoke in Kapitel 5, und die Anhaltspunkte zum Auffinden im tatsächlichen Code in den Kapiteln 6 und 7.

9. Zusammenfassung

Was bei async / await in WPF / WinForms wirklich zählt, ist nicht die vage Stimmung „Asynchronität ist schwierig“, sondern getrennt zu betrachten:

  • Wo etwas begonnen hat
  • Wohin die Fortsetzung von await zurückkehrt
  • Wer die Verantwortung trägt, zur UI zurückzukehren

Als erste Grundregeln reicht es, genau diese zu befolgen:

  1. Die äußerste UI-Schicht: plain await
  2. Nur schwere CPU-Arbeit: Task.Run
  3. In allgemeinen Bibliotheken: ConfigureAwait(false) erwägen
  4. Nur wenn eine Rückkehr zur UI nötig ist: Dispatcher / BeginInvoke / InvokeAsync
  5. Auf dem UI-Thread: .Result / .Wait() / .GetAwaiter().GetResult() nicht verwenden

async / await selbst ist kein besonders launischer Mechanismus. Nur wenn Sie ihn verwenden, ohne den UI-Thread als Mittelpunkt im Blick zu behalten, wird es plötzlich zum Morast.

Umgekehrt gilt:

  • Außen und innen der UI trennen
  • Sich des Rückkehrorts bewusst bleiben
  • Keine Blockaden hereinholen

Halten Sie sich an genau diese drei Punkte, wird der asynchrone Code in WPF / WinForms deutlich ruhiger. Code, der die Oberfläche einfrieren lässt, liegt meist nicht daran, dass „Asynchronität schlecht“ wäre, sondern schlicht daran, dass die Art, sich beim UI-Thread zu verschulden, unsauber ist.

10. Referenzen

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.

Zu welchem Thread kehrt der Code nach await zurück?
Bei einem plain await (ohne ConfigureAwait) in einem UI-Ereignishandler von WPF oder WinForms kehrt die Fortsetzung nach await grundsätzlich zum UI-Thread zurück. Der Grund ist, dass await den zu diesem Zeitpunkt aktuellen UI-SynchronizationContext einfängt und die Fortsetzung dorthin zurückgibt, sodass sich TextBox- oder Label-Updates nach dem await direkt schreiben lassen. Auch bei await Task.Run(...) läuft nur die eigentliche Berechnung im ThreadPool; bei einem plain await setzt sich die Fortsetzung anschließend auf dem UI-Thread fort.
Warum blockiert .Result oder .Wait() auf dem UI-Thread?
Weil der UI-Thread, während er bei .Result wartet, selbst blockiert ist: Die Fortsetzung der asynchronen Verarbeitung will zum eingefangenen UI-Kontext zurückkehren, kann dort aber nicht laufen, weil der UI-Thread durch .Result belegt ist – beide Seiten warten aufeinander, und es entsteht ein Deadlock. GetAwaiter().GetResult() unterscheidet sich nur in der Art, wie Ausnahmen verpackt werden; im Kern blockiert es den UI-Thread genauso. Vermeiden Sie in der UI alle drei – .Result, .Wait() und GetAwaiter().GetResult() – und verwenden Sie stattdessen await.
Sollte ConfigureAwait(false) an UI-Code angehängt werden?
Besser nicht. ConfigureAwait(false) bedeutet, dass die Rückkehr zum eingefangenen UI-Kontext nicht erzwungen wird; die Fortsetzung kann dann auf einem beliebigen Thread weiterlaufen, und ein direkt anschließendes UI-Update kann zu einem Cross-Thread-Zugriff werden. Es eignet sich für allgemeinen, UI-unabhängigen Bibliothekscode; die Richtlinie lautet, die äußerste UI-Schicht bei plain await zu belassen.
Wann sollte Task.Run verwendet werden?
Nur dann, wenn eine rechenintensive CPU-Berechnung vom UI-Thread wegverlagert werden soll. I/O-Wartezeiten in Task.Run zu verpacken bringt nichts – es wird lediglich unnötig erneut an den ThreadPool weitergereicht. Nur der Inhalt von Task.Run läuft auf einem anderen Thread; die Fortsetzung von await Task.Run(...) kehrt bei einem plain await normalerweise auf den UI-Thread zurück, sodass sich das Ergebnis direkt in der Oberfläche anzeigen lässt.

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