it-swarm.com.de

Wie lassen sich Kontrollflüsse in .NET umsetzen und abwarten?

Wie ich verstehe, gibt das Schlüsselwort yield, wenn es in einem Iteratorblock verwendet wird, den Kontrollfluss an den aufrufenden Code zurück, und wenn der Iterator erneut aufgerufen wird, setzt es dort an, wo es aufgehört hat.

Außerdem wartet await nicht nur auf den Angerufenen, sondern gibt die Kontrolle an den Anrufer zurück, um nur dort fortzufahren, wo sie aufgehört hat, als der Anrufer awaits die Methode ausgeführt 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 ich bin sehr vertraut mit Anweisungszeigern, Stacks usw. und verstehe, wie normale Steuerungsabläufe (Subroutine, Rekursion, Schleifen, Verzweigungen) funktionieren. Aber diese neuen Konstrukte - ich verstehe sie nicht.

Woher weiß die Laufzeit, welcher Code als nächstes ausgeführt werden soll, wenn ein await erreicht wird? Woher weiß es, wann es an der Stelle weitermachen kann, an der es aufgehört hat, und wie kann es sich an die Stelle erinnern, an der es aufgehört hat? Was passiert mit dem aktuellen Aufrufstapel, wird er irgendwie gespeichert? Was passiert, wenn die aufrufende Methode andere Methodenaufrufe vornimmt? awaits-- Warum wird der Stack nicht überschrieben? Und wie um alles in der Welt würde sich die Laufzeit im Falle einer Ausnahme und eines Stack-Unwinds durch all das arbeiten?

Wenn yield erreicht ist, wie verfolgt die Laufzeit den Punkt, an dem Dinge abgeholt werden sollten? Wie bleibt der Iteratorzustand erhalten?

102
John Wu

Ich werde Ihre spezifischen Fragen unten beantworten, aber Sie sollten wahrscheinlich einfach meine umfangreichen Artikel darüber lesen, wie wir Ertrag und Erwartung entworfen haben.

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 veraltet. Der generierte Code unterscheidet sich in vielerlei Hinsicht. Aber diese geben Ihnen sicherlich die Vorstellung, wie es funktioniert.

Wenn Sie nicht verstehen, wie Lambdas als Abschlussklassen generiert werden, verstehen Sie auch, dass zuerst . Sie werden keine asynchronen Köpfe oder Schwänze machen, wenn Sie keine Lambdas haben.

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

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 war's im Grunde. Warten ist nur eine schicke Rückkehr.

Woher weiß es, wann es an der Stelle weitermachen kann, an der es aufgehört hat, und wie kann es sich an die Stelle erinnern, an der es aufgehört hat?

Na, wie macht man das ohne abzuwarten? Wenn method foo method bar aufruft, erinnern wir uns irgendwie, wie wir wieder in die Mitte von foo zurückkehren können, wobei alle Einheimischen der Aktivierung von foo intakt sind, egal was bar tut.

Sie wissen, wie das in Assembler gemacht wird. Ein Aktivierungsdatensatz für foo wird auf den Stapel gelegt. 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 an die Stelle zurückgesetzt, an der sie sein müssen, und foo fährt an der Stelle fort, an der er aufgehört hat.

Die Fortsetzung eines Wartens ist genau die gleiche, mit der Ausnahme, dass der Datensatz aus dem offensichtlichen Grund auf den Heap gelegt wird, dass die Aktivierungssequenz keinen Stapel bildet . ).

Der Delegat, der als Fortsetzung der Aufgabe erwartet wird, enthält (1) eine Zahl, die die Eingabe für eine Nachschlagetabelle ist, die den Anweisungszeiger enthält, den Sie als nächstes ausführen müssen, und (2) alle Werte von Lokalen und Temporären.

