.NET Async / Await: Was Sie schon immer über ConfigureAwait wissen wollten – aber nie zu fragen wagten

Mit der Einführung von async/await in C# 5 (2012) wurde das asynchrone Programmieren auf Basis von Tasks deutlich einfacher. Nebenläufigkeit ist allerdings grundsätzlich kein einfaches Thema, und wenn die Reihenfolge der Ausführung nicht mehr rein sequenziell ist, passieren schlimme Dinge können.

In diesem Artikel:

sg
Sebastian Gingter ist Consultant und „Erklärbär“ bei Thinktecture. Seine Spezialgebiete sind Backend-Systeme mit .NET Core und IdentityServer.

Ein großes Thema, das immer wieder Fragezeichen in die Gesichter wirft, ist die Funktion .ConfigureAwait(): Was macht die überhaupt, warum gibt es die, und soll oder gar muss ich die jetzt wirklich überall dran schreiben?

Grundlagen: async, Tasks, Threads und Synchronisierung

Um ConfigureAwait() zu erklären, sollten wir uns noch einmal kurz ins Gedächtnis rufen, was await überhaupt macht. Schauen wir uns eine einfache Methode an, die einen asynchronen Aufruf simuliert:

				
					public async Task<string> SayHelloAsync()
{    
    var name = "World";

    // Wir tun so, als würden wir den Gruß asynchron, z.b. über ein
    // Netzwerk holen, und warten einfach etwas ab...
    await Task.Delay(100);

    return "Hello " + name;
}
				
			

Tasks

Der C#-Compiler baut aus dieser Methode im Hintergrund einen Task, der eine State-Machine enthält, die die Abarbeitung von mehreren weiteren Tasks koordiniert. Das async-Keyword in der Methodendeklaration ist die Anweisung, diese State-Machine zu erstellen.

Auch aus dem Code, der nicht asynchron ist (hier z.b. die Zuweisung der Variablen name) generiert der Compiler jetzt Tasks, und zwar jeweils einen Task für den Code vor und nach den await Keywords:

  • Der erste Task macht die Zuweisung der Variable name und führt das asynchrone Warten auf das Delay aus.
  • Der zweite Task liefert den kompletten String zurück.

Wenn der zweite innere Task abgeschlossen ist, setzt der vom Compiler erstellte Task diesen Wert als Ergebnis und setzt unseren SayHelloAsync-Task auch auf completed.

Die State-Machine generiert dabei aus jedem await einen Synchronisationspunkt, und dieser sitzt genau zwischen den Tasks: Der Teil rechts vom await gehört immer zum Code davor. Falls links vom await noch Code stünde (meist eine Zuweisung), gehört dieser zum folgenden Task.

Beispiel: var someResult = await SayHelloAsync();

In diesem Fall wird SayHelloAsync() im ersten Task ausgeführt. Die State-Machine wartet, bis dieser Task meldet, dass er fertig ist, und startet dann den folgenden Task, die sogenannte Continuation (Fortführung). In dem zweiten generierten Task geschieht dann die Zuweisung des Ergebnisses des „awaiteten“ Tasks (Hello World) zu der Variablen someResult.

Die folgende Abbildung zeigt, in welche Tasks die Methode vom Compiler zerlegt wird und in welcher Reihenfolge die Aufrufe starten. Sie zeigt auch ein potentielles Problem: Wenn wir diese Methode beispielsweise in einer Windows-Forms Anwendung ungünstig aufrufen, laufen wir in ein Deadlock-Problem. Warum ist das so?

Threads und SychronizationContext

