Weitere Informationen zu Coroutinen in C ++

Hallo Kollegen.

Im Rahmen der Entwicklung des C ++ 20-Themas stießen wir einmal auf einen ziemlich alten Artikel (September 2018) aus dem Yandex-Hublog mit dem Titel „ Vorbereitung auf C ++ 20. Coroutines TS mit einem echten Beispiel “. Es endet mit der folgenden sehr ausdrucksstarken Abstimmung:



„Warum nicht?“ Wir haben einen Artikel von David Pilarski unter dem Titel „Coroutines Introduction“ beschlossen und übersetzt. Der Artikel wurde vor etwas mehr als einem Jahr veröffentlicht, aber hoffentlich finden Sie ihn trotzdem sehr interessant.

So ist es passiert. Nach vielen Zweifeln, Debatten und Vorbereitungen zu diesem Feature kam WG21 zu einer gemeinsamen Meinung darüber, wie Coroutinen in C ++ aussehen sollten - und es ist sehr wahrscheinlich, dass sie in C ++ 20 enthalten sein werden. Da dies ein wichtiges Feature ist, denke ich, ist es Zeit, es bereits vorzubereiten und zu studieren Jetzt (wie Sie sich erinnern, gibt es noch mehr Module, Konzepte, Bereiche zu lernen ...)

Viele sind immer noch gegen Coroutine. Oft beschweren sie sich über die Komplexität ihrer Entwicklung, viele Anpassungspunkte und möglicherweise eine suboptimale Leistung aufgrund einer möglicherweise nicht optimierten Zuweisung des dynamischen Speichers (möglicherweise;)).

Parallel zur Entwicklung genehmigter (offiziell veröffentlichter) technischer Spezifikationen (TS) wurden sogar Versuche unternommen, einen anderen Corutin-Mechanismus parallel zu entwickeln. Hier werden wir über die Coroutinen sprechen, die in TS ( technische Spezifikation ) beschrieben sind. Ein alternativer Ansatz gehört wiederum zu Google. Infolgedessen stellte sich heraus, dass der Google-Ansatz unter zahlreichen Problemen leidet, deren Lösung häufig seltsame zusätzliche Funktionen von C ++ erfordert.

Am Ende wurde beschlossen, eine von Microsoft entwickelte Version von Corutin (gesponsert von TS) zu übernehmen. Es geht um solche Coroutinen, die in diesem Artikel behandelt werden. Beginnen wir also mit der Frage ...

Was sind Coroutinen?


Coroutinen gibt es bereits in vielen Programmiersprachen, beispielsweise in Python oder C #. Coroutinen sind eine weitere Möglichkeit, asynchronen Code zu erstellen. Wie sie sich von Flows unterscheiden, warum Coroutinen als dedizierte Sprachfunktion implementiert werden sollten und schließlich, wie sie verwendet werden, wird in diesem Abschnitt erläutert.

Es gibt ein ernstes Missverständnis darüber, was Coroutinen sind. Abhängig von der Umgebung, in der sie verwendet werden, können sie wie folgt bezeichnet werden:

  • Stapellose Coroutinen
  • Coroutinen stapeln
  • Grüne Bäche
  • Fasern
  • Gorutins

Die gute Nachricht: Stapelkorutine, grüne Ströme, Fasern und Gorutine sind ein und dasselbe (aber sie werden manchmal auf unterschiedliche Weise verwendet). Wir werden später in diesem Artikel darüber sprechen und sie Fasern oder Stapelkoroutinen nennen. Die stapellose Coroutine weist jedoch einige Funktionen auf, die separat behandelt werden müssen.

Um Coroutinen zu verstehen, auch auf einer intuitiven Ebene, lernen wir kurz die Funktionen und (sagen wir es so) „ihre API“ kennen. Die Standardmethode für die Arbeit mit ihnen besteht darin, anzurufen und zu warten, bis der Vorgang abgeschlossen ist:

void foo(){
     return; //     
}	
foo(); //   / 

