In meiner Praxis sehe ich in einer anderen Umgebung häufig Code wie den folgenden:
[1] var x = FooWithResultAsync(/*...*/).Result; // [2] FooAsync(/*...*/).Wait(); // [3] FooAsync(/*...*/).GetAwaiter().GetResult(); // [4] FooAsync(/*...*/) .ConfigureAwait(false) .GetAwaiter() .GetResult(); // [5] await FooAsync(/*...*/).ConfigureAwait(false) // [6] await FooAsync(/*...*/)
Aus der Kommunikation mit den Autoren solcher Zeilen wurde deutlich, dass sie alle in drei Gruppen unterteilt sind:
- Die erste Gruppe sind diejenigen, die nichts über mögliche Probleme beim Aufrufen von
Result/Wait/GetResult
. Die Beispiele (1-3) und manchmal (6) sind typisch für Programmierer aus dieser Gruppe. - Die zweite Gruppe umfasst Programmierer, die sich möglicher Probleme bewusst sind, aber die Ursachen ihres Auftretens nicht kennen. Entwickler aus dieser Gruppe versuchen einerseits, Zeilen wie (1-3 und 6) zu vermeiden, andererseits Missbrauchscode wie (4-5);
- Die dritte Gruppe, meiner Erfahrung nach die kleinste, sind diejenigen Programmierer, die wissen, wie der Code (1-6) funktioniert, und daher eine fundierte Entscheidung treffen können.
Ist das Risiko möglich und wie groß es ist, hängt die Verwendung des Codes wie in den obigen Beispielen, wie bereits erwähnt, von der Umgebung ab .

