Wie können Sie den Kontrollfluss in .NET implementieren und abwarten?

105

Soweit ich yieldweiß, 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 awaitwartet 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 awaitsdie 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.

awaitWoher 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 awaitausfü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 yielddie Laufzeit den Punkt, an dem die Dinge abgeholt werden sollen? Wie bleibt der Iteratorstatus erhalten?

John Wu
quelle
4
Sie können einen Blick auf den generierten Code im TryRoslyn Online-Compiler
werfen
1
Vielleicht möchten Sie die Eduasync-Artikelserie von Jon Skeet lesen .
Leonid Vasilev
Verwandte interessante Lektüre: stackoverflow.com/questions/37419572/…
Jason C

Antworten:

115

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.

Woher weiß die Laufzeit, wenn ein Warten erreicht ist, welcher Code als nächstes ausgeführt werden soll?

await wird generiert als:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

Das ist es im Grunde. Warten ist nur eine schicke Rückkehr.

Woher weiß es, wann es dort weitermachen kann, wo es aufgehört hat, und woher erinnert es sich, wo es aufgehört hat?

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.

Was passiert mit dem aktuellen Aufrufstapel, wird er irgendwie gespeichert?

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 .

Was ist, wenn die aufrufende Methode andere Methodenaufrufe ausführt, bevor sie wartet? Warum wird der Stapel nicht überschrieben?

Diese Methodenaufrufe kehren zurück, sodass sich ihre Aktivierungsdatensätze zum Zeitpunkt des Wartens nicht mehr auf dem Stapel befinden.

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?

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.

Wie verfolgt die Laufzeit den Punkt, an dem die Dinge abgeholt werden sollen, wenn der Ertrag erreicht ist? Wie bleibt der Iteratorstatus erhalten?

Gleicher Weg. Der Zustand der Einheimischen wird auf den Haufen verschoben, und eine Zahl, die die Anweisung darstellt, bei derMoveNext 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.

Eric Lippert
quelle
1
Aufgrund des Hintergrunds der Fragesteller (Assembler et al.) ist es möglicherweise erwähnenswert, dass beide Konstrukte ohne verwalteten Speicher nicht möglich sind. Ohne verwalteten Speicher, der versucht, die Lebensdauer eines Verschlusses zu koordinieren, würden Sie definitiv auf Ihren Bootstraps stolpern.
Jim
Der Link für alle Seiten wurde nicht gefunden (404)
Digital3D
Alle Ihre Artikel sind jetzt nicht verfügbar. Könnten Sie sie erneut veröffentlichen?
Michał Turczyn
1
@ MichałTurczyn: Sie sind immer noch im Internet; Microsoft bewegt sich weiter, wo sich das Blog-Archiv befindet. Ich werde sie schrittweise auf meine persönliche Website migrieren und versuchen, diese Links zu aktualisieren, wenn ich Zeit habe.
Eric Lippert
38

yield ist das einfachere von beiden, also lasst es uns untersuchen.

Sagen wir, wir haben:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

Dies wird ein bisschen so kompiliert, als hätten wir geschrieben:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

Also, nicht so effizient wie eine handschriftliche Umsetzung IEnumerable<int>und IEnumerator<int>( zum Beispiel würden wir wahrscheinlich verschwenden keinen separaten haben _state, _iund _currentin 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 komplizierten yieldMethoden umzugehen.

Und natürlich seitdem

foreach(var a in b)
{
  DoSomething(a);
}

Ist das gleiche wie:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

Dann wird das Generierte MoveNext()wiederholt aufgerufen.

Der asyncFall ist so ziemlich das gleiche Prinzip, aber mit etwas mehr Komplexität. So verwenden Sie ein Beispiel aus einem anderen Antwortcode wie folgt:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

Erzeugt Code wie:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

Es ist komplizierter, aber ein sehr ähnliches Grundprinzip. Die wichtigste zusätzliche Komplikation ist, dass jetzt GetAwaiter()verwendet wird. Wenn eine Zeit awaiter.IsCompletedaktiviert ist, wird sie zurückgegeben, trueweil die Aufgabe awaited 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 MoveNextund entweder mit der nächsten Arbeit (bis zur nächsten await) fortfährt oder fertig ist und zurückkehrt. In diesem Fall wird die von Taskihm implementierte Arbeit abgeschlossen.

Jon Hanna
quelle
Haben Sie sich die Zeit genommen, Ihre eigene Übersetzung zu erstellen? O_O uao.
CoffeDeveloper
4
@DarioOO Das erste, das ich ziemlich schnell machen kann, nachdem ich viele Übersetzungen von yieldzu 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.
Jon Hanna
13

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 asyncMethode vom Compiler in mehrere Teile zerlegt. Die awaitAusdrü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 awaitwird 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 meinem asyncIntro eine sehr ähnliche Terminologie ).