Ein erstellter Task in .NET wird immer an einen SynchronizationContext übergeben, der von dem Thread vorgegeben wird, der den Task erzeugt. Das gilt sowohl für Tasks, die von async Methoden generiert werden, als auch für Tasks die durch Aufrufe von Task erstellt wurden (z.B. new Task()Task.Delay()Task.FromResult()Task.Run() etc.). Die Basisklasse des SynchronizationContext ruft einfach nur ThreadPool.QueueUserWorkItem(task) auf. Damit landet der Task in der Warteschlange des .NET Threadpools. Die Arbeiter-Threads aus diesem Pool nehmen sich nun jeweils einen Task aus der Warteschlange, arbeiten diesen ab, und nehmen dann den nächsten. Unsere Tasks würden also ohne weiteres Zutun von mehreren, beliebigen, Threads abgearbeitet.

In der Praxis ist es aber meist so, dass unsere Anwendungen je nach Typ bestimmte Anforderungen haben:

  • In Windows Forms müssen Updates am UI zwingend den Win32 Message-Lopp passieren, der ausschließlich vom UI-Thread abgearbeitet wird.
  • Das gleiche gilt auch für die Windows Presentation Foundation (WPF), wo solche Updates in aller Regel durch Dispatcher.BeginInvoke() auf den UI-Thread gelegt werden müssen, wenn sie nicht schon dort laufen.
  • Auch in der Windows Runtime (WinRT) müssen UI-Updates über den CoreDispatcher auf dem UI-Thread passieren.
  • In ASP.NET WebForms, MVC und auch Web API (nicht ASP.NET Core) stellt die Laufzeitumgebungen bestimmte Informationen über den eingehenden Request (z.b. die vom Browser angefragte Sprache) auf dem Thread in Thread.CurrentUICulture zur Verfügung, auf dem die Abarbeitung des Requests begonnen wird. Außerdem behandelt sie den HttpContext des aktuellen Requests als SynchronizationContext.

In all diesen Fällen ist es also in der Regel wünschenswert oder gar erforderlich, das die Fortführung (Continuation) der Tasks auf dem gleichen Thread passiert, auf dem die asynchrone Operation gestartet wurde.

Damit dies automatisch passiert, stellen diese Anwendungstypen standardmäßig jeweils einen eigenen SynchronizationContext zur Verfügung. Diese jeweiligen Ableitungen sorgen dafür, dass die Fortführung, die ja in einer Warteschlange liegt bis der vorherige Task abgearbeitet wurde, so markiert wird, dass sie nur auf dem Thread des SynchronizationContext abgearbeitet werden darf.

Für Windows Forms, WPF und auch WinRT gibt es spezifische Implementationen, die dafür sorgen das die Fortführung der Tasks eben auf dem UI-Thread ausgeführt werden. Analog dazu hat auch ASP.NET (nicht ASP.NET Core) einen eigenen SynchronizationContext, der die Continuation-Tasks ausführt, nachdem die Identität und Culture des Request-Threads sowie dessen HttpContext wiederhergestellt wurde.

Der MSDN-Artikel Parallel Computing – It’s All About the SynchronizationContext gibt hierzu ausführliche Hintergrundinformationen.

Was macht ConfigureAwait?

Die Funktion ConfigureAwait erlaubt es uns nun, das Verhalten der Continuations zu konfigurieren. Die offizielle Dokumentation im MSDN schreibt dazu:

Konfiguriert einen Awaiter, der verwendet wird, um diese Task zu erwarten.

Parameter:
continueOnCapturedContext Boolean true um zu versuchen, die Fortsetzung zurück in den ursprünglich erfassten Text zu marshallen, andernfalls false.

Gibt zurück:
ConfiguredTaskAwaitable
Ein Objekt, das verwendet wird, um diese Aufgabe zu erwarten.

Die originale englische Version ist leider auch nicht viel aussagekräftiger.

Was bedeutet das nun konkret?

Wie wir eben erfahren haben ist es manchmal zwingend, dass auch der folgende Task auf dem Thread ausgeführt wird, der einen Task, ggf. auf einem anderen Thread, gestartet hat. Beispiel: Der UI-Thread startet einen Task der einen asynchronen Datenbankzugriff beginnt. Wenn die Daten geladen wurden, wird etwas mit diesen Daten gemacht (Berechnungen, Formatierungen etc.). Der folgende Task, der diese Werte auf das UI schreibt muss unbedingt auf dem UI-Thread laufen um Exceptions zu verhindern. Das ist auch der Default (continueOnCapturedContext = true).

