Wird eine Stapelstruktur für asynchrone Prozesse verwendet?

10

Diese Frage hat eine ausgezeichnete Antwort von Eric Lippert, der beschreibt, wofür der Stapel verwendet wird. Seit Jahren weiß ich - im Allgemeinen -, was der Stapel ist und wie er verwendet wird, aber Teile seiner Antworten lassen mich fragen, ob diese Stapelstruktur heute weniger verwendet wird, wo asynchrone Programmierung die Norm ist.

Aus seiner Antwort:

Der Stapel ist Teil der Verdinglichung der Fortsetzung in einer Sprache ohne Coroutinen.

Insbesondere der Teil ohne Coroutinen hat mich gefragt.

Er erklärt hier etwas mehr:

Coroutinen sind Funktionen, die sich daran erinnern können, wo sie sich befanden, für eine Weile die Kontrolle an eine andere Coroutine abgeben und dann dort fortfahren, wo sie später aufgehört haben, jedoch nicht unbedingt unmittelbar nach den so genannten Coroutinenerträgen. Denken Sie an "Yield Return" oder "Warten" in C #, das sich daran erinnern muss, wo sie sich befanden, als das nächste Element angefordert oder der asynchrone Vorgang abgeschlossen wurde. Sprachen mit Coroutinen oder ähnlichen Sprachfunktionen erfordern erweiterte Datenstrukturen als ein Stapel, um die Fortsetzung zu implementieren.

Dies ist in Bezug auf den Stapel hervorragend, lässt mich jedoch eine unbeantwortete Frage darüber, welche Struktur verwendet wird, wenn ein Stapel zu einfach ist, um diese Sprachfunktionen zu handhaben, die erweiterte Datenstrukturen erfordern.

Verschwindet der Stapel mit fortschreitender Technologie? Was ersetzt es? Ist es eine hybride Art von Dingen? (z. B. verwendet mein .NET-Programm einen Stapel, bis es einen asynchronen Aufruf ausführt, und wechselt dann bis zum Abschluss zu einer anderen Struktur. Zu diesem Zeitpunkt wird der Stapel wieder in einen Zustand abgewickelt, in dem er sicher sein kann, dass die nächsten Elemente usw. vorhanden sind. )

Dass diese Szenarien für einen Stapel zu weit fortgeschritten sind, ist durchaus sinnvoll, aber was ersetzt den Stapel? Als ich vor Jahren davon erfahren hatte, war der Stack da, weil er blitzschnell und leicht war, ein Stück Speicher, der bei der Anwendung außerhalb des Heaps zugewiesen wurde, weil er eine hocheffiziente Verwaltung für die jeweilige Aufgabe unterstützte (Wortspiel beabsichtigt?). Was hat sich geändert?

jleach
quelle

Antworten:

14

Ist es eine hybride Art von Dingen? (z. B. verwendet mein .NET-Programm einen Stapel, bis es einen asynchronen Aufruf ausführt, und wechselt dann bis zum Abschluss zu einer anderen Struktur. Zu diesem Zeitpunkt wird der Stapel wieder in einen Zustand abgewickelt, in dem er sicher sein kann, dass die nächsten Elemente usw. vorhanden sind. )

Grundsätzlich ja.

Angenommen, wir haben

async void MyButton_OnClick() { await Foo(); Bar(); }
async Task Foo() { await Task.Delay(123); Blah(); }

Hier ist eine extrem vereinfachte Erklärung, wie die Fortsetzungen verdichtet werden. Der eigentliche Code ist erheblich komplexer, aber dies vermittelt die Idee.

Sie klicken auf die Schaltfläche. Eine Nachricht wird in die Warteschlange gestellt. Die Nachrichtenschleife verarbeitet die Nachricht und ruft den Klick-Handler auf, wobei die Rücksprungadresse der Nachrichtenwarteschlange auf dem Stapel abgelegt wird. Das heißt, was passiert, nachdem der Handler fertig ist, ist, dass die Nachrichtenschleife weiterlaufen muss. Die Fortsetzung des Handlers ist also die Schleife.

Der Klick-Handler ruft Foo () auf und legt die Rücksprungadresse von sich selbst auf den Stapel. Das heißt, die Fortsetzung von Foo ist der Rest des Klick-Handlers.

Foo ruft Task.Delay auf und legt die Rücksprungadresse von sich auf den Stapel.

Task.Delay führt alle erforderlichen Aktionen aus, um eine Task sofort zurückzugeben. Der Stapel ist geknallt und wir sind wieder in Foo.

Foo überprüft die zurückgegebene Aufgabe, um festzustellen, ob sie abgeschlossen ist. Es ist nicht. Die Fortsetzung des Wartens besteht darin, Blah () aufzurufen. Foo erstellt also einen Delegaten, der Blah () aufruft, und signiert diesen Delegaten als Fortsetzung der Aufgabe. (Ich habe gerade eine leichte falsche Aussage gemacht. Hast du sie verstanden? Wenn nicht, werden wir sie gleich enthüllen.)