Nach dem Aufruf der Funktion ist es bereits unmöglich, die Arbeit anzuhalten oder fortzusetzen. Sie können nur zwei Operationen an Funktionen ausführen: startund finish. Wenn die Funktion gestartet wird, müssen Sie warten, bis sie abgeschlossen ist. Wenn die Funktion erneut aufgerufen wird, erfolgt ihre Ausführung von Anfang an.

Bei Coroutinen ist die Situation anders. Sie können sie nicht nur starten und stoppen, sondern auch anhalten und fortsetzen. Sie unterscheiden sich immer noch von Kernflüssen, da sich die Coroutinen selbst nicht verdrängen (andererseits beziehen sich Coroutinen normalerweise auf den Fluss, und der Fluss verdrängt sich). Um dies zu verstehen, betrachten Sie einen in Python definierten Generator. Lassen Sie so etwas in Python als Generator bezeichnen, in C ++ als Coroutine. Ein Beispiel stammt von dieser Site :

def generate_nums():
     num = 0
     while True:
          yield num
          num = num + 1	

nums = generate_nums()
	
for x in nums:
     print(x)
	
     if x > 9:
          break

So funktioniert dieser Code: Ein Funktionsaufruf generate_numsführt zur Erstellung eines Coroutine-Objekts. Bei jedem Schritt der Aufzählung eines Coroutine-Objekts nimmt Coroutine selbst die Arbeit wieder auf und hält sie erst nach einem Schlüsselwort yieldim Code an. dann wird die nächste Ganzzahl aus der Sequenz zurückgegeben (die for-Schleife ist syntaktischer Zucker zum Aufrufen einer Funktion next(), die die Coroutine wieder aufnimmt). Der Code beendet die Schleife, indem er auf eine break-Anweisung stößt. In diesem Fall endet Corutin nie, aber es ist leicht vorstellbar, dass Corutin das Ende erreicht und endet. Wie wir sehen können, auf ein korutine anwendbaren start, suspend, resumeund schließlich,finish. [Hinweis: C ++ bietet auch Erstellungs- und Zerstörungsvorgänge, die jedoch im Kontext eines intuitiven Verständnisses von Coroutine nicht wichtig sind].

Coroutinen als Bibliothek


Jetzt ist ungefähr klar, was Coroutinen sind. Möglicherweise wissen Sie, dass es Bibliotheken zum Erstellen von Glasfaserobjekten gibt. Die Frage ist, warum wir Coroutinen in Form einer speziellen Sprachfunktion benötigen und nicht nur eine Bibliothek, die mit Coroutinen funktioniert.

Hier versuchen wir, diese Frage zu beantworten und den Unterschied zwischen gestapelten und stapellosen Coroutinen zu demonstrieren. Dieser Unterschied ist der Schlüssel zum Verständnis von Corutin als Teil der Sprache.

Coroutinen stapeln


Lassen Sie uns zunächst diskutieren, was Stack-Coroutinen sind, wie sie funktionieren und warum sie als Bibliothek implementiert werden können. Ihre Erklärung ist relativ einfach, da sie in Bezug auf das Design Streams ähneln.

Fiber- oder Stack-Corutin verfügt über einen separaten Stack, mit dem Funktionsaufrufe verarbeitet werden können. Um genau zu verstehen, wie Coroutinen dieser Art funktionieren, betrachten wir Funktionsrahmen und Funktionsaufrufe kurz aus einer untergeordneten Perspektive. Aber zuerst sprechen wir über die Eigenschaften von Fasern.

  • Sie haben ihren eigenen Stapel,
  • Die Lebensdauer der Fasern hängt nicht von dem Code ab, der sie aufruft (normalerweise haben sie einen benutzerdefinierten Scheduler).
  • Fasern können von einem Faden gelöst und an einem anderen befestigt werden.
  • Kooperative Planung (die Glasfaser muss sich entscheiden, zu einer anderen Glasfaser / einem anderen Planer zu wechseln),
  • Kann nicht gleichzeitig im selben Thread arbeiten.