In diesem Beispiel haben wir aber noch weitere Operationen, die auf den asynchron geladenen Daten arbeiten. Idealerweise sollten solche Berechnungen, vor allem wenn sie CPU-Intensiver sind, nicht auf dem UI-Thread geschehen, denn sonst fühlt es sich so an als sei die Anwendung kurzfristig eingefroren.

Wir haben also mehrere Tasks: Das Laden der Daten, die Berechnung auf den Daten, das Aktualisieren der Oberfläche mit den berechneten Werten. Die Berechnung soll explizit nicht auf dem UI-Thread geschehen, das UI-Update muss zwingend auf dem UI-Thread passieren. Die Funktion ConfigureAwait() mit dem Parameter false erlaubt uns nun, genau die Continuation mit der Berechnung so zu konfigurieren, dass sie eben nicht auf dem captured context, also unserem SynchronizationContext und damit dem UI-Thread ausgeführt wird.

Wichtig: Die Konfiguration betrifft immer nur den Fortführungs-Task und nicht den Task auf dem .ConfigureAwait(false) aufgerufen wird.

Beispiel:

				
					public async Task<int> ConfigureExample()
{
   // wir starten eine asynchrone Ausführung. Wenn wir vom UI
   // aufgerufen wurden, geschieht der Start der Operation
   // noch auf dem UI-Thread
   var someData = await LoadDataAsync().ConfigureAwait(false);

   // Der folgende Code wird NICHT auf dem UI-Thread
   // ausgeführt und blockiert damit das UI nicht
   var number = DoLongRunningMaths(someData);

   return number;
}
				
			

Achtung: Das bedeutet nicht, das der Task nicht mehr an den Kontext gebunden wäre. Der Kontext bleibt weiterhin an den Task gebunden, und der Kontext hat auch weiterhin seinen Thread. Es bedeutet nur, dass der Task lediglich nicht auf dem Thread des Kontextes laufen wird.

Problem: Deadlocks

Ein weiteres Problem, welches sich sehr schnell ergeben kann, sind die schon kurz erwähnten Deadlocks. Schauen wir uns folgendes Windows Forms Beispiel an, welches das leider oft gesehene Anti-Pattern Sync over Async zeigt:

				
					public void Button1_Click(...)
{
   // wir starten eine asynchrone Ausführung...
   var greetingTaks = SayHelloAsync();

   // und wir warten auf das Ergebnis, um den Gruß anzuzeigen
   textBox1.Text = greetingTask.Result;
}
				
			

Diese Methode startet die Ausführung der Methode SayHelloAsync in einem Task. Da wir hier eine Windows Forms Anwendung haben, und die Button1_Click-Methode vom UI-Thread gestartet wird, greift hier automatisch der WindowsFormsSynchronizationContext. Dieser sagt, das die Continuation auf genau diesem UI-Thread ausgeführt werden soll. Das heisst, die Tasks werden auf den UI-Thread gescheduled und warten, bis dieser frei wird um die Tasks dann abzuarbeiten.

Der UI-Thread hingegen läuft erstmal weiter bis zum Aufruf greetingTask.Result. Hier blockiert der Thread und wartet auf das Ergebnis, da der Zugriff auf .Result grundsätzlich blockierend ist. Das Ergebnis aber wird nie geliefert, weil der Task der dies berechnen soll auf den jetzt blockierten UI-Thread wartet: Ein klassischer Deadlock.