Es gibt einige zusätzliche Ausrüstungsgegenstände; In .NET ist es beispielsweise unzulässig, 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. Aber das sind Buchhaltungsdetails. 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 zuerst auf den Stapel gelegt. es wird von Anfang an vom Haufen verteilt. (Nun, formale Parameter werden normalerweise auf dem Stack 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, also werden sie normal behandelt.

Beachten Sie, dass dies ein deutlicher Unterschied zwischen dem vereinfachten Weitergabestil für Fortsetzungen von Warten und echten Weitergabestrukturen für Aufrufe mit Strom ist, die Sie in Sprachen wie Schema sehen. In diesen Sprachen wird die gesamte Fortsetzung einschließlich der Rückführung in die Anrufer durch call-cc erfasst.

Was passiert, 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 Stack-Unwinds durch all das 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.

Erinnern Sie sich an all die Buchhaltung, die ich zuvor erwähnt habe? Ausnahmesemantik richtig zu machen, war ein großer Schmerz, lassen Sie mich Ihnen sagen.

Wie verfolgt die Laufzeit bei Erreichen des Ertrags den Punkt, an dem die Dinge abgeholt werden sollten? Wie bleibt der Iteratorzustand erhalten?

Gleicher Weg. Der Status der Einheimischen wird auf den Heap verschoben, und eine Zahl, die die Anweisung darstellt, bei der MoveNext beim nächsten 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.

109
Eric Lippert

yield ist die 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 mit einem Bit kompiliert, als ob wir geschrieben hätten:

// 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 Implementierung von IEnumerable<int> Und IEnumerator<int> (Z. B. würden wir wahrscheinlich keine separaten _state, _i Und _current In diesem Fall), aber nicht schlecht (der Trick, sich selbst wiederzuverwenden, wenn dies sicher ist, anstatt ein neues Objekt zu erstellen, ist gut) und erweiterbar, um mit sehr komplizierten yield - Anwendungen fertig zu werden Methoden.

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 die generierte MoveNext() wiederholt aufgerufen.

Der Fall async ist fast dasselbe Prinzip, jedoch mit ein bisschen mehr Komplexität. Um ein Beispiel aus eine andere Antwort wiederzuverwenden, schreiben Sie:

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 Hauptkomplikation ist, dass jetzt GetAwaiter() verwendet wird. Wenn zu irgendeinem Zeitpunkt awaiter.IsCompleted Aktiviert ist, wird true zurückgegeben, da die Aufgabe await ed bereits abgeschlossen ist (z. B. in Fällen, in denen sie synchron zurückgegeben werden könnte) andernfalls richtet es sich als Rückruf an den Kellner ein.

Was damit passiert, hängt vom Kellner ab, was den Rückruf auslöst (z. B. asynchrone E/A-Fertigstellung, Ausführung einer Aufgabe auf einem erledigten Thread) und welche Anforderungen für das Marshalling auf einen bestimmten Thread oder die Ausführung auf einem Threadpool-Thread bestehen , welcher Kontext aus dem ursprünglichen Aufruf benötigt wird oder nicht und so weiter. Was auch immer es ist, etwas in diesem Kellner ruft die MoveNext auf und es wird entweder mit der nächsten Arbeit (bis zur nächsten await) fortgefahren oder beendet und in diesem Fall die Task, das implementiert wird, wird abgeschlossen.

36
Jon Hanna

Hier gibt es bereits eine Menge großartiger Antworten. Ich werde nur ein paar Standpunkte teilen, die helfen können, ein mentales Modell zu bilden.

Zunächst wird eine async -Methode vom Compiler in mehrere Teile zerlegt. Die await Ausdrücke sind die Bruchstellen. (Dies ist für einfache Methoden leicht vorstellbar; komplexere Methoden mit Schleifen und Ausnahmebehandlung werden ebenfalls aufgelöst, wobei eine komplexere Zustandsmaschine hinzugefügt wird.).

Zweitens wird await in eine ziemlich einfache Folge übersetzt; Ich mag Lucians Beschreibung , was in Worten so ziemlich "wenn das Erwarten bereits abgeschlossen ist, erhalte das Ergebnis und führe diese Methode weiter aus; andernfalls speichere den Zustand dieser Methode und kehre zurück". (Ich verwende in meinem async intro eine sehr ähnliche Terminologie.).

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

Der Rest der Methode existiert als Rückruf für das Erwarten (im Fall von Aufgaben sind diese Rückrufe Fortsetzungen). Wenn das Warten beendet ist, ruft es seine Rückrufe auf.

Beachten Sie, dass die Aufrufliste nicht gespeichert und wiederhergestellt wird . Rückrufe werden direkt aufgerufen. Bei überlappenden E/A werden sie direkt aus dem Thread-Pool aufgerufen.

Diese Rückrufe können die Ausführung der Methode direkt fortsetzen oder sie können die Ausführung an einer anderen Stelle einplanen (z. B. wenn die await eine Benutzeroberfläche SynchronizationContext erfasst hat und die E/A im Thread-Pool abgeschlossen sind).

Woher weiß es, wann es an der Stelle weitermachen kann, an der es aufgehört hat, und wie kann es sich an die Stelle erinnern, an der es aufgehört hat?

Alles nur Rückrufe. Wenn ein Warten beendet ist, ruft es seine Rückrufe auf, und jede async Methode, die bereits awaited hatte, wird fortgesetzt. Der Callback springt in die Mitte dieser Methode und hat seine lokalen Variablen im Geltungsbereich.

Die Rückrufe sollen keinen bestimmten Thread ausführen, und sie müssen keinen Ihr Callstack wurde wiederhergestellt.

Was passiert mit dem aktuellen Aufrufstapel, wird er irgendwie gespeichert? Was passiert, 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 Stack-Unwinds durch all das arbeiten?

Der Callstack wird nicht an erster Stelle gespeichert. es ist nicht notwendig.

Mit synchronem Code können Sie einen Aufrufstapel erhalten, der alle Ihre Aufrufer enthält, und die Laufzeit weiß, wo Sie damit zurückkehren können.

Mit asynchronem Code können Sie eine Reihe von Callback-Zeigern erhalten, die auf einer E/A-Operation beruhen, die ihre Aufgabe beendet, und die eine async -Methode fortsetzen können, die ihre Aufgabe beendet, die eine async Methode, die ihre Aufgabe erledigt, etc.

Wenn der synchrone Code A aufruft B aufruft C, könnte Ihr Callstack folgendermaßen aussehen:

A:B:C

während der asynchrone Code Callbacks (Zeiger) verwendet:

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

Wie verfolgt die Laufzeit bei Erreichen des Ertrags den Punkt, an dem die Dinge abgeholt werden sollten? Wie bleibt der Iteratorzustand erhalten?

Derzeit eher ineffizient. :)