Risiken und ihre Ursachen
Die Beispiele (1-6) sind in zwei Gruppen unterteilt. Die erste Gruppe ist Code, der den aufrufenden Thread blockiert. Diese Gruppe umfasst (1-4).
Das Blockieren eines Threads ist meistens eine schlechte Idee. Warum? Der Einfachheit halber nehmen wir an, dass alle Threads aus einem Thread-Pool zugeordnet sind. Wenn das Programm eine Sperre hat, kann dies zur Auswahl aller Threads aus dem Pool führen. Im besten Fall verlangsamt dies das Programm und führt zu einer ineffizienten Ressourcennutzung. Im schlimmsten Fall kann dies zu einem Deadlock führen, wenn ein zusätzlicher Thread zum Ausführen einer Aufgabe benötigt wird, der Pool ihn jedoch nicht zuordnen kann.
Wenn ein Entwickler Code wie (1-4) schreibt, sollte er darüber nachdenken, wie wahrscheinlich die oben beschriebene Situation ist.
Aber es wird viel schlimmer, wenn wir in einer Umgebung arbeiten, in der es einen Synchronisationskontext gibt, der sich vom Standard unterscheidet. Wenn es einen speziellen Synchronisationskontext gibt, erhöht das Blockieren des aufrufenden Threads die Wahrscheinlichkeit, dass ein Deadlock häufig auftritt. Wenn der Code aus den Beispielen (1-3) im WinForms-UI-Thread ausgeführt wird, führt dies fast garantiert zu einem Deadlock. Ich schreibe "praktisch" weil Es gibt eine Option, wenn dies nicht der Fall ist, aber dazu später mehr. Durch Hinzufügen von ConfigureAwait(false)
wie in (4) wird keine 100% ige Garantie für den Schutz vor Deadlocks gegeben. Das folgende Beispiel bestätigt dies:
[7] // / . async Task FooAsync() { // Delay . . await Task.Delay(5000); // RestPartOfMethodCode(); } // "" , , WinForms . private void button1_Click(object sender, EventArgs e) { FooAsync() .ConfigureAwait(false) .GetAwaiter() .GetResult(); button1.Text = "new text"; }
Der Artikel "Paralleles Rechnen - Alles dreht sich um den SynchronizationContext" enthält Informationen zu verschiedenen Synchronisationskontexten.
Um die Ursache des Deadlocks zu verstehen, müssen Sie den Code der Zustandsmaschine analysieren, in die der Aufruf der asynchronen Methode konvertiert wird, und anschließend den Code der MS-Klassen. Ein Async Await und der Artikel Generated StateMachine bieten ein Beispiel für eine solche Zustandsmaschine.
Ich werde nicht den vollständigen Quellcode angeben, der zum Beispiel (7) generiert wurde, sondern den Automaten. Ich werde nur die Zeilen anzeigen, die für die weitere Analyse wichtig sind:
// MoveNext. //... // taskAwaiter . taskAwaiter = Task.Delay(5000).GetAwaiter(); if(tasAwaiter.IsCompleted != true) { _awaiter = taskAwaiter; _nextState = ...; _builder.AwaitUnsafeOnCompleted<TaskAwaiter, ThisStateMachine>(ref taskAwaiter, ref this); return; }
Die if
Verzweigung wird ausgeführt, wenn der asynchrone Aufruf ( Delay
) noch nicht abgeschlossen ist und daher der aktuelle Thread freigegeben werden kann.
Bitte beachten Sie, dass in AwaitUnsafeOnCompleted
taskAwaiter von einem internen (relativ zu FooAsync
) asynchronen Aufruf ( Delay
) empfangen wird.
Wenn Sie in den Dschungel der MS-Quellen AwaitUnsafeOnCompleted
, die hinter dem Aufruf von AwaitUnsafeOnCompleted
verborgen sind, gelangen wir am Ende zur SynchronizationContextAwaitTaskContinuation- Klasse und ihrer Basisklasse AwaitTaskContinuation , in der sich die Antwort auf die Frage befindet.
Der Code dieser und verwandter Klassen ist ziemlich verwirrend. Um die Wahrnehmung zu erleichtern, erlaube ich mir daher, ein sehr vereinfachtes "Analogon" zu schreiben, aus dem Beispiel (7) wird, aber ohne Zustandsmaschine und in Bezug auf TPL:
[8] Task FooAsync() { // methodCompleted , , // , " ". // , methodCompleted.WaitOne() , // SetResult AsyncTaskMethodBuilder, // . var methodCompleted = new AutoResetEvent(false); SynchronizationContext current = SynchronizationContext.Current; return Task.Delay(5000).ContinueWith( t=> { if(current == null) { RestPartOfMethodCode(methodCompleted); } else { current.Post(state=>RestPartOfMethodCode(methodCompleted), null); methodCompleted.WaitOne(); } }, TaskScheduler.Current); } // // void RestPartOfMethodCode(AutoResetEvent methodCompleted) // { // FooAsync. // methodCompleted.Set(); // }
In Beispiel (8) ist es wichtig zu beachten, dass bei einem Synchronisationskontext der gesamte Code der asynchronen Methode, der nach Abschluss des internen asynchronen Aufrufs kommt , über diesen Kontext ausgeführt wird (call current.Post(...)
). Diese Tatsache ist die Ursache für Deadlocks. Wenn es sich beispielsweise um eine WinForms-Anwendung handelt, wird der darin enthaltene Synchronisationskontext dem UI-Stream zugeordnet. Wenn der UI-Thread blockiert ist, in Beispiel (7), geschieht dies durch einen Aufruf von .GetResult()
, dann kann der Rest des Codes der asynchronen Methode nicht ausgeführt werden, was bedeutet, dass die asynchrone Methode nicht abgeschlossen werden kann und den UI-Thread nicht freigeben kann Deadlock.
In Beispiel (7) wurde der Aufruf von FooAsync
über ConfigureAwait(false)
konfiguriert, dies hat jedoch nicht geholfen. Tatsache ist, dass Sie genau das AwaitUnsafeOnCompleted
konfigurieren müssen, das an AwaitUnsafeOnCompleted
wird. In unserem Beispiel ist dies das AwaitUnsafeOnCompleted
aus dem Delay
. Mit anderen Worten, in diesem Fall ist es nicht sinnvoll, ConfigureAwait(false)
im Clientcode aufzurufen. Sie können das Problem lösen, wenn der Entwickler der FooAsync
Methode es wie folgt ändert:
[9] async Task FooAsync() { await Task.Delay(5000).ConfigureAwait(false); // RestPartOfMethodCode(); } private void button1_Click(object sender, EventArgs e) { FooAsync().GetAwaiter().GetResult(); button1.Text = "new text"; }
Oben haben wir die Risiken untersucht, die mit dem Code der ersten Gruppe entstehen - dem Code mit Sperre (Beispiele 1-4). Nun zur zweiten Gruppe (Beispiele 5 und 6) - ein Code ohne Sperren. In diesem Fall stellt sich die Frage, wann der Aufruf von ConfigureAwait(false)
gerechtfertigt ist. Beim Parsen von Beispiel (7) haben wir bereits herausgefunden, dass wir das wartende Objekt konfigurieren müssen, auf dessen Grundlage die Fortsetzung der Ausführung erstellt wird. Das heißt, Die Konfiguration ist (wenn Sie diese Entscheidung treffen) nur für interne asynchrone Aufrufe erforderlich.
Wer ist schuld?
Wie immer lautet die richtige Antwort "alles". Beginnen wir mit den Programmierern von MS. Einerseits haben Microsoft-Entwickler entschieden, dass bei Vorhandensein eines Synchronisationskontexts die Arbeit über diesen Kontext ausgeführt werden sollte. Und das ist logisch, sonst warum wird es noch gebraucht. Und wie ich glaube, haben sie erwartet, dass die Entwickler des "Client" -Codes den Hauptthread nicht blockieren würden , insbesondere wenn der Synchronisationskontext daran gebunden ist. Auf der anderen Seite gaben sie ein sehr einfaches Werkzeug, um "sich in den Fuß zu schießen" - es ist zu einfach und bequem, das Ergebnis durch Blockieren von .Result/.GetResult
oder den Stream zu blockieren und auf das .Wait
des Anrufs durch .Wait
. Das heißt, MS-Entwickler haben es möglich gemacht, dass die "falsche" (oder gefährliche) Verwendung ihrer Bibliotheken keine Schwierigkeiten verursacht.
Aber es gibt auch die Schuld an den Entwicklern des "Client" -Codes. Es besteht darin, dass Entwickler häufig nicht versuchen, ihr Tool zu verstehen und Warnungen zu vernachlässigen. Und dies ist ein direkter Weg zu Fehlern.
Was zu tun ist?
Unten gebe ich meine Empfehlungen.
Für Client-Code-Entwickler
- Geben Sie Ihr Bestes, um ein Blockieren zu vermeiden. Mit anderen Worten, mischen Sie keinen synchronen und asynchronen Code ohne besondere Notwendigkeit.
- Wenn Sie eine Sperre durchführen müssen, bestimmen Sie, in welcher Umgebung der Code ausgeführt wird:
- Gibt es einen Synchronisationskontext? Wenn ja, welches? Welche Eigenschaften schafft er in seiner Arbeit?
- Wenn es keinen Synchronisationskontext gibt, dann: Was wird die Last sein? Wie hoch ist die Wahrscheinlichkeit, dass Ihr Block zu einem "Leck" von Threads aus dem Pool führt? Reicht die Anzahl der zu Beginn erstellten Threads standardmäßig aus, oder sollte ich mehr zuweisen?
- Wenn der Code asynchron ist, müssen Sie den asynchronen Aufruf über
ConfigureAwait
?
Treffen Sie eine Entscheidung basierend auf allen erhaltenen Informationen. Möglicherweise müssen Sie Ihren Implementierungsansatz überdenken. Vielleicht hilft Ihnen ConfigureAwait
, oder Sie brauchen es nicht.
Für Bibliotheksentwickler
- Wenn Sie glauben, dass Ihr Code von "synchron" aufgerufen werden kann, müssen Sie eine synchrone API implementieren. Es muss wirklich synchron sein, d.h. Sie müssen die synchrone API von Bibliotheken von Drittanbietern verwenden.
ConfigureAwait(true / false)
.
Hier ist aus meiner Sicht ein subtilerer Ansatz erforderlich als normalerweise empfohlen. In vielen Artikeln heißt es, dass im Bibliothekscode alle asynchronen Aufrufe über ConfigureAwait(false)
konfiguriert werden müssen. Dem kann ich nicht zustimmen. Aus Sicht der Autoren haben Kollegen von Microsoft möglicherweise die falsche Entscheidung getroffen, als sie das "Standard" -Verhalten in Bezug auf die Arbeit mit dem Synchronisationskontext gewählt haben. Sie (MS) ließen den Entwicklern des "Client" -Codes dennoch die Möglichkeit, dieses Verhalten zu ändern. Die Strategie ändert das Standardverhalten, wenn der Bibliothekscode vollständig von ConfigureAwait(false)
abgedeckt wird, und, was noch wichtiger ist, dieser Ansatz beraubt Entwickler des "Client" -Codes der Wahl.
Meine Option besteht darin, bei der Implementierung der asynchronen API jeder API-Methode zwei zusätzliche Eingabeparameter hinzuzufügen: CancellationToken token
und bool continueOnCapturedContext
. Und implementieren Sie den Code wie folgt:
public async Task<string> FooAsync( /* */, CancellationToken token, bool continueOnCapturedContext) { // ... await Task.Delay(30, token).ConfigureAwait(continueOnCapturedContext); // ... return result; }
Der erste Parameter, token
, dient, wie Sie wissen, der Möglichkeit einer koordinierten Löschung (Bibliotheksentwickler vernachlässigen diese Funktion manchmal). Mit dem zweiten Befehl continueOnCapturedContext
können Sie die Interaktion mit dem Synchronisationskontext interner asynchroner Aufrufe konfigurieren.
Wenn die asynchrone API-Methode selbst Teil einer anderen asynchronen Methode ist, kann der "Client" -Code gleichzeitig bestimmen, wie er mit dem Synchronisationskontext interagieren soll:
// : async Task ClientFoo() { // "" ClientFoo , // FooAsync . await FooAsync( /* */, ancellationToken.None, false); // . await FooAsync( /* */, ancellationToken.None, false).ConfigureAwait(false); //... } // , . private void button1_Click(object sender, EventArgs e) { FooAsync( /* */, _source.Token, false).GetAwaiter().GetResult(); button1.Text = "new text"; }
Abschließend
Die Hauptschlussfolgerung aus dem Vorstehenden sind die folgenden drei Gedanken:
- Schlösser sind meistens die Wurzel allen Übels. Es ist das Vorhandensein von Sperren, die im besten Fall zu einer Verschlechterung der Leistung und einer ineffizienten Ressourcennutzung führen können, im schlimmsten Fall zu einem Deadlock. Überlegen Sie, ob dies erforderlich ist, bevor Sie Schlösser verwenden. Vielleicht gibt es in Ihrem Fall eine andere Art der Synchronisation, die akzeptabel ist.
- Lernen Sie das Tool kennen, mit dem Sie arbeiten.
- Wenn Sie Bibliotheken entwerfen, versuchen Sie sicherzustellen, dass ihre korrekte Verwendung einfach, fast intuitiv und die falsche mit Komplexität behaftet ist.
Ich habe versucht, die mit async / await verbundenen Risiken und die Gründe für ihr Auftreten so einfach wie möglich zu erklären. Außerdem stellte ich meine Vision vor, diese Probleme zu lösen. Ich hoffe, dass es mir gelungen ist und das Material für den Leser nützlich sein wird. Um besser zu verstehen, wie alles tatsächlich funktioniert, müssen Sie sich natürlich auf die Quelle beziehen. Dies kann über die MS-Repositories auf GitHub oder noch bequemer über die MS- Website selbst erfolgen.
PS Ich wäre dankbar für konstruktive Kritik.