Foo erstellt dann ein eigenes Task-Objekt, markiert es als unvollständig und gibt es im Stapel an den Click-Handler zurück.

Der Klick-Handler untersucht die Aufgabe von Foo und stellt fest, dass sie unvollständig ist. Die Fortsetzung des Wartens im Handler besteht darin, Bar () aufzurufen. Der Click-Handler erstellt also einen Delegaten, der Bar () aufruft, und legt ihn als Fortsetzung der von Foo () zurückgegebenen Aufgabe fest. Anschließend wird der Stapel in die Nachrichtenschleife zurückgeführt.

Die Nachrichtenschleife verarbeitet weiterhin Nachrichten. Schließlich macht die von der Verzögerungsaufgabe erzeugte Timer-Magie ihre Sache und sendet eine Nachricht an die Warteschlange, die besagt, dass die Fortsetzung der Verzögerungsaufgabe jetzt ausgeführt werden kann. Die Nachrichtenschleife ruft also die Taskfortsetzung auf und legt sich wie gewohnt auf den Stapel. Dieser Delegierte ruft Blah () an. Blah () macht das, was es macht und gibt den Stapel zurück.

Was passiert nun? Hier ist das Knifflige. Die Fortsetzung der Verzögerungsaufgabe ruft nicht nur Blah () auf. Es muss auch einen Aufruf von Bar () auslösen , aber diese Aufgabe weiß nichts über Bar!

Foo hat tatsächlich einen Delegaten erstellt, der (1) Blah () aufruft und (2) die Fortsetzung der Aufgabe aufruft, die Foo erstellt und an den Ereignishandler zurückgegeben hat. So rufen wir einen Delegaten auf, der Bar () aufruft.

Und jetzt haben wir alles getan, was wir tun mussten, in der richtigen Reihenfolge. Wir haben jedoch nie lange aufgehört, Nachrichten in der Nachrichtenschleife zu verarbeiten, sodass die Anwendung weiterhin reagierte.

Dass diese Szenarien für einen Stapel zu weit fortgeschritten sind, ist durchaus sinnvoll, aber was ersetzt den Stapel?

Ein Diagramm von Aufgabenobjekten, die über die Abschlussklassen von Delegaten Verweise aufeinander enthalten. Diese Schließungsklassen sind Zustandsautomaten, die die Position des zuletzt ausgeführten Wartens und die Werte der Einheimischen verfolgen. In dem angegebenen Beispiel außerdem eine Warteschlange mit globalen Status von Aktionen, die vom Betriebssystem implementiert wurden, und die Nachrichtenschleife, die diese Aktionen ausführt.

Übung: Wie funktioniert das alles in einer Welt ohne Nachrichtenschleifen? Zum Beispiel Konsolenanwendungen. Warten in einer Konsolen-App ist ganz anders; Können Sie aus dem, was Sie bisher wissen, ableiten, wie es funktioniert?

Als ich vor Jahren davon erfahren hatte, war der Stack da, weil er blitzschnell und leicht war, ein Stück Speicher, der bei der Anwendung außerhalb des Heaps zugewiesen wurde, weil er eine hocheffiziente Verwaltung für die jeweilige Aufgabe unterstützte (Wortspiel beabsichtigt?). Was hat sich geändert?

Stapel sind eine nützliche Datenstruktur, wenn die Lebensdauer von Methodenaktivierungen einen Stapel bildet, aber in meinem Beispiel bilden die Aktivierungen des Klick-Handlers Foo, Bar und Blah keinen Stapel. Daher kann die Datenstruktur, die diesen Workflow darstellt, kein Stapel sein. Vielmehr handelt es sich um ein Diagramm mit Heap-zugewiesenen Aufgaben und Delegaten, das einen Workflow darstellt. Die Wartezeiten sind die Punkte im Workflow, an denen im Workflow keine weiteren Fortschritte erzielt werden können, bis die zuvor begonnenen Arbeiten abgeschlossen sind. Während wir warten, können wir andere Arbeiten ausführen , die nicht von den abgeschlossenen Aufgaben abhängen.

Der Stapel ist nur ein Array von Frames, wobei Frames (1) Zeiger auf die Mitte von Funktionen (wo der Aufruf stattgefunden hat) und (2) Werte lokaler Variablen und Temps enthalten. Die Fortsetzung von Aufgaben ist dasselbe: Der Delegat ist ein Zeiger auf die Funktion und hat einen Status, der auf einen bestimmten Punkt in der Mitte der Funktion verweist (wo das Warten stattgefunden hat), und der Abschluss enthält Felder für jede lokale Variable oder temporäre Variable . Die Frames bilden einfach kein schönes, ordentliches Array mehr, aber alle Informationen sind gleich.