Schrittweise passiert folgendes:

  • Die Klick-Methode ruft SayHelloAsync auf (auf dem UI-Thread / innerhalb des SynchronizationContextes).
  • SayHelloAsync startet eine asynchrone Operation (z.B. einen Http- oder Datenbankaufruf, hier als Beispiel den asynchronen Delay). Auch dieser Start des Tasks passiert noch auf dem UI-Thread.
  • Task.Delay() gibt jetzt sofort(!) einen Task zurück, der zwar gestartet, aber noch nicht fertig ist.
  • SayHelloAsync awaited diesen Task. Der Kontext (mit unserem UI-Thread) wird ‚eingefangen‘ und wird verwendet, um den folgenden Teil der Methode (ein vom Compiler generierter Task) weiter auszuführen, sobald der Delay-Task fertig wird.
  • SayHelloAsync (bzw. die generierte State-Machine) gibt jetzt diesen Task zurück, der auch noch nicht fertig ist.
  • Die Klick-Methode wartet synchron auf das Ergebnis des SayHelloAsync-Tasks, durch den Zugriff auf .Result. Dies blockiert den UI-Thread.
  • Etwas Zeit vergeht…
  • … und der asynchrone Delay läuft ab. Dies stellt den Delay-Task auf completed.¹
  • Dies stellt automatisch den Fortführungs-Task von SayHelloAsync um, so dass er jetzt nicht mehr auf den vorherigen Task wartet, sondern das dieser jetzt gestartet werden darf – aber eben nur auf dem UI-Thread.
  • Deadlock, denn dieser ist bereits blockiert und wartet auf das Ergebnis das jetzt nie kommen kann.

¹ Das Ablaufen des Delays geschieht nicht innerhalb des Delay-Tasks. Auch dieser wäre bereits geblockt, da auch der Delay-Task innerhalb des Synchronization-Kontextes erstellt wurde und somit auf den UI-Thread gebunden ist. Das Framework realisiert einen Delay über Timer, die unabhängig vom SynchronzationContext auf belieben Threadpool-Threads ausgelöst werden können.

Das, was hier in unserem Windows Forms-Beispiel passiert, geschieht genau so auch bei ASP.NET (nicht ASP.NET Core), nur das hier der Kontext ein ASP.NET Request-Kontext ist. Zwar ist dieser Request-Kontext nicht an einen speziellen Thread gebunden wie bei Desktop-UI-Anwendungen, allerdings erzwingt dieser Kontext, das nur ein einziger Thread zu einem Zeitpunkt in diesem Kontext läuft. Solange dieser Thread läuft (oder blockiert ist), kann kein weiterer Thread in dem Kontext starten bzw. weiter laufen. Wir haben also auch in ASP.NET Anwendungen diesen Deadlock-Fall.

Läuft man in so einen Deadlock hinein, liest man immer wieder von .ConfigureAwait(false), das man an den Task-Aufruf anhängen soll. Man tut es, und es funktioniert.

Wir wissen schon, dass durch das .ConfigureAwait(false) die Fortführung unseres Tasks nicht auf dem UI-Thread passiert. Das bedeutet konkret in unserem Beispiel: Das Delay läuft ab, die Fortführung darf starten, und da diese explizit nicht auf dem UI-Thread laufen soll kann sie auch ausgeführt werden: Sie liefert „Hello World“ zurück, der UI-Thread der auf dieses Result wartet kann weiterlaufen und zeigt den Text am UI an. Der Deadlock ist behoben.

ConfigureAwait(false): Der Haken an der Sache

Was ist nun der Haken daran? Wir erinnern uns an das Achtung von oben: Das .ConfigureAwait(false) gilt nur für diese eine Fortführung, und der SynchronizationContext wird weiter beibehalten. Der Task läuft lediglich nicht auf dessen Thread.

Diese abweichende Konfiguration gilt daher nur für den Task, der auch so konfiguriert wurde. Jeder weitere Task, der nicht auch mit .ConfigureAwait(false) umkonfiguriert wird, unterliegt wieder dem Standardverhalten. Das hat zur Folge das jeder weitere await-Aufruf oder neu erzeugte Task ohne ConfigureAwait(false) auch wieder auf den Thread des gebundenen Kontextes gelegt wird.