Es funktioniert wie jedes andere Lambda - Variable Lebensdauern werden verlängert und Referenzen werden in ein Statusobjekt platziert, das auf dem Stapel lebt. Die beste Quelle für alle Details auf tiefer Ebene ist Jon Skeets EduAsync-Serie .

12
Stephen Cleary

yield und await sind zwei völlig verschiedene Dinge, während sich beide mit der Flusssteuerung befassen. Also werde ich sie separat angehen.

Das Ziel von yield ist es, die Erstellung fauler Sequenzen zu vereinfachen. Wenn Sie eine Enumerator-Schleife mit einer yield -Anweisung schreiben, generiert der Compiler eine Menge neuen Codes, den Sie nicht sehen. Unter der Haube generiert es tatsächlich eine ganz neue Klasse. Die Klasse enthält Member, die den Status der Schleife verfolgen, und eine Implementierung von IEnumerable, sodass sie bei jedem Aufruf von MoveNext diese Schleife erneut durchläuft. 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 Code für Zustandsautomaten, die einen "Schritt" der Schleife ausführen und dann zurückkehren. 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 und await sind dagegen ein ganz anderer Fischkessel. Await ist abstrakt ein Synchronisationsprimitiv. Auf diese Weise können Sie dem System mitteilen, dass ich erst dann weitermachen kann, wenn dies geschehen ist. Aber, wie Sie bemerkt haben, ist nicht immer ein Thread beteiligt.

Was ist , ist ein sogenannter Synchronisationskontext. Es gibt immer einen, der rumhängt. Die Aufgabe des Synchronisationskontexts besteht darin, die Aufgaben, auf die gewartet wird, und deren Fortsetzung zu planen.

Wenn Sie await thisThing() sagen, passieren einige Dinge. Bei einer asynchronen Methode zerlegt der Compiler die Methode tatsächlich in kleinere Abschnitte, wobei jeder Abschnitt ein Abschnitt "vor dem Warten" und ein Abschnitt "nach dem Warten" (oder einer Fortsetzung) ist. Wenn das Warten ausgeführt wird, werden die Task, auf die gewartet wird und die folgende Fortsetzung, dh der Rest der Funktion, an den Synchronisationskontext übergeben. Der Kontext übernimmt die Planung der Aufgabe. Wenn der Kontext abgeschlossen ist, führt er die Fortsetzung aus und übergibt den gewünschten Rückgabewert.

