(C++) Coroutines - Motivation?



  • Marthog schrieb:

    Sie sind in so fern minimal schneller als OS-threads, da der Wechsel weniger Overhead hat und man andererseits auch auf synchronisierungsmechanismen wie mutexe verzichten kann.

    Nicht die synchronisation dabei bedenken. Threads brauchen immer synchronisation, Coroutines nicht. Das ist nicht nur ein reiner Geschwindigkeitsunterschied sondern auch ein enormer Komplexitäts/Fehleranfälligkeits Unterschied.





  • Boost.Coroutine ist ein technischer Zwischenschritt. Es ist das, was ohne neue Sprach-Features möglich ist. Ein Callstack, der mit etwas Syntactic Sugar ohne Hilfe des Kernels betreten und verlassen werden kann.

    Was man eigentlich will, ist Compiler-Unterstützung. Im Idealfall kann der Compiler statisch berechnen, wie groß der Stack werden kann bzw den gleichen Code generieren, die man durch Callback Hell erhalten hätte. Der Speicherverbrauch wäre entsprechend gering. Boost Coroutine fordert AFAIK immer mindestens eine physikalische Speicherseite an (4096 Bytes oder sogar mehr) und den Rest des Stacks virtuell, sodass er wachsen kann (zumindest bei GCC ab 4.7). Auch das Betreten und Verlassen des Callstacks kann nicht für den Einzelfall vom Compiler optimiert werden, weil das in Boost.Context mit Assembler pro Plattform geschrieben wurde. Visual Studio 2015 hat schon erste, experimentelle Spracherweiterungen.

    Die Dokumentation von Boost.Coroutine ist äußerst mies und faselt nur von irgendwelchen XML-Parsern. Viel interessanter ist, was Asio damit macht. Coroutinen passen sehr gut zum io_service und dem async_result -Konzept. Das ist viel besser lesbar als die alte Asio-Callback-Hell. Bei I/O fällt die fette Boost Coroutine auch nicht mehr so auf.

    Man steigert damit nie die Geschwindigkeit, sondern nur die Les- und Schreibbarkeit.



  • Und irgendwo schon mal in der Praxis benutzt?



  • Nicht die synchronisation dabei bedenken. Threads brauchen immer synchronisation, Coroutines nicht. Das ist nicht nur ein reiner Geschwindigkeitsunterschied sondern auch ein enormer Komplexitäts/Fehleranfälligkeits Unterschied.

    Also doch auch ein Geschwindigkeitsvorteil? Wenn Synchronisierung plötzlich eine Rolle spielt, haben wir ja doch Multithreading im Spiel? Oder geht es dir auch um I/O.

    TyRoXx:
    Ehrlich gesagt verstehe ich bei deinem Beitrag nicht besonders viel, ich hab generell ein paar Wissenslücken. Was ist denn die Quintessenz? Coroutines könn(t)en den Callstack entschlacken. Wenn die Performance aber nicht steigt, was nützt mir das dann? Und Lesbarkeit... die hat ja mit dem Call-Stack wiederum nichts zu tun.

    Irgendwie überzeugen mich diese Coroutines immer noch nicht. IO mache ich sowieso über irgendwelche Libraries, da sehe ich also keinen Nutzen



  • Eisflamme schrieb:

    Also doch auch ein Geschwindigkeitsvorteil? Wenn Synchronisierung plötzlich eine Rolle spielt, haben wir ja doch Multithreading im Spiel? Oder geht es dir auch um I/O.

    Im Gegenteil. Synchronisieren spielt hier keine Rolle. Wenn du mehrere Aktionen gleichzeitig bzw. abwechselnd ausfuehren willst, brauchst du normalerweise verschiedene Ausfuehrungsstraenge (klassisches Beispiel: GUI-events und lange Berechnung gleichzeitig).
    Du kannst normale threads nehmen, aber dann musst du synchronisieren und dabei hoellisch aufpassen, weil an jeder Stelle im Code der threadwechsel stattfinden kann. Mit coroutines kannst du selber angeben, wann du wechseln willst und laesst es an kritischen Stellen einfach bleiben. Dadurch hat der Wechsel geringeren overhead als echte threads. Da laeuft trotzdem nichts in parallel.



  • Eisflamme schrieb:

    Irgendwie überzeugen mich diese Coroutines immer noch nicht. IO mache ich sowieso über irgendwelche Libraries, da sehe ich also keinen Nutzen

    Vermutlich weil du deine Coroutines selbst programmierst, ohne es zu wissen.

    IO.write (some_megabytes)   // non-blocking
    while (not IO.write_complete)
    {
       do_other_stuff();
    }
    

    😉



  • Mit einem konkreten Beispiel ist das Thema vielleicht leichter zu erklären:

    Nehmen wir einen GUI Texteditor. Der kann nicht mehr als eine Datei einlesen und den Inhalt darstellen. Es ergeben sich 3 Mögliche Implementierungen:

    1. Synchron: Wenn auf Öffnen geklickt wird, wird direkt die Datei eingelesen und das UI Element mit Text gefüllt. Wir sind im GUI Thread, brauchen also keine Synchronisation oder ähnliches. Dafür reagiert die GUI nicht mehr bis die Datei komplett eingelesen wurde.

    2. Threads: Wenn auf Öffnen geklickt wird, wird ein Thread erzeugt. Dieser Thread liest die Datei ein, kann dann aber nicht direkt in das GUI Element schreiben, da dies nur aus dem GUI Thread möglich ist. Also brauchen wir Synchronisation. Häufig gibt es da Invoke Funktionen um Code im GUI Thread auszuführen. Man muss also grundsätzlich immer darauf achten in welchem Thread man gerade ist und das Invoke ist natürlich nicht ohne Overhead, da wir durch das hin und her mehrere Context switches in den Kernel etc. haben.

    3. Coroutines: Wenn auf Öffnen geklick wird, wird die Coroutine zum Datei einlesen angestoßen. Sobald die Coroutine auf IO trifft, gibt sie die Kontrolle ab und die GUI bekommt wieder CPU Zeit. Die GUI bleibt im Gegensatz zu Fall 1 also nicht stehen. Es spielen sich beide Coroutines die Kontrolle hin und her bis die Datei eingelesen wurde. Dies geschieht im User Space und ist daher nicht annähernd so teuer wie im Thread Fall. Anschließend schreibt die Lese Coroutine die Daten direkt in das UI Element. Das ist möglich, weil es der gleiche Thread ist und die GUI in einem wohldefinierten Zustand die Kontrolle abgegeben hat.



  • Andromeda:
    Ja, Koroutinen nutzen Polling. Aber was ich hier polle, sieht ja Dinge, die u.U. in einem anderen Thread geschehen sind. Anderenfalls finde ich die Event-Queue nur mäßig sinnvoll. Aber stimmt wahrscheinlich, eine One-Thread-Event-Queue ist ein klassisches Beispiel für Coroutines?

    Tobiking2:
    Zu Fall 3: Verstehe ich theoretisch schon, praktisch aber nicht. Ich streame Dateien doch normalerweise und lese in diesem Prozess die Daten in eine Datenstruktur, da gibt's also immer mal wieder kleine IO-Abschnitte.

    Alternativ kann ich die gesamte Datei auf einmal lesen, z.B. über einen string, meinst du das? Aber das geht ja über die normalen fstreams nicht, richtig?



  • Eisflamme schrieb:

    Ich streame Dateien doch normalerweise und lese in diesem Prozess die Daten in eine Datenstruktur, da gibt's also immer mal wieder kleine IO-Abschnitte.

    Alternativ kann ich die gesamte Datei auf einmal lesen, z.B. über einen string, meinst du das?

    Für das Beispiel bin ich vereinfacht davon ausgegangen das man die Datei komplett in einen Buffer liest und dann anzeigt. Beim Streaming geht man vom Konzept aber in die gleiche Richtung. Aus der GUI stoße ich einen Lesevorgang an und rufe regelmäßig die Funktion zum Daten abholen auf. Jedes mal wenn ich die Funktion aufrufe, gebe ich die Kontrolle gezielt ab und die Lese Routine arbeitet. Kehrt die Lese Routine zurück, bekommt die GUI wieder die Kontrolle und kann sich mit den bisherigen Daten aktualisieren.

    Unterschied zu den Coroutines ist, dass die Lese Routine jedes mal komplett beendet und neu gestartet wird. Dabei geht dann auch jeder lokale Zustand verloren. Man muss also jedes mal die nötigen Daten bzw. den Objektkontext mitgeben. Eine Coroutine kann Daten liefern und später dort weiter machen wo sie gestoppt hat, ohne den lokalen Kontext zu verlieren.



  • Eisflamme schrieb:

    Ja, Koroutinen nutzen Polling. Aber was ich hier polle, sieht ja Dinge, die u.U. in einem anderen Thread geschehen sind. Anderenfalls finde ich die Event-Queue nur mäßig sinnvoll. Aber stimmt wahrscheinlich, eine One-Thread-Event-Queue ist ein klassisches Beispiel für Coroutines?

    Jein. Klar geht es um Threads aber zB Daten von Netzwerkkarte senden ist zwar prinzipiell ein Thread genauso wie Daten auf Festplatte schreiben - aber eben tief im System hinterlegt und die Synchronisierung findet hier eh sowieso immer statt.

    Coroutines sind einfach dafür da, wenn man eine Ausführung unterbrechen will und fortsetzen ohne echte Threads verwenden zu wollen. Threads haben gewisse Kosten.

    In C# verwende ich Coroutines zB sehr oft beim lesen aus einer "Datenbank" (wobei Datenbank hier abstrakt zu sehen ist: etwas dass mir Daten liefert) und ich verarbeite die Zeile für Zeile und lese Zeile für Zeile in eine Collection. Und immer wenn ich die neue Zeile brauche, hole ich sie mir adhoc aus der Datenbank - das ganze ist eben sehr simpel und der Read Cache übernimmt die Performance optimierung.

    Prinzipiell sind Coroutines dann sehr praktisch wenn man blockende Funktionen hat. zB readFromFile oder writeToNetwork. Dann macht man etwas anderes während das System hier den Rest handelt. Bedenke: ein readFromFile wird immer über den IO Thread des Systems fahren.



  • Die asynchronen IOs die für Coroutines & Co nötig sind sind auch nicht ganz gratis.
    Vermutlich billiger als Blocking-IO + Worker-Thread, aber sicher nicht gratis.

    Bzw. je nachdem welche Library/Implementierung man für die asynchronen IOs verwendet sogar teurer. Ich hab' mal in die Implementierung der asynchronen Sockets Funktionen im .NET Framework reingeguckt, da wurde mir schwummerig vor Augen 😉

    @Shade Of Mine
    Hast du zufällig Code zur Hand den du herzeigen kannst (und darfst)? Würde mich interessieren wie das aussieht.
    Ich kenne etwas sehr ähnliches aus C#, nämlich indem man den Lese-Code in eine Generator-Funktion reinstopft. Wobei ja vom Compiler automatisch eine Coroutine erzeugt wird. Bloss die blockt nach wie vor in IO Calls.
    Ansonsten kenne ich noch async/await , aber das wird recht schnell recht hässlich.
    Würde mich also interessieren wie das bei dir aussieht.



  • Eisflamme schrieb:

    Ehrlich gesagt verstehe ich bei deinem Beitrag nicht besonders viel, ich hab generell ein paar Wissenslücken. Was ist denn die Quintessenz? Coroutines könn(t)en den Callstack entschlacken. Wenn die Performance aber nicht steigt, was nützt mir das dann? Und Lesbarkeit... die hat ja mit dem Call-Stack wiederum nichts zu tun.

    Irgendwie überzeugen mich diese Coroutines immer noch nicht. IO mache ich sowieso über irgendwelche Libraries, da sehe ich also keinen Nutzen

    Da ist keine große Magie dabei. Es ist einfach eine andere Syntax, um das aufzuschreiben, was vorher schon möglich war:

    Callback Hell:

    struct async_stuff
    {
    	void start()
    	{
    		socket.async_read(buffer, [this](boost::system::error_code, std::size_t)
    		{
    			start();
    		});
    	}
    };
    

    Coroutine:

    struct async_stuff
    {
    	void start()
    	{
    		boost::asio::spawn(io, [](boost::asio::yield_context yield)
    		{
    			for (;;)
    			{
    				boost::system::error_code ec;
    				std::size_t s = socket.async_read(buffer, yield[ec]);
    			}
    		});
    	}
    };
    

    Man sieht vielleicht, dass start() einem goto recht ähnlich sieht. Bei der Coroutine hingegen kann man eine höhere Kontrollstruktur benutzen.



  • hustbaer schrieb:

    Hast du zufällig Code zur Hand den du herzeigen kannst (und darfst)? Würde mich interessieren wie das aussieht.
    Ich kenne etwas sehr ähnliches aus C#, nämlich indem man den Lese-Code in eine Generator-Funktion reinstopft. Wobei ja vom Compiler automatisch eine Coroutine erzeugt wird. Bloss die blockt nach wie vor in IO Calls.

    Prinzipiell sind es ja keine echten Coroutines sondern eigentlich nur yield calls.

    Es ist etwa so was:

    public IEnumerable<Data> GetData()
    {
    	EntityManager e = new EntityManager();
    	while(e.hasNext()) {
    		yield return e.current();
    	}
    }
    

    Das coole dabei ist eigentlich, dass es in der Collection versteckt ist was da eigentlich passiert. Sowas sind eigentlich die einzigen Fälle von Coroutunes die ich verwende.



  • @Shade Of Mine
    Danke für das Beispiel - genau so kenne ich das auch.
    Ist aber eben eines von den Beispielen wo nichts "ent-blockt" wird -- Nebenläufigkeit oder Time-Slicing innerhalb eines Threads erreicht man dadurch zwar schon, aber nicht so dass man die "Löcher" wo der Thread auf IO wartet auffüllen könnte.



  • TyRoXx schrieb:

    Coroutine:

    struct async_stuff
    {
    	void start()
    	{
    		boost::asio::spawn(io, [](boost::asio::yield_context yield)
    		{
    			for (;;)
    			{
    				boost::system::error_code ec;
    				std::size_t s = socket.async_read(buffer, yield[ec]);
    			}
    		});
    	}
    };
    

    Weisst du wie das intern funktioniert?
    Ich sehe da keine Möglichkeit ohne schwarze Compiler- und/oder OS-Magic.



  • Boost.Coroutine baut auf Boost.Context auf, das die CPU-Register sichern und wiederherstellen kann und eben unter anderem den Stack Pointer umbiegt. Der Teil ist natürlich plattformspezifisch in Assembler geschrieben. Der Stack kann von malloc kommen oder mit mmap -Spielchen gegen einen Überlauf geschützt werden.

    asio::spawn verwendet Boost Coroutine und bietet den yield_context an, der mit den async_* -Operationen von Asio kompatibel ist.

    Das benutzt sich in etwa wie ein Kernel-Thread. mutex und so gibt es hierfür (noch) nicht, was aber nicht unbedingt ein Nachteil ist ;). Der Vorteil ist, dass die Kontextwechsel ohne Umweg über den Kernel funktionieren und damit schneller sind. Konkret wird ein run -Thread von epoll oder ähnlichem geweckt und Asio lässt dann N Coroutinen nacheinander ein Stück laufen. Ein Kernel-Thread pro Vorgang würde bedeuten, dass N Kernel-Threads geweckt werden müssten. Man spart hier (N-1) mal den Kernel-zu-Userspace-und-am-Ende-wieder-zurück-Overhead.

    Mit richtiger Compiler-Unterstützung könnte man jetzt auch noch den Speicherverbrauch der Coroutinen reduzieren und die Kontextwechsel optimieren.



  • TyRoXx schrieb:

    Boost.Coroutine baut auf Boost.Context auf, das die CPU-Register sichern und wiederherstellen kann und eben unter anderem den Stack Pointer umbiegt. Der Teil ist natürlich plattformspezifisch in Assembler geschrieben. Der Stack kann von malloc kommen oder mit mmap -Spielchen gegen einen Überlauf geschützt werden.

    Danke für die Info.

    TyRoXx schrieb:

    Konkret wird ein run -Thread von epoll oder ähnlichem geweckt und Asio lässt dann N Coroutinen nacheinander ein Stück laufen.

    Verstehe ich jetzt nicht ganz. Sollte man die Dinger nicht laufen lassen so lange bis keine mehr was zu tun hat?

    Oder kann Boost.Coroutine einfach nicht feststellen dass eine bestimmte Coroutine gerade nix zu tun hat, und muss deswegen pollen? In dem Fall verstehe ich die Begeisterung nicht ganz. Damit ersetzt du halbwegs effizientes "reactive IO" durch Polling. Hmpf.



  • hustbaer schrieb:

    TyRoXx schrieb:

    Konkret wird ein run -Thread von epoll oder ähnlichem geweckt und Asio lässt dann N Coroutinen nacheinander ein Stück laufen.

    Verstehe ich jetzt nicht ganz. Sollte man die Dinger nicht laufen lassen so lange bis keine mehr was zu tun hat?

    Oder kann Boost.Coroutine einfach nicht feststellen dass eine bestimmte Coroutine gerade nix zu tun hat, und muss deswegen pollen? In dem Fall verstehe ich die Begeisterung nicht ganz. Damit ersetzt du halbwegs effizientes "reactive IO" durch Polling. Hmpf.

    Ich meinte damit, dass die solange läuft, bis sie wieder auf etwas wartet oder sich beendet.



  • OKok
    Ist quasi ein bisschen so wie async/await von C# - nur halt mit gänzlich anderem Unterbau.

    Ich hoffe aber sehr dass das als Core Language Erweiterung in C++ integriert wird, wie es bei C# gemacht wurde. Und nicht als Library.


Anmelden zum Antworten