Die folgenden Effekte ergeben sich aus den obigen Eigenschaften:

  • Das Umschalten des Kontexts der Fasern sollte vom Benutzer der Fasern und nicht vom Betriebssystem durchgeführt werden (außerdem kann das Betriebssystem die Faser freigeben und den Thread freigeben, in dem es funktioniert).
  • Es gibt kein echtes Datenrennen zwischen den beiden Fasern, da zu einem bestimmten Zeitpunkt nur eine von ihnen aktiv sein kann.
  • Der Glasfaserdesigner muss in der Lage sein, den richtigen Ort und die richtige Zeit auszuwählen, wo und wann es angebracht ist, die Rechenleistung an einen möglichen Planer oder Anrufer zurückzugeben.
  • Die Eingabe- / Ausgabeoperationen in der Faser müssen asynchron sein, damit andere Fasern ihre Aufgaben ausführen können, ohne sich gegenseitig zu blockieren.

Schauen wir uns nun die Funktionsweise der Fasern genauer an und erklären zunächst, wie der Stapel an Funktionsaufrufen teilnimmt.

Der Stapel ist also ein kontinuierlicher Speicherblock, der zum Speichern lokaler Variablen und Funktionsargumente benötigt wird. Noch wichtiger ist jedoch, dass nach jedem Funktionsaufruf (mit wenigen Ausnahmen) zusätzliche Informationen auf den Stapel übertragen werden, die der aufgerufenen Funktion mitteilen, wie sie zum Aufrufer zurückkehren und Prozessorregister wiederherstellen soll.

Einige dieser Register haben spezielle Zuweisungen, und beim Aufrufen von Funktionen werden sie auf dem Stapel gespeichert. Dies sind die Register (im Fall der ARM-Architektur):

SP - Stapelzeiger
LR - Kommunikationsregister
PC - Programmzähler

Stapelzeiger(SP) ist ein Register, das die Adresse des Stapelanfangs in Bezug auf den aktuellen Funktionsaufruf enthält. Dank des vorhandenen Werts können Sie leicht auf Argumente und lokale Variablen verweisen, die auf dem Stapel gespeichert sind.

Das Kommunikationsregister (LR) ist beim Aufrufen von Funktionen sehr wichtig. Es speichert die Absenderadresse (die Adresse des anrufenden Teilnehmers), an der der Code ausgeführt wird, nachdem die Ausführung der aktuellen Funktion abgeschlossen ist. Beim Aufruf der Funktion wird der PC in LR gespeichert. Wenn die Funktion zurückkehrt, wird der PC mit LR wiederhergestellt.

Der Programmzähler (PC) ist die Adresse des aktuell ausgeführten Befehls.
Bei jedem Aufruf einer Funktion wird die Liste der Links gespeichert, damit die Funktion weiß, wohin das Programm nach Abschluss zurückkehren soll.



Das Verhalten der PC- und LR-Register beim Aufrufen und Zurückgeben einer Funktion

Bei der Ausführung einer Stapelkoroutine verwenden die aufgerufenen Funktionen den zuvor zugewiesenen Stapel, um ihre Argumente und lokalen Variablen zu speichern. Da alle Informationen zu jeder Funktion, die auf dem Stapel-Corutin aufgerufen wird, auf dem Stapel gespeichert sind, kann die Faser jede Funktion innerhalb dieses Corutins aussetzen.



Mal sehen, was auf diesem Bild passiert. Erstens hat jede Faser und jeder Faden einen eigenen Stapel. Die grüne Farbe zeigt Seriennummern an, die die Reihenfolge der Aktionen angeben.

  1. Ein regulärer Funktionsaufruf innerhalb eines Threads. Der Speicher wird auf dem Stapel zugewiesen.
  2. . . , . . , .
  3. .
  4. . .
  5. .
  6. .
  7. . , , , .
  8. .
  9. .
  10. – , .
  11. , .
  12. . .
  13. . : , . , ( ) .
  14. , .
  15. .
  16. . . . , .
  17. .
  18. , , .