Das bedeutet, wir müssen ausnahmslos alle Tasks und auch deren weiteren Task-Aufrufe bis zum (bitteren) Ende mit .ConfigureAwait(false) versehen.

Problematisch wird dies vor allem, wenn man Code aus externen Bibliotheken (z.B. NuGet Packages) aufruft, der seine asynchronen Aufrufe nicht oder nicht konsequent mit .ConfigureAwait(false) versehen hat.

Die unvermeidbare Ausnahme von der Regel

Innerhalb der ersten Methode (in unserem Beispiel das Button1_Click Event), also grundsätzlich innerhalb von UI-Events von ASP.NET WebForms, in ASP.NET MVC oder Web API Actions oder auch in einer Blazor Komponente², dürfen die Aufrufe von asynchronen Methoden nicht mit .ConfigureAwait(false) konfiguriert werden: Die Fortführung, die das Ergebnis des Tasks am UI sichtbar macht, muss ja gerade explizit auf dem UI-Thread (Desktop & Blazor) bzw. dem Thread des Kontextes (ASP.NET, nicht ASP.NET Core) geschehen. Andernfalls erhalten wir zwangsläufig Exceptions die uns mitteilen, dass wir vom falschen Thread aus auf das UI Zugreifen und wir den Zugriff bitte auf den UI-Thread zu legen haben.

² Siehe hierzu auch diese Antwort vom Blazor-Team auf eine entsprechende Frage.

Den Deadlock effektiv und effizient umgehen

Es gibt zwei bzw. eigentlich drei Best Practices, um diese Situation zu umgehen.

  1. In allen asynchronen Methoden grundsätzlich immer .ConfigureAwait(false) verwenden.
  2. Niemals auf asynchrone Tasks synchron warten & blockieren, sondern auch hier immer awaiten. Grundsätzlich gilt: Es gibt keinen Grund, einen Task nicht zu awaiten. Wirklich nicht.
  3. Beides zusammen anwenden.

Sich auf die erste Variante zu beschränken, birgt Gefahren. Zum einen muss .ConfigureAwait(false) in wirklich jedem einzelnen Aufruf durch die asynchronen Methoden komplett hindurchgezogen werden. Das betrifft auch, beziehungsweise vor allem, Code in externen Bibliotheken (z.B. aus NuGet Paketen). Verzichtet ein Bibliotheksauthor bewusst oder versehentlich an einer Stelle auf diesen Aufruf, so besteht wieder die Gefahr eines Deadlocks – und gerade in fremden Bibliothekscode ist diese Situation extrem schwer zu finden, und auch nicht selbst zu beheben.

Die zweite Methode ist definitiv die bessere, denn sie vermeidet es, das der UI-Thread überhaupt blockiert. Dennoch hat dies zur Folge, das alle Tasks zwingend auf dem (nicht blockierenden) UI-Thread abgearbeitet werden, und dieser somit weniger Zeit hat, seiner eigentlichen Aufgabe nachzugehen, nämlich das UI regelmäßig neu zu zeichnen und die Eingaben des Benutzers abzuarbeiten. Das ist alles andere als effizient.

Die dritte Methode verbindet das beste aus beiden Welten: Der UI-Thread blockiert nicht und verhindert somit effektiv Deadlocks, während es die konsequente Anwendung von .ConfigureAwait(false) zudem erlaubt, das unsere asynchronen Tasks auch auf anderen Threads ausgeführt werden können. Dies entlastet zum einen den UI-Thread, wodurch die Oberfläche schneller reagieren kann. Darüber hinaus lastet es zudem den Threadpool und damit die verfügbaren Prozessorkerne besser aus, wodurch die Anwendung tatsächlich mehr Aufgaben zeitgleich ausführen kann. Ein weiterer, großer Vorteil dieser Kombination ist, dass es in dieser Konstellation nicht mehr so problematisch ist, falls in einer genutzten Bibliothek nicht konsequent alle Tasks mit .ConfigureAwait(false) versehen sind.

