12. Nov 2018
Lesedauer 13 Min.
Async ist die Zukunft
Code zu async/await refaktorisieren
Synchronen Code in nebenläufigen zu verwandeln gelingt dank des iPob-Verfahrens.

Asynchroner Code bedarf besonderer Sorgfalt. Andernfalls läuft der Code im besten Fall einfach nicht asynchron oder das Programm glänzt durch besonders schwer zu findende Fehler. Deshalb bedarf auch das Umstellen auf Nebenläufigkeit besonderer Aufmerksamkeit.Dieser Artikel zeigt die Fallstricke bei diesem Prozess auf und wie Sie Stück für Stück Ihren Code auf async umstellen. Ein kleines Toolset, das bei diesem Übergang hilft, rundet den Artikel ab.In [1] gab es einen Überblick, was async und await vollbringen und wie sie arbeiten. In diesem Artikel soll deshalb darauf gar nicht mehr eingegangen werden. Es gibt mit den Reactive Extensions auch einige asynchrone Paradigmen. Auch hierauf wird der Artikel nicht eingehen.Vielmehr soll nur das behandelt werden, was direkt im .NET Framework enthalten ist. Denn man sollte erst das im Framework Vorhandene verstehen, bevor man sich mit den anderen Technologien auseinandersetzt. Der Code zum Artikel ist unter [2] verfügbar.
Async ist obligatorisch
Wann immer Sie mit Ihren Projekten in die Cloud gehen oder Backend-Systeme haben, müssen Sie sich überlegen, ob Sie nicht mit nebenläufigem Code mehr Performance erreichen. Und sobald Sie anfangen, Ihre Projekte in Service Fabric oder Software as a Service zu schieben, werden Sie zwangsläufig mit asynchronem Code in Berührung kommen.Auch Ökosysteme wie Mass Transit oder Entity Framework werden immer mehr async anbieten. Andere Anbieter gehen ebenfalls in diese Richtung. Und das heißt schlussendlich, dass Sie nur noch diese async-APIs nutzen können und keine synchronen mehr.Kurz und gut: Es führt kein Weg daran vorbei, sich mit Nebenläufigkeit auseinanderzusetzen. Doch wenn Sie das nicht richtig machen, werden Sie irgendwann frustriert denken: „Async ist extrem schwierig. Ich habe irgendwelche Deadlocks und weiß nicht, warum.“Dabei ist der Einsatz von async/await eigentlich gar nicht schwierig, solange Sie die Nebenläufigkeit komplett umsetzen. Es wird nur dann zum Albtraum, wenn Sie an den Berührungsgrenzen zwischen asynchronem und synchronem Code Fehler machen.Async gibt es nicht nur unter .NET. Es wurde schon in JavaScript implementiert. Seit ES2017 kann man die Schlüsselwörter async und await nutzen. Hier ein Beispiel für einen asynchronen Code in JavaScript:async function chainAnimationsPromise(
elem, animations) {
let ret = null;
try {
for(const anim of animations) {
ret = await anim(elem);
}
} catch(e) { /* ignore and keep going */ }
return ret;
}
Beispielsweise lassen sich in Babel Generatoren einhängen, die async-Code erzeugen.Unter .NET gibt es async schon seit dem .NET Framework 4.0. Besonders deutlich wird das bei der Klasse httpclient, denn diese ist nur noch in einer async-Variante vorhanden. Sie können also nur noch GetAsync oder PutAsync aufrufen.
using (var client = new HttpClient()){
var response =
await client.GetAsync("api/products/1");
if (response.IsSuccessStatusCode)
{
return await response.Content
.ReadAsAsync<Product>();
}
}
Auch wenn Sie Software as a Service mit dem Azure SDK nutzen, um Software für die Cloud zu schreiben, sind Sie nur noch async unterwegs:
var queryable = client.CreateDocumentQuery<Entity>(...)
.AsDocumentQuery();
while (queryable.HasMoreResults)
{
foreacht(var e in await queryable
.ExecuteNextAsync<Entity>())
{
// Iterate through entities
}
}
Allerdings gibt es auch APIs von Microsoft, die sowohl asynchron als auch synchron angeboten werden. Wenn Sie sich aber entscheiden, beide Versionen anzubieten, müssen Sie zwei getrennte Codebasen redundant implementieren, um Deadlocks zur Laufzeit zu vermeiden.
Task als Abstraktionsebene
Asynchrone Operationen sind eventgetrieben. Das heißt, dass der Aufrufer einer Aufgabe während der Abarbeitung etwas anderes tun kann. Die Operation meldet sich per Event, sobald sie fertig ist oder ein Fehler aufgetreten ist.Diese Art der Programmierung wird oft bei IO-Bound-Anwendungen angewendet. Input-Output (IO) findet beispielsweise bei so etwas wie Datenbank-, Socket- und Dateisystemzugriffen oder Web Service Calls statt. Auch innerhalb von Service Fabric, wenn man einen Übergang von Speicher- zu Dateisystem hat, passieren diese Vorgänge.Windows bietet die IO Completion Ports. Diese melden dem Auftraggeber zurück, wenn eine Aufgabe erledigt ist. Die Art des eventgetriebenen Ablaufs ist selbstverständlich wesentlich effizienter, da der Auftraggeber nicht blockiert ist und etwas anderes machen kann..NET bietet das System.Threading.Task-Objekt an. Das ist aus Sicht des Autors ein Abstraktionslayer, der auf der einen Seite die IO-Bound-Vorgänge repräsentiert, auf der anderen Seite aber auch die Compute-Bound- oder CPU-Bound-Vorgänge.Das Task-Objekt fasst diese beiden in einem Objekt zusammen. Das Task-Objekt repräsentiert immer einen Zustand und das Resultat einer asynchronen Operation.Task ist das neue void
Die Verwendung von Task ist zwar nicht obligatorisch, doch sehr empfehlenswert. Verwenden Sie also immer möglichst async Task statt async void. Warum sollten Sie das tun?async void hat einen gravierenden Nachteil. Sobald das erste await erreicht wird, ist für den Aufrufer die Aufgabe erledigt und er springt zurück.Wenn dann nach einem await-Statement eine Exception auftritt, sieht der Aufrufer sie nicht. Das kann zu ziemlich üblen Bugs führen. Als Merksatz könnte man sagen, dass Task das neue void ist.Die zweite wichtige Regel ist: async all the way. Das bedeutet, dass man nicht async und blockierenden Code mischen sollte. Ganz besonders sollte blockierender Code keinesfalls asynchronen Code aufrufen. Es ist in Ordnung, aus asynchronem Code synchronen aufzurufen, aber nicht umgekehrt. Andernfalls besteht die Gefahr, dass der Code in Deadlocks laufen kann.Außerdem ist es ineffizient, weil der Code wieder blockiert und der Thread nichts machen kann.Wenn man diese zwei Regeln konsequent anwendet, kann man sagen, dass async/await einen viralen Charakter hat. Es ist wie eine hochinfektiöse Krankheit. Wenn man sich die einfängt, verbreitet sie sich überall.Warum sollte man sich das also antun? Async gibt den Servern einen Performance-Schub. Denn jeder Thread, der nicht benötigt wird, ist frei und kann andere Dinge tun.Asynchroner Code auf IO-Bound-Operationen wird in 99 Prozent der Fälle wesentlich schneller sein als blockierend ausgeführter Code. Aber warum ist das so?Pro App-Domäne hat man einen Threadpool (Bild 1). Und der ist unterteilt in zwei Sub-Pools: Worker Pool und IO Pool. Der erste wird immer dann verwendet, wenn man so etwas macht wie Task.Run, Task.Factory.StartNew, Parallel.For oder Parallel.ForEach. Den Worker-Pool sollte man nur für Compute-Bound-Operationen verwenden.
Vereinfacht gesagt verwaltet der Worker-Pool die Threads so: Nehmen wir an, Sie haben eine Maschine mit acht Kernen. Dann haben Sie vereinfacht gesehen auch acht Threads zur Verfügung, die tatsächlich gleichzeitig abgearbeitet werden können. Sie sind aber nicht voralloziert.Jedes Mal, wenn Sie jetzt Task.Run oder Task.Factor.StartNew etc. aufrufen, fordern Sie Kapazität von diesem Worker Pool an. Wenn dessen Kapazitätsgrenze erreicht ist, passiert Folgendes: Der Worker Pool sagt dem Programm, dass er die Anfrage nicht mehr erfüllen kann. Es müssen also neue Threads erzeugt werden (Ramp up).Dabei handelt es sich aber um eine sogenannte Stop-the-World-Operation. Das heißt, das Programm muss warten, bis die Threads erzeugt sind.Das geht in Sprüngen von 8, 16, 32, 64 Threads. Maximal können Sie auf einem 32-Bit-System etwa 1000 logische Threads haben, auf einem 64-Bit-System etwa 30.000 bis 32.000.Der zweite Sub-Pool ist der IO Pool. Der wird verwendet, wenn Sie await iobound oder iobound.FireForget() machen. FireForget ist eine Operation, auf die nicht durch await gewartet wird. Sie soll einfach abgearbeitet werden. Ob es dort eine Exception gibt oder nicht, interessiert nicht.Der IO Pool wird nie blockiert. Das heißt, die beispielsweise acht Threads können Tausende von nebenläufigen Operationen abarbeiten. Das geht, weil sie eben nie blockiert werden. Einen Ramp-up gibt es deshalb für diesen Pool nicht und er verbraucht weniger Ressourcen.Außerdem braucht es hier weniger Context-Switches, denn es wird versucht, dass immer der gleiche Thread eine Continuation weiterbearbeitet.
Geschwindigkeiten
Isoliert betrachtet ist der synchrone Code in der Regel schneller als der asynchrone, da beim asynchronen ja noch Verwaltung und Statemachine als Overhead dazukommen. Allerdings gilt das nicht mehr, wenn viele nebenläufige Operationen ausgeführt werden. Dann gewinnt der asynchrone, weil er eben gleichzeitig beliebig viele Operationen abarbeiten kann.
|
Messungen etwa, die für NServiceBus durchgeführt wurden, zeigen das Ergebnis wie in Tabelle 1 [3][4].Und selbst wenn man berücksichtig, dass man nur der Statistik glauben darf, die man selbst gefälscht hat, ist doch die Aussage klar und deutlich: Asynchron bringt wesentlich mehr Durchsatz.
Async all the things
Wenn man den bestehenden Code auf asynchron umstellen will, durchläuft ein Projekt mehrere Phasen.- Identify: Wo kann ich async und await anwenden?
- Explore: Kann es in den Codepfaden, die auf async/await umgestellt werden sollen, etwas geben, das das unmöglich macht? Oder bringt das nicht genug, sodass sich die Investition in Zeit und Aufwand nicht lohnen?
- Overcome: Wie können Sie mögliche Probleme lösen?
- Bring together: Wie kann man den Code über Wochen und Monate lauffähig halten, um ihn Schritt für Schritt in Richtung von async/await zu bringen?
Identify
Hier überlegt man sich, welcher Code IO Bound ist. In Bild 2 sind das die orangen Bereiche. Hier ergibt es Sinn, den Code auf async/await umzustellen. Bei den weißen Bereichen ist es nicht sinnvoll, async anzuwenden.
Am Beispiel von NServiceBus sieht das zum Beispiel folgendermaßen aus: In der Konfiguration werden nur ein paar Connectionstrings gesetzt. Hier ist sicher kein async/await nötig. Gleiches gilt für das Scanning, in dem so etwas wie Reflection auf Assemblies angewendet wird.Die Pipeline ist der Treiber, der dafür sorgt, dass der Code des Kunden irgendwann aufgerufen wird. Der muss async/await sein (Bild 3).
Der Transport ist beispielsweise RabbitMQ, der Messages weiterleitet. Dort gibt es schon Threading vom RabbitMQ-Client. Deshalb muss der async sein.Und dann gibt es noch die Persistenz. Auch hier braucht es async, wenn der Zustand auf der Platte abgelegt werden soll.
Explore
Im nächsten Schritt muss man nun eruieren, ob es für die identifizierten Module oder Codestellen zu irgendwelchen Problemen kommen könnte.Um das zu prüfen, bauen Sie zwei Spikes: einen High-level-Spike, in dem Sie von oben anfangen, async einzubauen, und einen Low-level-Spike, der von unten beginnt.Das läuft ziemlich stupide ab: async Task, async Task, await und so weiter. So beginnt man erst von oben und dann mit dem Low-level-Spike von unten und irgendwann trifft man zusammen.Wenn Sie das so machen, treffen Sie auf einige Konstrukte, die sich als problematisch herausstellen. Dazu zählen zum Beispiel Eventhandler, Lock, Monitor, Semaphor, Mutex, Thread, Ambient State und Remote Procedure Calls.Beispiel Eventhandler: Ein Eventhandler ist ein Delegate, also so etwas wie:public delegate void EventHandler(
object sender, EventArgs e);
public delegate void EventHandler<TEventArgs>(
object sender, TEventArgs e);
Wenn man daraus async macht, wird daraus:
async void MyEventHandler(object sender, EventArgs e)
{
await Task.Yield();
throw new InvalidOperationException();
}
Das Problem ist wie oben genannt, dass die Exception nie dort ankommt, wo sie hin soll.Beispiel ManualResetEvent: Dieses Konstrukt kann man sich wie eine Tür vorstellen. Diese ist geschlossen und die Threads müssen davor warten, bis das ManualResetEvent gesetzt ist.
var syncEvent = new ManualResetEvent(false);
var t1 = Task.Run(() => {
syncEvent.WaitOne();
});
var t2 = Task.Run(() => {
Thread.Sleep(2000);
syncEvent.Set();
});
await Task.WhenAll(t1, t2);
Hier werden zwei Threads blockiert.Problematisch sind also alle void- und wait-Anweisungen wie WaitOne() oder Sleep().Beispiel Lock: Auch Lock-Statements sind problematisch. Grob würde der Code für ein Lock-Statement so aussehen:
var locker = new object();
lock(locker)
{
await Task.Yield();
}
Wenn man das kompiliert, gibt der Compiler folgende Fehlermeldung aus:
Error CS1996
Cannot wait in the body of a lock statement
Das ist also nicht erlaubt. Der Grund liegt in der Eigenschaft des Lock-Statements. Der Thread, der ein Lock-Statement öffnet, muss es auch wieder schließen. Das ist hier nicht gewährleistet, weshalb es zu der Fehlermeldung kommt.Das Lock-Statement ist nichts anderes als syntaktischer Zucker für ein Try-finally-Statement mit Zugriff auf die Monitor-Klasse aus dem .NET Framework. Und in dem Finally-Statement könnte eben ein anderer Thread aktiv sein. Dies ist nicht zulässig, da Locks immer von dem Thread gelöst werden müssen, von dem der Lock gehalten wurde.Beispiel Ref/Out-Parameter: Auch der Code
static async Task Out(
string content, out string parameter)
{
var randomFileName = Path.GetTempFileName();
using (var writer = new
StreamWriter(randomFileName))
{
await writer
.WriteLineAsync(content);
}
parameter = randomFileName;
}
führt zu einem Compiler-Fehler:
Error CS1988
Async methods cannot have ref or out parameters
Der Grund ist eigentlich recht einfach: Wenn aus Sicht des Aufrufers die Methode zurückkehrt, sollte der Out-Parameter gesetzt sein. Das kann aber nicht garantiert werden.Beispiel Ambient State: Dieser Name ist die Idee des Autors. Das ist der Zustand, der in einer Klasse gehalten wird, aber nicht zu dieser Klasse gehört:
class ClassWithAmbientState
{
static ThreadLocal<int> ambientState =
new ThreadLocal<int>(() => 1);
public void Do()
{
ambientState.Value++;
}
}
Verwendet man diese Klasse im Code
var instance = new ClassWithAmbientState();
var tasks = new Task[3];
for (int i = 0; i < 3; i++) {
tasks[i] = Task.Run(() => {
instance.Do();
Thread.Sleep(200);
instance.Do();
});
}
sieht das Ergebnis aus, wie in Bild 3 zu sehen ist. Wie man sieht, hat jeder Thread seinen eigenen Zustand. Das hat man leider häufig bei WCF Proxy Channel Factories.Zusammenfassend kann man folgende Merkregel ausgeben: Ältere Konstrukte, die an die Notation von Threads gebunden sind, fallen in der Welt von async/await auseinander. Threads sind tot, es lebe die Task.
Overcome
Nach der Phase der Erforschung geht es nun darum, die einzelnen Konstrukte so umzuwandeln, dass sie auch unter async/await funktionieren.Eventhandler: Will man Eventhandler nicht ersetzen, dann muss aus dem Codepublic delegate void
EventHandler(
object sender, EventArgs e);
public delegate void
EventHandler<TEventArgs>(
object sender, TEventArgs e);
async void MyEventHandler(object sender, EventArgs e)
{
await Task.Yield();
throw new InvalidOperationException();
}
folgender Code werden:
public delegate Task AsyncEventHandler(
object sender, EventArgs e);
async Task MyAsyncEventHandler(
object sender, EventArgs e) { }
async Task MyEventHandler(object sender, EventArgs e)
{
await Task.Yield();
throw new InvalidOperationException();
}
In .NET kann man seinen eigenen Delegate deklarieren und der ist nicht mehr void, sondern eben Task. Der Vorteil ist, dass man die Events sogar intern nebenläufig ausführen lassen kann, indem man über GetInvocationList() auf die Aufrufliste zugreift und mit Task.WhenAll(handlerTasks) darauf wartet, bis alle ausgeführt sind.
protected virtual Task OnMyAsyncEvent() {
var invocations = handler.GetInvocationList();
var handlerTasks = new Task[invocations.Length];
for (int i = 0; i < invocations.Length; i++) {
handlerTasks[i] =
((AsyncEventHandler)invocations[i])(...);
}
return Task.WhenAll(handlerTasks);
}
In diesem Fall verschwindet auch die Exception nicht im Nichts, sondern wird tatsächlich durchgereicht (Bild 4).
Ein anderer Ansatz ist, auf Eventhandler zu verzichten, beispielsweise indem man ein Function Delegate nutzt, das einen Task zurückgibt.ManualResetEvent: Das Beispiel von oben
var syncEvent = new ManualResetEvent(false);
var t1 = Task.Run(() => {
syncEvent.WaitOne();
});
var t2 = Task.Run(() => {
Thread.Sleep(2000);
syncEvent.Set();
});
await Task.WhenAll(t1, t2);
lässt sich mit einer einfachen TaskCompletionSource anpassen. Eine TaskCompletionSource ist ein Task, den man kontrollieren kann, also eine Art manueller Task. Und so kann man den Code umschreiben zu:
var tcs = new TaskCompletionSource<object>();
var t1 = ((Func<Task>)(async () => {
await tcs.Task;
});
var t2 = ((Func<Task>)(async () => {
await Task.Delay(2000);
tcs.TrySetResult(null);
});
await Task.WhenAll(t1, t2);
Die TaskCompletionSource gehört unbedingt in Ihren Werkzeugkasten. Unter [5] finden Sie noch ein Beispiel.Lock Statements: Die wichtigste Frage ist hier, ob man den Code so umstellen kann, dass man nicht innerhalb des Lock Statements per await auf den Abschluss einer Task warten muss. Denn Lock ist eine schützenswerte Ressource, die eben vom gleichen Thread wieder gelöst werden muss.Eine Möglichkeit ist, das Lock zu setzen, etwa eine Variable zu lesen, das Lock zu lösen, die asynchrone Operation auszuführen und dann das Lock wieder zu setzen. Nicht vergessen darf man dann noch zu checken, ob sie sich in der Zwischenzeit verändert hat oder nicht.Wenn das so aus irgendwelchen Gründen nicht funktioniert, kann man das SemaphoreSlim verwenden.Ein Semaphor kann man sich wie eine Mautstelle vorstellen, an die Bedingungen geknüpft sind. Beispielsweise kann der Platz nur für ein Auto ausreichen. Wenn Sie dem Semaphor acht Plätze geben, führen acht Straßen hinein und acht Straßen hinaus. Somit können gleichzeitig acht Autos durchfahren.
int sharedResource = 0;
var semaphore = new SemaphoreSlim(1);
var tasks = new Task[3];
for (int i = 0; i < 3; i++) {
tasks[i] = ((Func<Task>) (async () => {
await semaphore.WaitAsync();
sharedResource++;
semaphore.Release();
}}))();
}
await Task.WhenAll(tasks);
Im diesem Code hat der Semaphor nur einen Platz, sprich, es kann nur ein Auto durchfahren.
await semaphore.WaitAsync();
Wenn der Platz frei ist, kann der Code hinein und kann die sharedResource hochzählen und dann die Semaphore wieder freigeben. Das heißt, alle asynchronen Operationen, die hier durch wollen, müssen warten, bis der Platz wieder frei ist. Sie können in der Zwischenzeit etwas anderes machen. Sobald der Platz wieder frei ist, kommt irgendeiner der anderen zum Zuge. Die Reihenfolge ist dabei nicht garantiert.Wichtig ist aber, dass Semaphore 100- bis 1000-mal langsamer sind als Lock Statements. Wenn es also schnell gehen soll, sollten Sie sich noch einmal überlegen, ob Sie das Lock Statement nicht anders implementieren können. Trotzdem ist ein Semaphor in bestimmten Situationen ein gutes Mittel zum Zweck. Allerdings kann man nur SemphoreSlim asynchron verwenden. Die anderen Semaphore haben das API nicht.Sie können sich das Leben vereinfachen, indem Sie ein Konstrukt mit using schreiben, sodass Sie nicht immer wieder try-finally schreiben müssen:
using (await semaphore.LockAsync())
{
sharedRessource++;
}
ref/out-Parameter: Der Compiler erlaubt die Verwendung dieser Syntax nicht. Hier lässt sich aber Folgendes schreiben:
static async Task<string> Out(string content)
{
var randomFileName = Path.GetTempFileName();
using (var writer = new StreamWriter(randomFileName))
{
await writer.WriteLineAsync(content);
}
return randomFileName;
}
Der Trick: Sie machen aus dem out-Parameter eine Rückgabe mittels return. Denn in einem Task können Sie etwas zurückgeben. Da C# 7 value-Tupel mit sehr einfacher Syntax anbietet, lassen sich damit mehrere Werte zurückgeben.Auch ref-Parameter sind recht einfach. Hier macht man einen Reference-Type, den man hineingibt.Ambient State: Ambient ist nicht nur Thread-static, Thread-local, Transaction Scope, sondern auch, wenn Sie einen IoC-Container verwenden, per-Thread-Scope. Dadurch kann es passieren, dass ein Aufruf im async-Code zu unterschiedlichem Laufzeitverhalten führt – deshalb lieber vermeiden.
class ClassWithAmbientState {
static AsyncLocal<int> ambientState =
new AsyncLocal<int>();
static ClassWithAmbientState() {
ambientState.Value = 1;
}
public void Do() {
ambientState.Value++;
}
}
Ab .NET 4.6 gibt es ein neues Konstrukt, das AsyncLocal heißt. Man kann ThreadStatic beziehungsweise ThreadLocal durch AsyncLocal ersetzen. Allerdings ist das ein magisches Konstrukt, das schwer nachzuvollziehen ist. Deshalb empfiehlt es sich, den Zustand nicht zu halten, sondern ihn in die Methode hineinzugeben und den in dem Beispiel inkrementierten Wert wieder zurückzuerhalten.
class ClassWithFloatingState {
public int Do(int current) {
current++;
return current;
}
}
Der Code, der die Klasse verwendet, könnte so aussehen:
var instance = new ClassWithFloatingState();
var tasks = new Task[3];
for (int i = 0; i < 3; i++) {
tasks[i] = ((Func<Task>)(async () => {
int current = 1;
current = instance.Do(current);
await Task.Delay(200).ConfigureAwait(false);
instance.Do(current);
}))();
}
await Task.WhenAll(tasks);
Der Vorteil ist, dass dieser Code sicher in puncto Nebenläufigkeit (concurrency safe), funktionaler und frei von irgendwelchen magischen Konstrukten ist.
Bring it together
Wenn alle Problemstellen ermittelt und Lösungen dafür gefunden wurden, dann kommt der Zeitpunkt, an dem alles zusammengebracht werden soll. Hier empfiehlt sich, auf unterster Ebene anzufangen. Damit wird in einem ersten Schritt ausvoid HighLevel() {
try {
MidLevel();
} catch(InvalidOperationException) { }
}
void MidLevel() {
...
LowLevel();
...
}
void LowLevel() {
}
der folgende Code:
void HighLevel() {
try {
MidLevel();
} catch(InvalidOperationException) { }
}
void MidLevel() {
...
LowLevel().GetAwaiter().GetResult();
...
}
async Task LowLevel() {
}
Zu beachten ist, dass hier auf dem mittleren Level GetAwaiter().GetResult() steht. Im Gegensatz zu Wait() oder Result() packt GetAwaiter().GetResult() eine mögliche AggregateException aus und reicht die mögliche InvalidOperationException als solche weiter. Wait() oder Result() machen das nicht. Das Catch auf InvalidOperationException würde also ins Leere laufen.Nach dem Einchecken des Codes geht es dann mit der mittleren Ebene weiter.
void HighLevel() {
try {
MidLevel().GetAwaiter().GetResult();
} catch(InvalidOperationException) { }
}
async Task MidLevel() {
...
await LowLevel().ConfigureAwait(false);
...
}
async Task LowLevel() {
}
Hier kommt dann await LowLevel() {} ins Spiel. Sie müssen sich überlegen, ob es sich um eine Bibliothek oder ein Framework handelt. Wenn dem so ist, wenn Sie also Zugriff auf das Execution Environment wie HttpContext.Current haben müssen, dann müssen Sie noch hinschreiben: ConfigureAwait
(false). Wenn Sie Zugriff auf das Single-Thread-Department zum Beispiel in einer Windows-Forms-Anwendung brauchen, um auf ein UI-Control zuzugreifen, dann benötigen Sie diese Zeile nicht. Standardmäßig ist dann Context-Capturing eingeschaltet. Empfehlenswert ist aber, das Context-Capturing auszuschalten. Bis zu diesem Punkt läuft der Code immer noch synchron. Es wurde also noch nicht viel erreicht. Im letzten Schritt wird der Code nun asynchron geschaltet.
(false). Wenn Sie Zugriff auf das Single-Thread-Department zum Beispiel in einer Windows-Forms-Anwendung brauchen, um auf ein UI-Control zuzugreifen, dann benötigen Sie diese Zeile nicht. Standardmäßig ist dann Context-Capturing eingeschaltet. Empfehlenswert ist aber, das Context-Capturing auszuschalten. Bis zu diesem Punkt läuft der Code immer noch synchron. Es wurde also noch nicht viel erreicht. Im letzten Schritt wird der Code nun asynchron geschaltet.
async Task HighLevel() {
try {
await MidLevel().ConfigureAwait(false);
} catch(InvalidOperationException) { }
}
async Task MidLevel() {
...
await LowLevel().ConfigureAwait(false);
...
}
async Task LowLevel() {
}
Damit ist der Code nebenläufig: Async all the way.
Fazit
Async/await haben viralen Charakter. Wenn in einem Code-Pfad asynchron gearbeitet werden soll, dann muss der gesamte Pfad mit async/await versehen werden. Mit dem iPob-Verfahren können Sie sehr effizient synchronen Code in nebenläufigen Code refaktorisieren.Fussnoten
- Daniel Marbach, Task ist das neue void, dotnetpro 2/2018, Seite 23 ff., http://www.dotnetpro.de/A1802Async
- RearchitectTowardsAsyncAwait,, http://www.dotnetpro.de/SL1812AsyncRefactor1
- RabbitMQ updates in NServiceBus 6,, http://www.dotnetpro.de/SL1812AsyncRefactor2
- Particular/EndToEnd, www.dotnetpro.de/SL1812Async Refactor3,
- RearchitectTowardsAsyncAwait, http://www.dotnetpro.de/SL1812AsyncRefactor4