Woher weiß die Laufzeit, wenn ein Warten erreicht ist, welcher Code als nächstes ausgeführt werden soll?

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 awaiteine Benutzeroberfläche erfasst SynchronizationContextund die E / A im Thread-Pool abgeschlossen wurden).

Woher weiß es, wann es dort weitermachen kann, wo es aufgehört hat, und woher erinnert es sich, wo es aufgehört hat?

Es sind alles nur Rückrufe. Wenn ein Warten abgeschlossen ist, ruft es seine Rückrufe und alle asyncbereits 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.

Was passiert mit dem aktuellen Aufrufstapel, wird er irgendwie gespeichert? Was ist, wenn die aufrufende Methode andere Methodenaufrufe ausführt, bevor sie wartet? 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?

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 asyncMethode fortsetzen kann, die ihre Aufgabe beendet, die eine asyncMethode fortsetzen kann, die ihre Aufgabe beendet, usw.

Also, mit synchronem Code Acalling Bcalling C, Ihre Aufrufliste kann wie folgt aussehen:

A:B:C

Während der asynchrone Code Rückrufe (Zeiger) verwendet:

A <- B <- C <- (I/O operation)

Wie verfolgt die Laufzeit den Punkt, an dem die Dinge abgeholt werden sollen, wenn der Ertrag erreicht ist? Wie bleibt der Iteratorstatus erhalten?

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 .

Stephen Cleary
quelle
7

yieldund awaitsind, während beide sich mit Flusskontrolle befassen, zwei völlig verschiedene Dinge. Also werde ich sie separat angehen.

Das Ziel von yieldist es, es einfacher zu machen, faule Sequenzen zu erstellen. Wenn Sie eine Enumerator-Schleife mit einer yieldAnweisung 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, MoveNextdiese Schleife erneut durchlaufen wird. Wenn Sie also eine foreach-Schleife wie folgt ausführen:

foreach(var item in mything.items()) {
    dosomething(item);
}

Der generierte Code sieht ungefähr so ​​aus:

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

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.

asyncund awaitandererseits 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 asyncund awaitim Allgemeinen zu suchen .

Chris Tavares
quelle
1
"Es weist das System an, nicht den Standard-Synchronisierungskontext zu verwenden und stattdessen den Standardkontext zu verwenden, der den Thread-Pool verwendet." Können Sie klarstellen, was Sie damit meinen? "Standard nicht verwenden, Standard verwenden"
Kroltan
3
Entschuldigung, meine Terminologie ist durcheinander, ich werde den Beitrag reparieren. Verwenden Sie grundsätzlich nicht die Standardversion für die Umgebung, in der Sie sich befinden, sondern die Standardversion für .NET (dh den Thread-Pool).
Chris Tavares
sehr einfach, konnte verstehen, du hast meine Stimme bekommen :)
Ehsan Sajjad
4

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.

yieldist eine ältere und einfachere Aussage und ein syntaktischer Zucker für eine grundlegende Zustandsmaschine. Eine Methode, die a zurückgibt IEnumerable<T>oder IEnumerator<T>enthalten kann yield, 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 ein yieldInside vorhanden ist. Der Grund dafür ist, dass der von Ihnen geschriebene Code in die IEnumerator<T>.MoveNextMethode 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.

awaiterfordert etwas Unterstützung von der Typbibliothek und funktioniert etwas anders. Es nimmt ein Taskoder Task<T>Argument an und ergibt entweder seinen Wert, wenn die Aufgabe abgeschlossen ist, oder registriert eine Fortsetzung über Task.GetAwaiter().OnCompleted. Die vollständige Implementierung des async/ 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 yieldund awaitmü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.

IllidanS4 will Monica zurück
quelle
5
Randnotiz : awaitmuss keine Aufgabe übernehmen . Alles mit INotifyCompletion GetAwaiter()ist ausreichend. Ein bisschen ähnlich wie foreachnicht braucht IEnumerable, alles mit IEnumerator GetEnumerator()ist ausreichend.
IllidanS4 will Monica zurück