Soweit ich yield
weiß, gibt das Schlüsselwort, wenn es aus einem Iteratorblock heraus verwendet wird, den Kontrollfluss an den aufrufenden Code zurück, und wenn der Iterator erneut aufgerufen wird, setzt er dort fort, wo er aufgehört hat.
Außerdem await
wartet es nicht nur auf den Angerufenen, sondern gibt die Kontrolle an den Anrufer zurück, nur um dort fortzufahren, wo es aufgehört hat, als der Anrufer awaits
die Methode angewendet hat .
Mit anderen Worten-- es gibt keinen Thread , und die "Parallelität" von Async und Warten ist eine Illusion, die durch einen geschickten Kontrollfluss verursacht wird, dessen Details durch die Syntax verborgen werden.
Jetzt bin ich ein ehemaliger Assembly-Programmierer und mit Befehlszeigern, Stapeln usw. sehr vertraut. Ich verstehe, wie normale Steuerungsflüsse (Unterprogramm, Rekursion, Schleifen, Verzweigungen) funktionieren. Aber diese neuen Konstrukte - ich verstehe sie nicht.
await
Woher weiß die Laufzeit, wenn ein erreicht ist, welcher Code als nächstes ausgeführt werden soll? Woher weiß es, wann es dort weitermachen kann, wo es aufgehört hat, und woher erinnert es sich, wo es aufgehört hat? Was passiert mit dem aktuellen Aufrufstapel, wird er irgendwie gespeichert? Was ist, wenn die aufrufende Methode andere Methodenaufrufe await
ausführt, bevor sie ausgeführt wird ? Warum wird der Stapel nicht überschrieben? Und wie um alles in der Welt würde sich die Laufzeit im Falle einer Ausnahme und eines Abwickelns eines Stapels durch all dies arbeiten?
Wann verfolgt yield
die Laufzeit den Punkt, an dem die Dinge abgeholt werden sollen? Wie bleibt der Iteratorstatus erhalten?
quelle
Antworten:
Ich werde Ihre spezifischen Fragen unten beantworten, aber Sie sollten wahrscheinlich einfach meine ausführlichen Artikel darüber lesen, wie wir Ertrag entworfen haben und auf ihn warten.
https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/
https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/
https://blogs.msdn.microsoft.com/ericlippert/tag/async/
Einige dieser Artikel sind jetzt veraltet. Der generierte Code unterscheidet sich in vielerlei Hinsicht. Aber diese geben Ihnen sicherlich eine Vorstellung davon, wie es funktioniert.
Wenn Sie nicht verstehen, wie Lambdas als Abschlussklassen generiert werden, verstehen Sie dies zuerst . Sie werden keine asynchronen Köpfe oder Schwänze machen, wenn Sie keine Lambdas haben.
await
wird generiert als:Das ist es im Grunde. Warten ist nur eine schicke Rückkehr.
Nun, wie machst du das ohne zu warten? Wenn method foo method bar aufruft, erinnern wir uns irgendwie daran, wie wir zur Mitte von foo zurückkehren können, wobei alle Einheimischen der Aktivierung von foo intakt sind, egal welche Bar dies tut.
Sie wissen, wie das im Assembler gemacht wird. Ein Aktivierungsdatensatz für foo wird auf den Stapel verschoben. es enthält die Werte der Einheimischen. Zum Zeitpunkt des Aufrufs wird die Rücksprungadresse in foo auf den Stapel geschoben. Wenn der Balken fertig ist, werden der Stapelzeiger und der Anweisungszeiger auf die Position zurückgesetzt, an der sie sich befinden müssen, und foo fährt dort fort, wo er aufgehört hat.
Die Fortsetzung eines Wartens ist genau das gleiche, außer dass der Datensatz auf den Heap gelegt wird, aus dem offensichtlichen Grund, dass die Aktivierungssequenz keinen Stapel bildet .
Der wartende Delegat, der als Fortsetzung der Aufgabe angibt, enthält (1) eine Zahl, die die Eingabe in eine Nachschlagetabelle ist, die den Befehlszeiger enthält, den Sie als Nächstes ausführen müssen, und (2) alle Werte von Einheimischen und Provisorien.
Es gibt dort einige zusätzliche Ausrüstung; In .NET ist es beispielsweise illegal, in die Mitte eines try-Blocks zu verzweigen, sodass Sie die Adresse des Codes nicht einfach in einen try-Block in die Tabelle einfügen können. Dies sind jedoch Details zur Buchhaltung. Konzeptionell wird der Aktivierungsdatensatz einfach auf den Heap verschoben.
Die relevanten Informationen im aktuellen Aktivierungsdatensatz werden niemals in erster Linie auf den Stapel gelegt. Es wird von Anfang an vom Heap zugewiesen. (Nun, formale Parameter werden normalerweise auf dem Stapel oder in Registern übergeben und dann zu Beginn der Methode in einen Heap-Speicherort kopiert.)
Die Aktivierungsaufzeichnungen der Anrufer werden nicht gespeichert. Das Warten wird wahrscheinlich zu ihnen zurückkehren, denken Sie daran, damit sie normal behandelt werden.
Beachten Sie, dass dies ein deutlicher Unterschied zwischen dem vereinfachten Wartestil für das Weiterleiten von Fortsetzungen und echten Call-with-Current-Continuation-Strukturen ist, die Sie in Sprachen wie Scheme sehen. In diesen Sprachen wird die gesamte Fortsetzung einschließlich der Fortsetzung zurück in die Anrufer von call-cc erfasst .
Diese Methodenaufrufe kehren zurück, sodass sich ihre Aktivierungsdatensätze zum Zeitpunkt des Wartens nicht mehr auf dem Stapel befinden.
Im Falle einer nicht erfassten Ausnahme wird die Ausnahme abgefangen, in der Aufgabe gespeichert und erneut ausgelöst, wenn das Ergebnis der Aufgabe abgerufen wird.
Erinnerst du dich an all die Buchhaltung, die ich zuvor erwähnt habe? Es war ein großer Schmerz, die Ausnahmesemantik richtig zu machen.
Gleicher Weg. Der Zustand der Einheimischen wird auf den Haufen verschoben, und eine Zahl, die die Anweisung darstellt, bei der
MoveNext
der nächste Aufruf fortgesetzt werden soll, wird zusammen mit den Einheimischen gespeichert.Und wieder gibt es eine Menge Ausrüstung in einem Iteratorblock, um sicherzustellen, dass Ausnahmen korrekt behandelt werden.
quelle
yield
ist das einfachere von beiden, also lasst es uns untersuchen.Sagen wir, wir haben:
Dies wird ein bisschen so kompiliert, als hätten wir geschrieben:
Also, nicht so effizient wie eine handschriftliche Umsetzung
IEnumerable<int>
undIEnumerator<int>
( zum Beispiel würden wir wahrscheinlich verschwenden keinen separaten haben_state
,_i
und_current
in diesem Fall) , aber nicht schlecht (der Trick der Wiederverwendung von selbst , wenn sie sicher so etwas zu tun , als einen neuen zu schaffen Objekt ist gut) und erweiterbar, um mit sehr kompliziertenyield
Methoden umzugehen.Und natürlich seitdem
Ist das gleiche wie:
Dann wird das Generierte
MoveNext()
wiederholt aufgerufen.Der
async
Fall ist so ziemlich das gleiche Prinzip, aber mit etwas mehr Komplexität. So verwenden Sie ein Beispiel aus einem anderen Antwortcode wie folgt:Erzeugt Code wie:
Es ist komplizierter, aber ein sehr ähnliches Grundprinzip. Die wichtigste zusätzliche Komplikation ist, dass jetzt
GetAwaiter()
verwendet wird. Wenn eine Zeitawaiter.IsCompleted
aktiviert ist, wird sie zurückgegeben,true
weil die Aufgabeawait
ed bereits abgeschlossen ist (z. B. Fälle, in denen sie synchron zurückgegeben werden könnte), und die Methode bewegt sich weiter durch Zustände. Andernfalls richtet sie sich als Rückruf an den Kellner ein.Was damit passiert, hängt vom Wartenden ab, was den Rückruf auslöst (z. B. asynchrone E / A-Fertigstellung, eine Aufgabe, die auf einem abgeschlossenen Thread ausgeführt wird) und welche Anforderungen für das Marshalling eines bestimmten Threads oder die Ausführung auf einem Threadpool-Thread bestehen , welcher Kontext aus dem ursprünglichen Aufruf möglicherweise benötigt wird oder nicht und so weiter. Was auch immer es ist, obwohl etwas in diesem Kellner in das anruft
MoveNext
und entweder mit der nächsten Arbeit (bis zur nächstenawait
) fortfährt oder fertig ist und zurückkehrt. In diesem Fall wird die vonTask
ihm implementierte Arbeit abgeschlossen.quelle
yield
zu handgerollt gemacht habe, wenn dies von Vorteil ist (im Allgemeinen als Optimierung, aber ich möchte sicherstellen, dass der Startpunkt nahe am vom Compiler generierten liegt so wird nichts durch schlechte Annahmen deoptimiert). Die zweite wurde zuerst in einer anderen Antwort verwendet, und es gab zu dieser Zeit einige Lücken in meinem eigenen Wissen, so dass ich davon profitierte, diese zu füllen und diese Antwort durch manuelles Dekompilieren des Codes bereitzustellen.Hier gibt es bereits eine Menge großartiger Antworten. Ich werde nur einige Standpunkte teilen, die helfen können, ein mentales Modell zu bilden.
Zunächst wird eine
async
Methode vom Compiler in mehrere Teile zerlegt. Dieawait
Ausdrücke sind die Bruchpunkte. (Dies ist für einfache Methoden leicht vorstellbar. Komplexere Methoden mit Schleifen und Ausnahmebehandlung werden durch die Hinzufügung einer komplexeren Zustandsmaschine ebenfalls aufgelöst.)Zweitens
await
wird in eine ziemlich einfache Sequenz übersetzt; Ich mag Lucians Beschreibung , die in Worten so ziemlich "wenn das Warten bereits abgeschlossen ist, erhalte das Ergebnis und führe diese Methode weiter aus; andernfalls speichere den Status dieser Methode und kehre zurück". (Ich verwende in meinemasync
Intro eine sehr ähnliche Terminologie ).Der Rest der Methode existiert als Rückruf für das Wartende (bei Aufgaben sind diese Rückrufe Fortsetzungen). Wenn das Warten abgeschlossen ist, ruft es seine Rückrufe auf.
Beachten Sie, dass der Aufrufstapel nicht ist gespeichert und wiederhergestellt wird. Rückrufe werden direkt aufgerufen. Bei überlappenden E / A werden sie direkt aus dem Thread-Pool aufgerufen.
Diese Rückrufe führen die Methode möglicherweise direkt weiter aus oder planen die Ausführung an anderer Stelle (z. B. wenn
await
eine Benutzeroberfläche erfasstSynchronizationContext
und die E / A im Thread-Pool abgeschlossen wurden).Es sind alles nur Rückrufe. Wenn ein Warten abgeschlossen ist, ruft es seine Rückrufe und alle
async
bereits vorhandenen Methoden aufawait
wird fortgesetzt. Der Rückruf springt in die Mitte dieser Methode und hat seine lokalen Variablen im Gültigkeitsbereich.Die Rückrufe werden nicht einen bestimmten Thread laufen, und sie nicht ihre Aufrufliste wiederhergestellt.
Der Callstack wird überhaupt nicht gespeichert. es ist nicht notwendig.
Mit synchronem Code können Sie einen Aufrufstapel erhalten, der alle Ihre Anrufer enthält, und die Laufzeit weiß, wohin Sie damit zurückkehren müssen.
Mit asynchronem Code können Sie eine Reihe von Rückrufzeigern erhalten, die auf einer E / A-Operation basieren, die ihre Aufgabe beendet, die eine
async
Methode fortsetzen kann, die ihre Aufgabe beendet, die eineasync
Methode fortsetzen kann, die ihre Aufgabe beendet, usw.Also, mit synchronem Code
A
callingB
callingC
, Ihre Aufrufliste kann wie folgt aussehen:Während der asynchrone Code Rückrufe (Zeiger) verwendet:
Derzeit eher ineffizient. :) :)
Es funktioniert wie jedes andere Lambda - variable Lebensdauern werden verlängert und Referenzen werden in ein Statusobjekt eingefügt, das auf dem Stapel lebt. Die beste Quelle für alle Details ist Jon Eduets EduAsync-Serie .
quelle
yield
undawait
sind, während beide sich mit Flusskontrolle befassen, zwei völlig verschiedene Dinge. Also werde ich sie separat angehen.Das Ziel von
yield
ist es, es einfacher zu machen, faule Sequenzen zu erstellen. Wenn Sie eine Enumerator-Schleife mit eineryield
Anweisung darin schreiben , generiert der Compiler eine Menge neuen Codes, den Sie nicht sehen. Unter der Haube entsteht tatsächlich eine ganz neue Klasse. Die Klasse enthält Mitglieder, die den Status der Schleife verfolgen, und eine Implementierung von IEnumerable, sodass jedes Mal, wenn Sie sie aufrufen,MoveNext
diese Schleife erneut durchlaufen wird. Wenn Sie also eine foreach-Schleife wie folgt ausführen:Der generierte Code sieht ungefähr so aus:
In der Implementierung von mything.items () befindet sich eine Reihe von State-Machine-Code, der einen "Schritt" der Schleife ausführt und dann zurückkehrt. Während Sie es also wie eine einfache Schleife in die Quelle schreiben, ist es unter der Haube keine einfache Schleife. Also Compiler-Trick. Wenn Sie sich selbst sehen möchten, ziehen Sie ILDASM oder ILSpy oder ähnliche Tools heraus und sehen Sie, wie die generierte IL aussieht. Es sollte lehrreich sein.
async
undawait
andererseits sind ein ganz anderer Fischkessel. Await ist abstrakt ein Synchronisationsprimitiv. Auf diese Weise können Sie dem System mitteilen, dass ich erst fortfahren kann, wenn dies erledigt ist. Wie Sie bereits bemerkt haben, handelt es sich jedoch nicht immer um einen Thread.Es handelt sich um einen sogenannten Synchronisationskontext. Es hängt immer einer herum. Die Aufgabe des Synchronisationskontexts besteht darin, Aufgaben zu planen, auf die gewartet wird, und ihre Fortsetzung.
Wenn Sie sagen
await thisThing()
, passieren ein paar Dinge. Bei einer asynchronen Methode zerlegt der Compiler die Methode tatsächlich in kleinere Blöcke, wobei jeder Block ein Abschnitt "vor einem Warten" und ein Abschnitt "nach einem Warten" (oder eine Fortsetzung) ist. Wenn das Warten ausgeführt wird, wird die erwartete Aufgabe und die folgende Fortsetzung - mit anderen Worten der Rest der Funktion - an den Synchronisationskontext übergeben. Der Kontext kümmert sich um die Planung der Aufgabe, und wenn sie abgeschlossen ist, führt der Kontext die Fortsetzung aus und übergibt den gewünschten Rückgabewert.Der Synchronisierungskontext kann frei tun, was er will, solange er Dinge plant. Es könnte den Thread-Pool verwenden. Es könnte einen Thread pro Aufgabe erstellen. Es könnte sie synchron ausführen. Unterschiedliche Umgebungen (ASP.NET vs. WPF) bieten unterschiedliche Implementierungen für den Synchronisierungskontext, die unterschiedliche Aufgaben ausführen, je nachdem, was für ihre Umgebungen am besten ist.
(Bonus: Haben
.ConfigurateAwait(false)
Sie sich jemals gefragt, was das bedeutet? Es weist das System an, den aktuellen Synchronisierungskontext nicht zu verwenden (normalerweise basierend auf Ihrem Projekttyp - z. B. WPF vs ASP.NET) und stattdessen den Standardkontext zu verwenden, der den Thread-Pool verwendet.)Es ist also wieder eine Menge Compiler-Tricks. Wenn Sie sich den generierten Code ansehen, ist er kompliziert, aber Sie sollten sehen können, was er tut. Diese Art von Transformationen ist schwierig, aber deterministisch und mathematisch, weshalb es großartig ist, dass der Compiler sie für uns erledigt.
PS Es gibt eine Ausnahme zum Vorhandensein von Standardsynchronisierungskontexten: Konsolen-Apps haben keinen Standardsynchronisierungskontext. Weitere Informationen finden Sie in Stephen Toubs Blog . Es ist ein großartiger Ort, um Informationen über
async
undawait
im Allgemeinen zu suchen .quelle
Normalerweise würde ich empfehlen, sich die CIL anzuschauen, aber in diesem Fall ist es ein Chaos.
Diese beiden Sprachkonstrukte funktionieren ähnlich, sind jedoch etwas unterschiedlich implementiert. Im Grunde ist es nur ein syntaktischer Zucker für eine Compiler-Magie, es gibt nichts Verrücktes / Unsicheres auf Assembly-Ebene. Schauen wir sie uns kurz an.
yield
ist eine ältere und einfachere Aussage und ein syntaktischer Zucker für eine grundlegende Zustandsmaschine. Eine Methode, die a zurückgibtIEnumerable<T>
oderIEnumerator<T>
enthalten kannyield
, die die Methode dann in eine Zustandsmaschinenfabrik umwandelt. Eine Sache, die Sie beachten sollten, ist, dass zum Zeitpunkt des Aufrufs kein Code in der Methode ausgeführt wird, wenn einyield
Inside vorhanden ist. Der Grund dafür ist, dass der von Ihnen geschriebene Code in dieIEnumerator<T>.MoveNext
Methode verschoben wird, die den Status überprüft und den richtigen Teil des Codes ausführt.yield return x;
wird dann in etwas Ähnliches umgewandeltthis.Current = x; return true;
Wenn Sie etwas nachdenken, können Sie die konstruierte Zustandsmaschine und ihre Felder leicht inspizieren (mindestens eine für den Staat und für die Einheimischen). Sie können es sogar zurücksetzen, wenn Sie die Felder ändern.
await
erfordert etwas Unterstützung von der Typbibliothek und funktioniert etwas anders. Es nimmt einTask
oderTask<T>
Argument an und ergibt entweder seinen Wert, wenn die Aufgabe abgeschlossen ist, oder registriert eine Fortsetzung überTask.GetAwaiter().OnCompleted
. Die vollständige Implementierung desasync
/await
-Systems würde zu lange dauern, um es zu erklären, aber es ist auch nicht so mystisch. Außerdem wird eine Zustandsmaschine erstellt und die Fortsetzung an OnCompleted weitergeleitet . Wenn die Aufgabe abgeschlossen ist, verwendet sie das Ergebnis in der Fortsetzung. Die Implementierung des Kellners entscheidet, wie die Fortsetzung aufgerufen wird. In der Regel wird der Synchronisationskontext des aufrufenden Threads verwendet.Beide
yield
undawait
müssen die Methode basierend auf ihrem Vorkommen aufteilen, um eine Zustandsmaschine zu bilden, wobei jeder Zweig der Maschine jeden Teil der Methode darstellt.Sie sollten nicht über diese Konzepte in Begriffen der "unteren Ebene" wie Stapel, Threads usw. nachdenken. Dies sind Abstraktionen, und ihr Innenleben erfordert keine Unterstützung durch die CLR, sondern nur der Compiler, der die Magie ausübt. Dies unterscheidet sich stark von Luas Coroutinen, die die Laufzeitunterstützung haben, oder Cs Longjmp , das nur schwarze Magie ist.
quelle
await
muss keine Aufgabe übernehmen . Alles mitINotifyCompletion GetAwaiter()
ist ausreichend. Ein bisschen ähnlich wieforeach
nicht brauchtIEnumerable
, alles mitIEnumerator GetEnumerator()
ist ausreichend.