Wer von uns mäht nicht? Ich stoße regelmäßig auf Fehler im asynchronen Code und mache sie selbst. Um dieses Rad von Samsara zu stoppen, teile ich mit Ihnen die typischsten Pfosten von denen, die manchmal ziemlich schwer zu fangen und zu reparieren sind.

Dieser Text ist inspiriert vom Blog von Stephen Clary , einem Mann, der alles über Wettbewerbsfähigkeit, Asynchronität, Multithreading und andere beängstigende Wörter weiß. Er ist der Autor des Buches Concurrency in C # Cookbook , das eine Vielzahl von Mustern für die Arbeit mit dem Wettbewerb gesammelt hat.
Klassischer asynchroner Deadlock
Um den asynchronen Deadlock zu verstehen, sollten Sie herausfinden, welcher Thread die mit dem Schlüsselwort await aufgerufene Methode ausführt.
Zunächst wird die Methode die Aufrufkette von asynchronen Methoden untersuchen, bis sie auf eine asynchrone Quelle stößt. Wie genau die Quelle der Asynchronität implementiert ist, ist ein Thema, das den Rahmen dieses Artikels sprengt. Der Einfachheit halber nehmen wir an, dass dies ein Vorgang ist, für den kein Workflow erforderlich ist, während auf das Ergebnis gewartet wird, z. B. eine Datenbankanforderung oder eine HTTP-Anforderung. Der synchrone Start einer solchen Operation bedeutet, dass während des Wartens auf das Ergebnis im System mindestens ein einschlafender Thread vorhanden ist, der Ressourcen verbraucht, aber keine nützliche Arbeit leistet.
Bei einem asynchronen Aufruf unterbrechen wir den Ausführungsfluss von Befehlen für "vor" und "nach" der asynchronen Operation, und in .NET gibt es keine Garantie dafür, dass der Code, der nach dem Warten liegt, im selben Thread ausgeführt wird wie der Code vor dem Warten. In den meisten Fällen ist dies nicht erforderlich, aber was ist zu tun, wenn ein solches Verhalten für das Funktionieren des Programms von entscheidender Bedeutung ist? SynchronizationContext
. Dies ist ein Mechanismus, mit dem Sie den Threads, in denen der Code ausgeführt wird, bestimmte Einschränkungen auferlegen können. Als nächstes werden wir uns mit zwei Synchronisationskontexten befassen ( WindowsFormsSynchronizationContext
und AspNetSynchronizationContext
), aber Alex Davis schreibt in seinem Buch, dass es in .NET ungefähr ein Dutzend davon gibt. Über SynchronizationContext
hier , hier gut geschrieben, und hier hat der Autor seinen eigenen implementiert, für den er großen Respekt hat.
Sobald der Code an der Quelle der Asynchronität ankommt, speichert er den Synchronisationskontext, der sich in der thread-statischen Eigenschaft von SynchronizationContext.Current
befand. Anschließend wird die asynchrone Operation gestartet und der aktuelle Thread freigegeben. Mit anderen Worten, während wir auf den Abschluss der asynchronen Operation warten, blockieren wir keinen einzelnen Thread, und dies ist der Hauptgewinn der asynchronen Operation im Vergleich zur synchronen. Nach Abschluss der asynchronen Operation müssen wir den Anweisungen folgen, die sich nach der asynchronen Quelle befinden. Um zu entscheiden, in welchem Thread der Code nach der asynchronen Operation ausgeführt werden soll, müssen wir den zuvor gespeicherten Synchronisationskontext konsultieren. Wie er sagt, werden wir das tun. Er wird Ihnen sagen, dass Sie im selben Thread wie der Code ausführen sollen, bevor Sie warten - wir werden im selben Thread ausführen, nicht sagen - wir werden den ersten Thread aus dem Pool nehmen.
Was aber, wenn es in diesem speziellen Fall für uns wichtig ist, dass der Code nach dem Warten in einem freien Thread aus dem Thread-Pool ausgeführt wird? Sie müssen das ConfigureAwait(false)
Mantra ConfigureAwait(false)
. Der an den Parameter continueOnCapturedContext
falsche Wert teilt dem System mit, dass jeder Thread aus dem Pool verwendet werden kann. Und was passiert, wenn zum Zeitpunkt der Methodenausführung mit Warten überhaupt kein Synchronisationskontext vorhanden war ( SynchronizationContext.Current == null
), wie zum Beispiel in einer Konsolenanwendung. In diesem Fall gibt es keine Einschränkungen für den Thread, in dem der Code nach dem Warten ausgeführt werden soll, und das System nimmt den ersten Thread aus dem Pool, wie im Fall von ConfigureAwait(false)
.
Was ist ein asynchroner Deadlock?
Deadlock in WPF und WinForms
Der Unterschied zwischen WPF- und WinForms-Anwendungen liegt im Kontext der Synchronisierung. Der Synchronisationskontext von WPF und WinForms hat einen speziellen Thread - den Benutzeroberflächenthread. Pro SynchronizationContext
gibt es einen UI-Thread, und nur dieser Thread kann mit Elementen der Benutzeroberfläche interagieren. Standardmäßig setzt der Code, der im UI-Thread zu arbeiten begonnen hat, den Betrieb nach einem asynchronen Vorgang fort.
Schauen wir uns nun ein Beispiel an:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; }
Was passiert, wenn Sie
StartWork().Wait()
aufrufen
StartWork().Wait()
:
- Der aufrufende Thread (und dies ist der Benutzeroberflächenthread) wechselt in die
StartWork
Methode und in die Anweisung await Task.Delay(100)
. - Der UI-Thread startet die asynchrone
Task.Delay(100)
Button_Click
und gibt die Steuerung an die Button_Click
Methode zurück. Dort Wait()
die Wait()
-Methode der Task
Klasse darauf. Wenn die Wait()
-Methode aufgerufen wird, wird der UI-Thread bis zum Ende des asynchronen Vorgangs blockiert, und wir gehen davon aus, dass der UI-Thread nach Abschluss der Ausführung sofort die Ausführung aufnimmt und den Code weiterführt. Es ist jedoch alles falsch. - Sobald
Task.Delay(100)
abgeschlossen ist, muss der UI-Thread zuerst die StartWork()
-Methode weiter ausführen und benötigt dazu genau den Thread, in dem die Ausführung gestartet wurde. Der UI-Thread wartet nun jedoch auf das Ergebnis der Operation. StartWork()
: StartWork()
kann die Ausführung nicht fortsetzen und das Ergebnis zurückgeben, und Button_Click
wartet auf dasselbe Ergebnis. Aufgrund der Tatsache, dass die Ausführung im Benutzeroberflächenthread gestartet wurde, bleibt die Anwendung einfach hängen, ohne dass die Möglichkeit besteht, weiter zu arbeiten.
Diese Situation kann ganz einfach behandelt werden, indem der Aufruf von
Task.Delay(100)
in
Task.Delay(100).ConfigureAwait(false)
:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; }
Dieser Code funktioniert ohne Deadlocks, da jetzt ein Thread aus dem Pool verwendet werden kann, um die StartWork()
-Methode zu vervollständigen, anstatt ein blockierter UI-Thread. Stephen Clary empfiehlt die Verwendung von ConfigureAwait(false)
in allen „Bibliotheksmethoden“ in seinem Blog, betont jedoch ausdrücklich, dass die Verwendung von ConfigureAwait(false)
zur Behandlung von Deadlocks keine gute Vorgehensweise ist. Stattdessen rät er, KEINE Blockierungsmethoden wie Wait()
, Result
, GetAwaiter().GetResult()
und alle Methoden so zu GetAwaiter().GetResult()
, dass sie nach Möglichkeit async / await verwenden (das sogenannte Async-All-Way-Prinzip).
Deadlock in ASP.NET
ASP.NET hat auch einen Synchronisationskontext, jedoch geringfügig andere Einschränkungen. Sie können jeweils nur einen Thread pro Anforderung verwenden und müssen außerdem den Code nach dem Warten im selben Thread wie den Code vor dem Warten ausführen.
Ein Beispiel:
public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } }
Dieser Code verursacht auch einen Deadlock, da zum Zeitpunkt des Aufrufs von StartWork().Wait()
einzige zulässige Thread blockiert wird und auf den StartWork()
der StartWork()
wartet und niemals endet, da der Thread, in dem die Ausführung fortgesetzt werden soll, beschäftigt ist Warten.
Dies wird alles durch dasselbe ConfigureAwait(false)
behoben.
Deadlock in ASP.NET Core (eigentlich nicht)
Versuchen wir nun, den Code aus dem Beispiel für ASP.NET im Projekt für ASP.NET Core auszuführen. Wenn wir dies tun, werden wir sehen, dass es keinen Deadlock geben wird. Dies liegt daran, dass ASP.NET Core keinen Synchronisationskontext hat . Großartig! Und jetzt können Sie den Code mit blockierenden Anrufen abdecken und haben keine Angst vor Deadlocks? Genau genommen ja, aber denken Sie daran, dass der Thread während des Wartens einschlafen muss, dh der Thread verbraucht Ressourcen, leistet aber keine nützliche Arbeit.
Denken Sie daran, dass durch die Verwendung des Blockierens von Anrufen alle Vorteile der asynchronen Programmierung beseitigt werden und diese synchronisiert werden . Ja, manchmal ohne Wait()
funktioniert das Schreiben eines Programms nicht, aber der Grund muss schwerwiegend sein.
Fehlerhafte Verwendung von Task.Run ()
Die Task.Run()
-Methode wurde erstellt, um Vorgänge in einem neuen Thread zu starten. Wie es sich für eine in einem TAP-Muster geschriebene Methode gehört, gibt sie Task
oder Task<T>
und Personen, die zum ersten Mal mit Async / Task.Run()
konfrontiert sind, haben den großen Wunsch, synchronen Code in Task.Run()
zu verpacken und das Ergebnis dieser Methode zu verwenden. Der Code schien asynchron zu werden, aber tatsächlich hat sich nichts geändert. Mal sehen, was mit dieser Verwendung von Task.Run()
passiert.
Ein Beispiel:
private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); }
Das Ergebnis dieses Codes ist:
Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3
Hier ist Thread.Sleep(1000)
eine Art synchroner Vorgang, für dessen Abschluss ein Thread erforderlich ist. Angenommen, wir möchten unsere Lösung asynchron machen und damit diese Operation eingeschläfert werden kann, haben wir sie in Task.Run()
.
Sobald der Code die Task.Run()
-Methode erreicht, wird ein weiterer Thread aus dem Thread-Pool entnommen und der Code, den wir an Task.Run()
darin ausgeführt. Der alte Thread kehrt, wie es sich für einen anständigen Thread gehört, in den Pool zurück und wartet darauf, dass er erneut aufgerufen wird, um die Arbeit zu erledigen. Der neue Thread führt den übertragenen Code aus, erreicht die synchrone Operation, führt ihn synchron aus (wartet, bis die Operation abgeschlossen ist) und geht weiter entlang des Codes. Mit anderen Worten, die Operation blieb synchron: Wir verwenden den Stream wie zuvor während der Ausführung der synchronen Operation. Der einzige Unterschied besteht darin, dass wir beim Aufrufen von Task.Run()
und beim Zurückkehren zu ExecuteOperation()
Zeit damit verbracht haben, den Kontext zu ExecuteOperation()
. Alles ist etwas schlimmer geworden.
Es versteht sich, dass trotz der Tatsache, dass in den Zeilen Inside after sleep: 3
und After: 3
dieselbe ID des Streams angezeigt wird, der Ausführungskontext an diesen Stellen völlig unterschiedlich ist. ASP.NET ist einfach schlauer als wir und versucht, Ressourcen zu sparen, wenn der Kontext von Code in Task.Run()
auf externen Code Task.Run()
. Hier beschloss er, zumindest den Hinrichtungsfluss nicht zu ändern.
In solchen Fällen ist es nicht sinnvoll, Task.Run()
. Stattdessen empfiehlt Clary, alle Operationen asynchron zu machen, Thread.Sleep(1000)
in unserem Fall Thread.Sleep(1000)
durch Thread.Sleep(1000)
zu Task.Delay(1000)
, aber dies ist natürlich nicht immer möglich. Was tun, wenn wir Bibliotheken von Drittanbietern verwenden, die wir nicht umschreiben und bis zum Ende asynchron machen können oder wollen, aber aus dem einen oder anderen Grund die asynchrone Methode benötigen? Es ist besser, Task.FromResult()
zu verwenden, um das Ergebnis der Herstellermethoden in Task zu verpacken. Dies macht den Code natürlich nicht asynchron, aber wir sparen zumindest beim Kontextwechsel.
Warum dann Task.Run () verwenden? Die Antwort ist einfach: Für CPU-gebundene Vorgänge, wenn Sie die Reaktionsfähigkeit der Benutzeroberfläche beibehalten oder die Berechnungen parallelisieren müssen. Hier muss gesagt werden, dass CPU-gebundene Operationen synchroner Natur sind. Task.Run()
synchrone Operationen in einem asynchronen Stil zu starten, wurde Task.Run()
erfunden.
Missbrauch der asynchronen Leere
Die Möglichkeit, asynchrone Methoden zu schreiben, die
void
wurde hinzugefügt, um asynchrone Ereignishandler zu schreiben. Mal sehen, warum sie Verwirrung stiften können, wenn sie für andere Zwecke verwendet werden:
- Sie können nicht auf das Ergebnis warten.
- Ausnahmebehandlung durch Try-Catch wird nicht unterstützt.
- Es ist unmöglich, Aufrufe über
Task.WhenAll()
, Task.WhenAny()
und andere ähnliche Methoden zu kombinieren.
Von all diesen Gründen ist der interessanteste Punkt die Behandlung von Ausnahmen. Tatsache ist, dass bei asynchronen Methoden, die Task
oder Task<T>
, Ausnahmen abgefangen und in ein Task
Objekt eingeschlossen werden, das dann an die aufrufende Methode übergeben wird. In ihrem Artikel für MSDN schreibt Clary, dass es in async-void-Methoden keinen Rückgabewert gibt, dass nichts in Ausnahmen eingeschlossen werden kann und diese direkt im Kontext der Synchronisation ausgelöst werden. Das Ergebnis ist eine nicht behandelte Ausnahme, aufgrund derer der Prozess abstürzt und möglicherweise Zeit hat, einen Fehler in die Konsole zu schreiben. Sie können solche Ausnahmen AppDomain.UnhandledException
und reservieren, indem Sie das Ereignis AppDomain.UnhandledException
abonnieren. Sie können den Prozessabsturz jedoch auch im Handler dieses Ereignisses nicht mehr stoppen. Dieses Verhalten ist nur für den Ereignishandler typisch, nicht jedoch für die übliche Methode, von der wir die Möglichkeit einer Standardausnahmebehandlung durch Try-Catch erwarten.
Wenn Sie beispielsweise in einer ASP.NET Core-Anwendung so schreiben, wird der Prozess garantiert abgebrochen:
public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); }
Es lohnt sich jedoch, den Rückgabetyp der ThrowAsynchronously
Methode in Task
zu ändern (ohne das Schlüsselwort await hinzuzufügen), und die Ausnahme wird vom Standard-ASP.NET Core-Fehlerbehandler abgefangen, und der Prozess wird trotz der Ausführung weiterhin ausgeführt.
Seien Sie vorsichtig mit asynchronen Methoden - sie können Sie in den Prozess einbeziehen.
Warten Sie in einer einzeiligen Methode
Das letzte Antimuster ist nicht so beängstigend wie die vorherigen. Das Fazit ist, dass es keinen Sinn macht, async / await in Methoden zu verwenden, die beispielsweise einfach das Ergebnis einer anderen asynchronen Methode weiterleiten, mit der möglichen Ausnahme der Verwendung von await bei der Verwendung .
Anstelle dieses Codes:
public async Task MyMethodAsync() { await Task.Delay(1000); }
es wäre durchaus möglich (und vorzugsweise) zu schreiben:
public Task MyMethodAsync() { return Task.Delay(1000); }
Warum funktioniert es? Weil das Schlüsselwort await auf aufgabenähnliche Objekte angewendet werden kann und nicht auf Methoden, die mit dem Schlüsselwort async gekennzeichnet sind. Das Schlüsselwort async teilt dem Compiler wiederum nur mit, dass diese Methode auf einer Zustandsmaschine bereitgestellt werden muss, und alle zurückgegebenen Werte sollten in eine Task
(oder in ein anderes Task-ähnliches Objekt) eingeschlossen werden.
Mit anderen Worten, das Ergebnis der ersten Version der Methode ist Task
, die abgeschlossen wird, sobald das Warten auf Task.Delay(1000)
endet, und das Ergebnis der zweiten Version der Methode ist Task
, das von Task.Delay(1000)
wird und Completed
, sobald 1000 Millisekunden vergehen .
Wie Sie sehen können, sind beide Versionen gleichwertig, aber gleichzeitig erfordert die erste viel mehr Ressourcen, um ein asynchrones „Bodykit“ zu erstellen.
Alex Davis schreibt, dass die Kosten für das direkte Aufrufen der asynchronen Methode das Zehnfache der Kosten für das Aufrufen der synchronen Methode betragen können. Es gibt also etwas zu versuchen.
UPD:Wie die Kommentare zu Recht hervorheben, führt das Heraussägen von Async / Wait aus einzeiligen Methoden zu negativen Nebenwirkungen. Wenn Sie beispielsweise eine Ausnahme auslösen, ist die Methode, mit der Task ausgelöst wird, im Stapel nicht sichtbar. Daher wird das
Entfernen von Standardeinstellungen standardmäßig nicht empfohlen .
Clarys Post mit Analyse.