Bei der Arbeit mit Stack-Coroutinen ist keine spezielle Sprachfunktion erforderlich, die deren Verwendung sicherstellt. Die gesamte Stapelkorutiny kann mithilfe von Bibliotheken implementiert werden, und es gibt bereits Bibliotheken, die speziell für diesen Zweck entwickelt wurden:

swtch.com/libtask
code.google.com/archive/p/libconcurrency
www.boost.org Boost.Fiber
www.boost.org the Boost .Coroutine

Von all diesen Bibliotheken ist nur Boost C ++ und alle anderen sind C.
Eine ausführliche Beschreibung der Funktionsweise dieser Bibliotheken finden Sie in der Dokumentation. Im Allgemeinen können Sie mit all diesen Bibliotheken einen separaten Stapel für Glasfaser erstellen und die Coroutine (auf Initiative des Anrufers) wieder aufnehmen und anhalten (von innen).

Betrachten Sie ein Beispiel Boost.Fiber:

#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
	
#include <boost/intrusive_ptr.hpp>
	
#include <boost/fiber/all.hpp>
	
inline
void fn( std::string const& str, int n) {
     for ( int i = 0; i < n; ++i) {
          std::cout << i << ": " << str << std::endl;
               boost::this_fiber::yield();
     }
}
	
int main() {
     try {
          boost::fibers::fiber f1( fn, "abc", 5);
          std::cerr << "f1 : " << f1.get_id() << std::endl;
          f1.join();
          std::cout << "done." << std::endl;
	
          return EXIT_SUCCESS;
     } catch ( std::exception const& e) {
          std::cerr << "exception: " << e.what() << std::endl;
     } catch (...) {
          std::cerr << "unhandled exception" << std::endl;
     }
     return EXIT_FAILURE;
}

Im Fall von Boost.Fiber verfügt die Bibliothek über einen integrierten Scheduler für Coroutine. Alle Fasern laufen im gleichen Faden. Da die Corutin-Planung kooperativ ist, muss die Faser zuerst entscheiden, wann die Steuerung an den Scheduler zurückgegeben werden soll. In diesem Beispiel geschieht dies, wenn die Ertragsfunktion aufgerufen wird, wodurch die Coroutine angehalten wird.

Da es keine andere Faser gibt, beschließt der Faserplaner immer, die Coroutine wieder aufzunehmen.

Stapellose Coroutinen


Stapellose Coroutinen unterscheiden sich geringfügig in ihren Eigenschaften von stapelbaren. Sie haben jedoch die gleichen grundlegenden Eigenschaften, da die nicht gestapelten Coroutinen ebenfalls gestartet und nach ihrer Suspendierung wieder aufgenommen werden können. Coroutinen dieses Typs finden wir wahrscheinlich in C ++ 20.

Wenn wir über die ähnlichen Eigenschaften von Corutin sprechen, können Coroutinen:

  • Corutin ist eng mit ihrem Anrufer verbunden: Wenn eine Coroutine aufgerufen wird, wird die Ausführung an sie übertragen und das Ergebnis der Coroutine wird zurück an den Anrufer übertragen.
  • Die Lebensdauer eines Stapelkorutins entspricht der Lebensdauer seines Stapels. Die Lebensdauer einer stapellosen Coroutine entspricht der Lebensdauer ihres Objekts.

Bei stapellosen Coroutinen ist es jedoch nicht erforderlich, einen ganzen Stapel zuzuweisen. Sie verbrauchen viel weniger Speicher als Stapelspeicher, aber dies liegt genau an einigen ihrer Einschränkungen.

Wie funktionieren sie, wenn sie dem Stapel keinen Speicher zuweisen? Wo in ihrem Fall alle Daten abgelegt werden, die auf dem Stapel gespeichert werden sollten, wenn mit Stapelkoroutinen gearbeitet wird. Antwort: auf dem Stapel des Anrufers.