Der Synchronisierungskontext kann frei tun, was immer er will, solange er etwas terminiert. Es könnte den Thread-Pool verwenden. Es könnte ein Thread pro Task erstellt werden. Es könnte sie synchron laufen lassen. Unterschiedliche Umgebungen (ASP.NET vs. WPF) bieten unterschiedliche Implementierungen des Synchronisierungskontexts, die unterschiedliche Aufgaben ausführen, je nachdem, was für ihre Umgebungen am besten geeignet ist.

(Bonus: Haben Sie sich jemals gefragt, was .ConfigurateAwait(false) tut? Es weist das System an, nicht den aktuellen Synchronisierungskontext zu verwenden (normalerweise basierend auf Ihrem Projekttyp, z. B. WPF oder ASP.NET) und stattdessen den Standardkontext zu verwenden benutzt den Thread Pool).

Also nochmal, es ist eine Menge Compiler-Trickserei. Wenn Sie sich den generierten Code ansehen, ist er kompliziert, aber Sie sollten sehen können, was er tut. Solche Transformationen sind schwierig, aber deterministisch und mathematisch. Deshalb ist es großartig, dass der Compiler sie für uns ausführt.

P.S. Es gibt eine Ausnahme für das Vorhandensein von Standardsynchronisierungskontexten: Konsolenanwendungen verfügen nicht über einen Standardsynchronisierungskontext. Weitere Informationen finden Sie in Stephen Toubs Blog . Es ist ein großartiger Ort, um nach Informationen zu async und await im Allgemeinen zu suchen.

7
Chris Tavares

Normalerweise würde ich empfehlen, die CIL anzusehen, aber in diesem Fall ist es ein Durcheinander.

Diese beiden Sprachkonstrukte funktionieren ähnlich, sind jedoch etwas unterschiedlich implementiert. Grundsätzlich ist es nur ein syntaktischer Zucker für eine Compiler-Magie, es gibt nichts Verrücktes/Unsicheres auf der Ebene der Versammlung. Schauen wir sie uns kurz an.

yield ist eine ältere und einfachere Aussage, und es ist ein syntaktischer Zucker für eine grundlegende Zustandsmaschine. Eine Methode, die IEnumerable<T> Oder IEnumerator<T> Zurückgibt, kann ein yield enthalten, das die Methode dann in eine State-Machine-Factory umwandelt. Eine Sache, die Sie beachten sollten, ist, dass zum Zeitpunkt des Aufrufs kein Code in der Methode ausgeführt wird, wenn sich ein yield darin befindet. Der Grund dafür ist, dass der von Ihnen geschriebene Code in die Methode IEnumerator<T>.MoveNext Verschoben wird, die den Status überprüft und den richtigen Teil des Codes ausführt. yield return x; Wird dann in etwas umgewandelt, das this.Current = x; return true; Ähnelt.

Wenn Sie sich etwas überlegen, können Sie die konstruierte Zustandsmaschine und ihre Felder (mindestens eine für den Zustand und für die Einheimischen) leicht inspizieren. Sie können es sogar zurücksetzen, wenn Sie die Felder ändern.

await benötigt etwas Unterstützung von der Typbibliothek und funktioniert etwas anders. Es nimmt ein Argument Task oder Task<T> An und führt dann entweder zu seinem 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. Es erstellt auch eine Zustandsmaschine und übergibt sie an OnCompleted. Wenn die Aufgabe abgeschlossen ist, verwendet sie das Ergebnis in der Fortsetzung. Die Implementierung des Kellners entscheidet, wie die Fortsetzung aufgerufen wird. Normalerweise wird der Synchronisationskontext des aufrufenden Threads verwendet.

Sowohl yield als auch await 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 den Begriffen der "unteren Ebene" wie Stapel, Threads usw. nachdenken. Dies sind Abstraktionen, und ihr inneres Funktionieren erfordert keine Unterstützung durch die CLR, sondern nur der Compiler, der die Magie ausführt. Dies unterscheidet sich stark von Luas Coroutinen, die die Unterstützung der Laufzeit haben, oder Cs longjmp, was nur schwarze Magie ist.

4
IllidanS4