Eric Lippert
quelle
1
Sehr hilfreich, danke. Wenn ich beide Antworten als akzeptiert markieren könnte, würde ich, aber weil ich nicht kann, werde ich sie leer lassen (aber ich wollte nicht, dass jemand denkt, dass die Zeit für die Antwort nicht geschätzt wird)
jleach
3
@ jdl134679: Ich würde vorschlagen, dass Sie etwas als Antwort markieren, wenn Sie der Meinung sind, dass Ihre Frage beantwortet wurde. das sendet ein Signal, dass Leute hierher kommen sollten, wenn sie eine gute Antwort lesen wollen, anstatt eine zu schreiben . (Natürlich wird das Schreiben guter Antworten immer empfohlen.) Es ist mir egal, wer das Häkchen bekommt.
Eric Lippert
8

Appel schrieb, eine alte Papiermüllsammlung könne schneller sein als die Stapelzuweisung . Lesen Sie auch sein Compiling with Continuations- Buch und das Garbage Collection-Handbuch . Einige GC-Techniken sind (unintuitiv) sehr effizient. Der Fortsetzungsübertragungsstil definiert eine kanonische Ganz Programm Transformation (die CPS - Transformation ) erhalten von Stapeln rid (konzeptuell Anruf Frames mit Heap zugewiesenen Ersatz Verschlüssen , mit anderen Worten individuellen Anruf Frames als einzelne „Werte“ oder „Objekte“ „reifying“ ).

Der Aufrufstapel wird jedoch immer noch sehr häufig verwendet, und aktuelle Prozessoren verfügen über dedizierte Hardware (Stapelregister, Cache-Maschinen usw.), die für Aufrufstapel vorgesehen ist (und das liegt daran, dass die meisten Programmiersprachen auf niedriger Ebene, insbesondere C, einfacher zu verwenden sind mit einem Aufrufstapel implementieren). Beachten Sie auch , dass Stacks Cache freundlich (und das zählt eine Menge für die Leistung).

In der Praxis gibt es immer noch Call Stacks. Aber wir haben jetzt viele davon, und manchmal ist der Aufrufstapel in viele kleinere Segmente aufgeteilt (z. B. von einigen Seiten mit jeweils 4 KByte), die manchmal durch Müll gesammelt oder Heap zugewiesen werden. Diese Stapelsegmente können in einer verknüpften Liste (oder einer komplexeren Datenstruktur, falls erforderlich) organisiert sein. Zum Beispiel können die GCC - Compiler haben eine -fsplit-stackOption (vor allem nützlich für Go, und seine „goroutines“ und seine „Asynchron - Prozesse“). Mit geteilten Stapeln können Sie viele tausend Stapel (und Co-Routinen werden einfacher zu implementieren) aus Millionen kleiner Stapelsegmente erstellen lassen, und das "Abwickeln" des Stapels kann schneller (oder zumindest fast so schnell wie mit einem einzelnen Block) sein Stapel).

(Mit anderen Worten, die Unterscheidung zwischen Stack und Heap ist verschwommen, erfordert jedoch möglicherweise eine Ganzprogrammtransformation oder eine inkompatible Änderung der Aufrufkonvention und des Compilers.)

Siehe auch dies und das und viele Artikel (z. B. dies ) über die CPS-Transformation. Lesen Sie auch über ASLR & call / cc . Lesen Sie (& STFW) mehr über Fortsetzungen .

Die .CLR- und .NET-Implementierungen verfügen aus vielen pragmatischen Gründen möglicherweise nicht über die neueste GC- und CPS-Transformation. Dies ist ein Kompromiss im Zusammenhang mit ganzen Programmtransformationen (und der einfachen Verwendung von C-Routinen auf niedriger Ebene und einer in C oder C ++ codierten Laufzeit).

Chicken Scheme verwendet den Maschinenstapel (oder C-Stapel) auf unkonventionelle Weise mit CPS-Transformation: Jede Zuordnung erfolgt auf dem Stapel, und wenn er zu groß wird, verschiebt ein GC-Kopier- und Weiterleitungsschritt der Generation die zuletzt zugewiesenen Stapelwerte (und wahrscheinlich die Stromfortsetzung) auf den Haufen, und dann wird der Stapel mit einem großen drastisch reduziert setjmp.


Lesen Sie auch SICP , Programmiersprache Pragmatik , das Drachenbuch , Lisp In Small Pieces .

Basile Starynkevitch
quelle
1
Sehr hilfreich, danke. Wenn ich beide Antworten als akzeptiert markieren könnte, würde ich, aber weil ich nicht kann, werde ich sie leer lassen (aber ich wollte nicht, dass jemand denkt, dass die Zeit für die Antwort nicht geschätzt wird)
jleach