Das Geheimnis stapelloser Coroutinen ist, dass sie sich nur an der obersten Funktion aufhängen können. Bei allen anderen Funktionen befinden sich ihre Daten auf dem Stapel der aufgerufenen Seite, sodass alle von Corutin aufgerufenen Funktionen ausgeführt werden müssen, bevor die Arbeit des Corutins unterbrochen wird. Alle Daten, die Coroutine benötigt, um seinen Status aufrechtzuerhalten, werden dynamisch auf dem Heap zugeordnet. Dies erfordert normalerweise einige lokale Variablen und Argumente, die viel kompakter sind als ein ganzer Stapel, der im Voraus zugewiesen werden müsste.

Schauen Sie sich an, wie stapellose Corutine funktionieren:



Fordern Sie ein stapelloses Corutin heraus

Wie Sie sehen können, gibt es jetzt nur noch einen Stapel - dies ist der Hauptstapel des Threads. Schauen wir uns Schritt für Schritt an, was in diesem Bild gezeigt wird (der Coroutine-Aktivierungsrahmen hier ist zweifarbig - Schwarz zeigt an, was auf dem Stapel gespeichert ist, und Blau - was auf dem Heap gespeichert ist).

  1. Ein regulärer Funktionsaufruf, dessen Frame auf dem Stapel gespeichert ist
  2. Die Funktion erstellt eine Coroutine . Das heißt, es wird irgendwo auf dem Heap ein Aktivierungsrahmen dafür zugewiesen.
  3. Normaler Funktionsaufruf.
  4. Rufen Sie Corutin an . Corutins Körper sticht in einem regelmäßigen Stapel hervor. Das Programm wird wie bei einer regulären Funktion ausgeführt.
  5. Ein regelmäßiger Funktionsaufruf von Coroutine. Auch hier passiert immer noch alles auf dem Stapel [Hinweis: Sie können die Coroutine von diesem Punkt aus nicht anhalten, da dies nicht die oberste Funktion in der Coroutine ist]
  6. [: .]
  7. – , , .
  8. – , + .
  9. 5.
  10. 6.
  11. . .

Es ist also offensichtlich, dass im zweiten Fall viel weniger Daten für alle Vorgänge zum Unterbrechen und Wiederaufnehmen der Arbeit von Coroutine gespeichert werden müssen. Coroutine kann jedoch nur sich selbst und nur von der obersten Funktion aus wieder aufnehmen und aussetzen. Alle Funktionsaufrufe und Coroutine erfolgen auf die gleiche Weise. Zwischen den Aufrufen müssen jedoch einige zusätzliche Daten gespeichert werden, und die Funktion muss in der Lage sein, zum Suspendierungspunkt zu springen und den Status lokaler Variablen wiederherzustellen. Es gibt keine weiteren Unterschiede zwischen dem Coroutine-Frame und dem Funktionsframe.

Corutin kann auch andere Coroutinen verursachen (in diesem Beispiel nicht gezeigt). Bei stapellosen Coroutinen führt jeder Aufruf zur Zuweisung eines neuen Speicherplatzes für neue Coroutinendaten (bei einem wiederholten Aufruf von Coroutine kann der dynamische Speicher auch mehrmals zugewiesen werden).

Der Grund, warum Coroutinen eine dedizierte Sprachfunktion bereitstellen müssen, liegt darin, dass der Compiler entscheiden muss, welche Variablen den Status der Coroutine beschreiben, und stereotypen Code erstellen muss, um zu den Suspendierungspunkten zu springen.

Praktische Anwendung von Corutin


Coroutinen in C ++ können auf die gleiche Weise wie in anderen Sprachen verwendet werden. Coroutinen vereinfachen die Rechtschreibung:

  • Generatoren
  • asynchroner Eingabe- / Ausgabecode
  • Lazy Computing
  • ereignisgesteuerte Anwendungen

Zusammenfassung


Ich hoffe, dass Sie durch das Lesen dieses Artikels Folgendes herausfinden:

  • Warum müssen Sie in C ++ Coroutinen als dedizierte Sprachfunktion implementieren?
  • Was ist der Unterschied zwischen gestapelten und stapellosen Coroutinen?
  • warum Coroutinen benötigt werden

All Articles