Umdenken mit async/await

10. Oktober 2013

Mit dem .NET-Framework in der Version 4.5 und C# 5 hat Microsoft C# auf Sprachebene um asynchrone Programmierparadigmen erweitert. Ziel dessen ist es asynchrone Programmierung für den Entwickler ähnlich der synchronen Programmierung zu gestalten und dabei Threads durch die Nutzung der Task Parallel Library (TPL) so optimal wie möglich auszunutzen. Sowohl hier im Flurfunk der SDX AG als auch auf anderen Internetseiten finden sich bereits zahlreiche Artikel, Tutorials und Whitepaper, die sich mit den Grundlagen beschäftigen. Was sollten aber Entwickler, außer dem Methoden-Modifikator “async”, dem Operator “await” und dem Pattern noch wissen, um qualitativ hochwertigen und fehlerfreien Code zu produzieren?

Viele .NET-Entwickler sind bisher gewohnt, dass sie einen Thread erzeugen und starten bzw. einen Threadpool oder ferner auch TPL-Funktionen nutzen, um eine parallelisierbare Aufgabe durchzuführen. Das Abschließen jener Aufgaben wird letztlich (wenn erforderlich) von einem anderen Thread/Kontext abgewartet. Zudem kommen Synchronisationsmechanismen wie das lock-Schlüsselwort zum Einsatz. Bei der Umstellung auf async/await muss jedoch ein Umdenken stattfinden, da die altbekannten blockierenden Mechanismen mit den neuen nichtblockierenden Mechanismen nicht kompatibel sind. Ursache dafür ist bei async/await die von der TPL hergestellte Threadtransparenz durch die Nutzung von Kontexten in Form von Threadpools, welche von dem in die Sprache integrierten async/await-Pattern wiederverwendet werden.

Wenngleich im Umgang mit async/await von Tasks die Rede ist, sollte der Entwickler dennoch wissen, in welchem Thread der geschriebene Code ausgeführt wird. Dies wird bereits an einem sehr einfachen Beispiel deutlich. Man nehme eine einfache WPF-Anwendung mit einem Button, der eine asynchrone Aktion durchführen soll. In den Augen eines async/await-Neulings, der alte und neue Konzepte mischt, da er das Konzept von async/await noch nicht verinnerlicht hat, könnte folgender Code valide aussehen:

private async void HandleButtonClick(object sender, RoutedEventArgs e) {
    this.DoSomeWorkAsync().Wait();
}

private async Task DoSomeWorkAsync() {
    await Task.Delay(1000);
}

Tatsächlich handelt es sich hierbei um einen Deadlock. Der Klick auf den Button wird im UI-Thread durchgeführt, woraufhin der durch den Aufruf der Methode DoSomeWorkAsync erzeugte Task im selben Kontext und somit auch vom UI-Thread ausgeführt wird. Da dieser Thread nach dem await im Task aber freigegeben wird, wird anschließend das blockierende Warten auf den erzeugten Task durchführt. Im Kontext des Tasks ist aber nur dieser Thread verfügbar, weshalb weder der Task noch das Warten auf ihn beendet werden kann. Mehr zum Threadwechsel mit async/await lässt sich erfahren, wenn man sich mit der generierten StateMachine auseinandersetzt, deren einfaches Prinzip verstanden werden sollte, um fehlerfreien Code zu schreiben.

Auflösen ließe sich dieser Deadlock durch nichtblockierendes Warten auf DoSomeWorkAsync mithilfe des await-Operators:

private async void HandleButtonClick(object sender, RoutedEventArgs e) {
    await this.DoSomeWorkAsync();
}

Eine andere Möglichkeit besteht in der Konfiguration des abgewarteten Delay-Tasks. Durch ConfigureAwait(false) wird nach Abwarten des Tasks der Kontext gewechselt, wodurch ein anderer Thread zum Beenden des Aufrufs verwendet wird:

private async Task DoSomeWorkAsync() {
    await Task.Delay(1000).ConfigureAwait(false);
}

Für die Vermischung blockierender und nichtblockierender Mechanismen lassen sich weitere beliebig komplexe Beispielproblemfälle aufzeigen. Ziel soll jedoch sein, solche Vermischungen zu vermeiden. Es ist deshalb zu empfehlen durchgehend nichtblockierende Mechanismen zu verwenden und die Anwendung durchgehend unter Nutzung von async/await umzusetzen. Im Anschluss findet sich dazu eine kleine Tabelle, für vergleichbar oder äquivalent einsetzbare nichtblockierende Mechanismen zu blockierenden Mechanismen:

Blockierende Mechanismen

Nichtblockierende Mechanismen

task.Wait(), task.Result

await

task.WaitAny()

task.WhenAny()

task.WaitAll()

task.WhenAll()

Thread.Sleep()

await Task.Delay()

lock

await SemaphoreSlim.WaitAsync()

Sofern konsequent nichtblockierende Mechanismen durch alle Schichten einer Anwendung verwendet werden, steht fehlerfreiem Code bezüglich der Asynchronität nichts mehr im Wege.

Weiterführende Artikel zum Thema async/await: