C++20: Coroutinen mit cppcoro

Modernes C++  –  1 Kommentare

Die cppcoro-Bibliothek von Lewis Baker bietet an, was der C++20-Standard nicht leistet: High-Level-Coroutinen.

Zugegeben, meine zwei letzten Artikel "C++20: Ein unendlicher Datenstrom mit Coroutinen" und "C++20: Thread-Synchronisation mit Coroutinen" waren schwere Kost. Meine nächsten Artikel zu Couroutinen werden deutlich bekömmlicher sein. Ich verwende Beispiele der Coroutinen-Bibliothek cppcoro.

Um mir meine Argumentation zu erleichtern, werde ich die Begriffe Coroutinen und Coroutinen-Framework verwenden.

cppcoro

Die cppcoro-Bibliothek von Lewis Baker basiert auf dem Coroutinen TS. TS steht für Technical Specification und ist die vorläufige Spezifikation des Coroutinen-Frameworks, das wir in C++20 erhalten. Baker wird die cppcoro-Bibliothek noch auf das C++20-Coroutinen Framework-portieren.

Diese Portierung von cppcoro ist aus meiner Sicht aus einem Grund sehr wichtig: Wir erhalten keine Coroutinen, sondern ein Coroutinen-Framework mit C++20. Dieser kleiner Unterschied bedeutet, dass du, wenn du Coroutinen in C++20 verwenden willst, diese auf Grundlage des C++ 20-Coroutinen-Frameworks umsetzen musst. Mit C++23 werden wir wohl konkrete Coroutinen erhalten. Ehrlich gesagt sehe ich das sehr kritisch, denn eigene Coroutinen aufgrund des C++20-Coroutinen-Frameworks zu implementieren, ist sehr anspruchsvoll und damit fehleranfällig. Diese Lücke ist genau die Lücke, die cppcoro schließt. cppcoro bietet viele Abstraktionen rund um Coroutinen-Datentypen an:

  • Coroutinen-Datentypen
  • "Awaitable types"
  • Funktionen
  • Beendigung von Coroutinen
  • Netzwerk
  • Scheduler
  • Metafunktionen
  • Concepts

Verwendung von cppcoro

Zurzeit setzt cppcoro auf dem Coroutines TS auf und kann mit Windows (Visual Studio 2017) oder Linux (Clang 5.0/6.0 und libc++) verwendet werden. Für meine Experimente kommt die folgende Kommandozeile zum Einsatz:

  • -std=c++17: Unterstützung für C++17
  • -fcoroutines-ts: Unterstützung für das Coroutines TS
  • -Iinclude: cppcoro-Headerdateien
  • -stdlib=libc++: LLVM-Implementierung der Standard-Bibliothek
  • libcppcoro.a: cppcoro-Bibliothek

Gerne will ich es nochmal betonen: Wenn cppcoro in der Zukunft auf C++20 basiert, lässt sich die Bibliothek mit jedem C++20-konformen Compiler verwenden. Zusätzlich gibt dir cppcoro ein Gefühl dafür, welche konkreten Coroutinen wir wohl mit C++23 erhalten werden.

Nach meiner steilen Lernkurve möchte ich eine paar Beispiele zu cppcoro vorstellen. Ich verwende für meine Vorstellung der Features von cppcoro vorhandene Codeschnipsel oder dokumentierte Testfälle. Los geht es mit den Coroutinen.

Coroutinen-Datentypen

cppcoro bietet Tasks und Generatoren in verschiedenen Variationen an.

task<T>

Was ist eine Task? Dies ist die Definition von task<T> direkt von der Online-Dokumentation.

  • A task represents an asynchronous computation that is executed lazily in that the execution of the coroutine does not start until the task is awaited.

Eine Task ist eine Coroutine. In dem folgenden Programm wartet die Funktion main auf die Funktion first, die Funktion first auf die Funktion second und die Funktion second auf die Funktion third:

// cppcoroTask.cpp

#include <chrono>
#include <iostream>
#include <string>
#include <thread>

#include <cppcoro/sync_wait.hpp>
#include <cppcoro/task.hpp>

using std::chrono::high_resolution_clock;
using std::chrono::time_point;
using std::chrono::duration;

using namespace std::chrono_literals; // 1s

auto getTimeSince(const time_point<high_resolution_clock>& start) {

auto end = high_resolution_clock::now();
duration<double> elapsed = end - start;
return elapsed.count();

}

cppcoro::task<> third(const time_point<high_resolution_clock>& start) {

std::this_thread::sleep_for(1s);
std::cout << "Third waited " << getTimeSince(start) << " seconds."
<< std::endl;

co_return; // (4)

}

cppcoro::task<> second(const time_point<high_resolution_clock>& start) {

auto thi = third(start); // (2)
std::this_thread::sleep_for(1s);
co_await thi; // (3)

std::cout << "Second waited " << getTimeSince(start) << " seconds."
<< std::endl;

}

cppcoro::task<> first(const time_point<high_resolution_clock>& start) {

auto sec = second(start); // (2)
std::this_thread::sleep_for(1s);
co_await sec; // (3)

std::cout << "First waited " << getTimeSince(start) << " seconds."
<< std::endl;

}

int main() {

std::cout << std::endl;

auto start = high_resolution_clock::now();
cppcoro::sync_wait(first(start)); // (1)

std::cout << "Main waited " << getTimeSince(start) << " seconds."
<< std::endl;

std::cout << std::endl;

}