Kein Kontext, kein Problem?

ASP.NET Core kennt, im Gegensatz zum klassischen ASP.NET, keinen SynchronizationContext.

Hat man also Code, der ausschliesslich in ASP.NET Core läuft oder in Anwendungen, die keinen UI-Thread haben wie z.B. Konsolenanwendungen oder Hintergrunddienste, so besteht keine Gefahr von Deadlocks. Wir können in solchem Code also auf .ConfigureAwait(false) verzichten.

Wenn wir dies tun müssen wir jedoch bedenken, das falls jemand unseren Code doch im Rahmen eines SynchronizationContext aufruft, die Deadlock-Thematik wieder akut wird. Die Wiederverwendbarkeit unseres Codes in anderen Anwendungen, dazu gehören auch ASP.NET Core Blazor-Anwendungen, ist also eingeschränkt.

Zusammenfassung

Die Methode .ConfigureAwait(false) konfiguriert einen Task so, das dessen Folgetask (Fortführung, Continuation) nicht auf dem Thread des Synchronization-Kontextes ausgeführt wird. Dies kann Deadlocks verhindern und die Ausführung unserer Anwendung effizienter machen.

Es gibt eine paar Faustregeln, wann man .ConfigureAwait(false) verwenden sollte und wann nicht.

Die Verwendung ist zu vermeiden innerhalb von

  • UI-Events (Windows Forms, WPF, WinRT ASP.NET WebForms),
  • Actions (ASP.NET MVC und ASP.NET Web API),
  • Blazor Components.

Die Verwendung ist optional

  • wenn man blockierendes Warten auf Tasks vollständig vermeiden kann,
  • in jeglichen Anwendungen ohne speziellen SynchronizationContext wie

    • Konsolenanwendungen,
    • Hintergrundiensten,
    • ASP.NET Core (ausgenommen Blazor).

Die Verwendung ist vorzusehen in jedem asynchronem Code der aufgerufen werden kann von

  • Windows Forms Anwendungen,
  • WPF Anwendungen,
  • WinRT Anwendungen,
  • ASP.NET WebForms, MVC und Web API (nicht ASP.NET Core) und Blazor,
  • Bibliotheken, die von den oben gelisteten Anwendungstypen verwendet werden können oder sollen.
Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Database Access with Sessions
.NET
KP-round

Data Access in .NET Native AOT with Sessions

.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
Old computer with native code
.NET
KP-round

Native AOT with ASP.NET Core – Overview

Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
.NET
KP-round

Optimize ASP.NET Core memory with DATAS

.NET 8 introduces a new Garbage Collector feature called DATAS for Server GC mode - let's make some benchmarks and check how it fits into the big picture.
09.10.2023
.NET CORE
pg

Incremental Roslyn Source Generators: High-Level API – ForAttributeWithMetadataName – Part 8

With the version 4.3.1 of Microsoft.CodeAnalysis.* Roslyn provides a new high-level API - the method "ForAttributeWithMetadataName". Although it is just 1 method, still, it addresses one of the biggest performance issue with Source Generators.
16.05.2023
AI
favicon

Integrating AI Power into Your .NET Applications with the Semantic Kernel Toolkit – an Early View

With the rise of powerful AI models and services, questions come up on how to integrate those into our applications and make reasonable use of them. While other languages like Python already have popular and feature-rich libraries like LangChain, we are missing these in .NET and C#. But there is a new kid on the block that might change this situation. Welcome Semantic Kernel by Microsoft!
03.05.2023
.NET
sg

.NET 7 Performance: Regular Expressions – Part 2

There is this popular quote by Jamie Zawinski: Some people, when confronted with a problem, think "I know, I'll use regular expressions." Now they have two problems."

In this second article of our short performance series, we want to look at the latter one of those problems.
25.04.2023