Zugegeben, das Programm löst kein Problem. Das Programm hilft aber, den Arbeitsablauf von Coroutinen besser zu verstehen.

Zuerst einmal kann die main-Funktion keine Coroutine sein. Aus diesem Grund dient die Funktion cppcoro::sync_wait (Zeile 1) gerne als der startende Top-Level Task. Er wartet, bis er abgeschlossen ist. Die Coroutine first erhält wie die anderen Coroutinen als Argument die start-Zeit und stellt ihre Ausführungszeit dar. Was passiert in der Coroutine first? Sie startet die Coroutine second (Zeile 2), die sofort angehalten wird, anschließend schläft first für eine Sekunde und weckt die Coroutine second mithilfe des Coroutinen-Handle sec in Zeile 3 wieder auf. Die Coroutine second folgt demselben Arbeitsablauf. Dies gilt nicht für die Coroutine third. Diese gibt nichts zurück und wartet auch nicht auf eine andere Coroutine. Wenn third mit der Ausführung fertig ist, werden alle anderen Coroutinen ausgeführt. Damit besitzt jede Coroutine eine Ausführungszeit von drei Sekunden.

Jetzt variiere ich den Arbeitsablauf ein wenig. Was passiert, wenn ich die Coroutine erst nach dem co_await call schlafen lege?

// cppcoroTask2.cpp

#include <chrono>
#include <iostream>
#include <string>
#include <thread>

#include <cppcoro/sync_wait.hpp>
#include <cppcoro/task.hpp>

using std::chrono::high_resolution_clock;
using std::chrono::time_point;
using std::chrono::duration;

using namespace std::chrono_literals;

auto getTimeSince(const time_point<::high_resolution_clock>& start) {

auto end = high_resolution_clock::now();
duration<double> elapsed = end - start;
return elapsed.count();

}

cppcoro::task<> third(const time_point<high_resolution_clock>& start) {

std::cout << "Third waited " << getTimeSince(start) << " seconds."
<< std::endl;
std::this_thread::sleep_for(1s);
co_return;

}


cppcoro::task<> second(const time_point<high_resolution_clock>& start) {

auto thi = third(start);
co_await thi;

std::cout << "Second waited " << getTimeSince(start) << " seconds."
<< std::endl;
std::this_thread::sleep_for(1s);

}

cppcoro::task<> first(const time_point<high_resolution_clock>& start) {

auto sec = second(start);
co_await sec;

std::cout << "First waited " << getTimeSince(start) << " seconds."
<< std::endl;
std::this_thread::sleep_for(1s);

}

int main() {

std::cout << std::endl;

auto start = ::high_resolution_clock::now();

cppcoro::sync_wait(first(start));

std::cout << "Main waited " << getTimeSince(start) << " seconds."
<< std::endl;

std::cout << std::endl;

}

Du erahnst es wohl schon. Die main-Funktion wartet drei Sekunden, aber jede sukzessiv aufgerufene Coroutine eine Sekunde kürzer.

In zukünftigen Artikeln werde ich Tasks um Threads und Signale erweitern.

generator<T>

In bekannter Manier zitiere ich die Definition eines Generators generator<T> aus cppcoro.

  • A generator represents a coroutine type that produces a sequence of values of type T, where values are produced lazily and synchronously.

Ohne viele Worte zeigt das Programm cppcoroGenerator.cpp zwei Generatoren in Aktion:

// cppcoroGenerator.cpp

#include <iostream>
#include <cppcoro/generator.hpp>

cppcoro::generator<char> hello() {
co_yield 'h';
co_yield 'e';
co_yield 'l';
co_yield 'l';
co_yield 'o';
}

cppcoro::generator<const long long> fibonacci() {
long long a = 0;
long long b = 1;
while (true) {
co_yield b; // (2)
auto tmp = a;
a = b;
b += tmp;
}
}

int main() {

std::cout << std::endl;

for (auto c: hello()) std::cout << c;

std::cout << "\n\n";

for (auto i: fibonacci()) { // (1)
if (i > 1'000'000 ) break;
std::cout << i << " ";
}

std::cout << "\n\n";

}

Die erste Coroutine hello gibt auf Anfrage den nächsten Buchstaben zurück; die Coroutine fibonacci die nächste Fibonaccizahl. fibonacci erzeugt dabei einen unendlichen Datenstrom. Was passiert in der Zeile 1? Die Range-basierte for-Schleife stößt die Ausführung der Coroutine an. Die erste Iteration startet die Coroutine, gibt den Wert b des Ausdrucks co_yield b zurück und legt sich schlafen. Darauf folgende Aufrufe der Range-basierten for-Schleife wecken die Coroutine fibonacci auf und geben die nächste Fibonacccizahl zurück.

Bevor ich diesen Artikel abschließe, möchte ich eine Intuition für den Unterschied von co_wait (Task) und co_yield (Generator) anbieten: co_wait wartet nach innnen, co_yield wartet nach außen. Zum Beispiel wartet die Coroutine first auf die aufgerufen Coroutine second (cppcoroTask.cpp), hingegen wartet die coroutine fibonacci (cppcoroGenerator.cpp), bis sie durch die externe Range-basierte for-Schleife angestoßen wird.

Wie geht's weiter?

Mein nächster Artikel zu cppcoro taucht tiefer in Tasks ein. Ich kombiniere sie mit Threads, Signalen und Thread-Pools.