Coroutinen

Inhaltsverzeichnis[Anzeigen]

Coroutinen sind Funktionen, die ihren Ablauf unterbrechen und wieder aufnehmen können und dabei ihren Zustand behalten. Die Evolution in C++20 geht einen deutlichen Schritt weiter.

Was ich in diesem Artikel als neues Konzept in C++20 verkaufe, ist tatsächlich ein alter Hut. Der Begriff Coroutine stammt von Melvin Conway, der ihn 1963 in einer Veröffentlichung über Compilerbau. Donald Knuth bezeichnet Prozeduren als Spezialfall von Coroutinen. Manchmal dauert es einfach ein bisschen länger.

Obwohl ich Coroutinen bereits von Python kenne, fand ich es relativ anspruchsvoll, die neuen Konzepte in C++20 wiederzufinden. Bevor ich daher auf weitere Details zu Coroutinen eingehen, gibt es eine erste Berührung.

Eine erste Berührung

Mit den neuen Schlüsselwörtern co_await und co_yield verallgemeinert C++20 den Ablauf einer Funktion um zwei neue Aspekte.

Dank co_await expression ist es möglich, die Ausführung des Ausdrucks expression zu unterbrechen und später wieder aufzunehmen. Wird co_await expression in einer Funktion func verwendet, ist ein Aufruf der Form auto getResult= func() nicht zwangsläufig blockierend, wenn das Ergebnis der Funktion noch nicht zur Verfügung steht. Ein ressourcenintensives Blockieren lässt sich in ein ressourcenschonendes Warten umformulieren.

co_yield expression erlaubt es, eine Generator-Funktion zu schreiben. Diese Generator-Funktion liefert auf jede Anfrage den nächsten Wert. Eine Generator-Funktion verhält sich wie ein Datenstrom, aus dem sukzessive die Werte abgefragt werden können. Dabei kann der Datenstrom auch unendlich sein. Damit sind wir mitten in der Bedarfsauswertung mit C++.

Ein einfaches Beispiel

Das Programm ist denkbar einfach. Die Funktion getNumbers gibt alle ganzen Zahlen von begin bis end um inc inkrementiert zurück. Dabei muss begin kleiner als end und inc positiv sein.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// greedyGenerator.cpp

#include <iostream>
#include <vector>

std::vector<int> getNumbers(int begin, int end, int inc= 1){
  
  std::vector<int> numbers;
  for (int i= begin; i < end; i += inc){
    numbers.push_back(i);
  }
  
  return numbers;
  
}

int main(){

  std::cout << std::endl;

  auto numbers= getNumbers(-10, 11);
  
  for (auto n: numbers) std::cout << n << " ";
  
  std::cout << "\n\n";

  for (auto n: getNumbers(0,101,5)) std::cout << n << " ";

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

}

 

Mit getNumbers erfinde ich das Rad natürlich neu, den für diesen Job gibt es seit C++11 std::iota. Nur zur Vervollständigung die Ausgabe des Programms.

greedyGenerator

Das Entscheidende an dem Programm ist natürlich das Ergebnis. Zwei Punkte sind mir besonders wichtig. Zum einen wird der Vektor numbers in Zeile 8 immer vollständig gefüllt. Das trifft auch zu, wenn ich nur an den erste fünf Elementen eines Vektors mit 1000 Elementen interessiert bin. Zum anderen lässt sich der getNumbers direkt in einen Generator umschreiben.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// lazyGenerator.cpp

#include <iostream>
#include <vector>

generator<int> generatorForNumbers(int begin, int inc= 1){
  
  for (int i= begin;; i += inc){
    co_yield i;
  }
  
}

int main(){

  std::cout << std::endl;

  auto numbers= generatorForNumbers(-10);
  
  for (int i= 1; i <= 20; ++i) std::cout << numbers << " ";
  
  std::cout << "\n\n";

  for (auto n: getForNumbers(0, 5)) std::cout << n << " ";

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

}

 

Während die Funktion getNumbers in der Datei greedyGenerator.cpp einen std::vector<int> zurück gibt, gibt die Coroutine getGeneratorNumbers in der Datei lazyGenerator.cpp einen Generator zurück. Der Generator numbers in Zeile 18 oder getForNumbers(0,5) geben auf Anfrage eine neue Zahl zurück. Diese Anfrage wird durch die Range-based for-Schleife angestoßen. Dabei stößt die Anfrage die Coroutine an, gibt den aktuellen Wert i mittels co_yield i zurück und pausiert anschließend. Wird der nächste Wert angefragt, setzt die Coroutine genau an dieser Stelle ihre Arbeit fort.

Der Ausdruck getForNumbers(0, 5) in Zeile 24 mag befremdlich wirken. Dadurch wird ein Generator an Ort und Stelle verwendet.

Auf eine Besonderheit will ich ganz explizit hinweisen. Die Coroutine generatorForNumbers erzeugt einen unendlichen Datenstrom, denn die for Schleife in Zeile 8 besitzt keine Endbedingung. Alles kein Problem, solange ich nur endlich viele Werte von dem Generator wie in Zeile 20 anfordere. Das gilt natürlich nicht für die Zeile 24. Hier verwende ich keine Endbedingung.

 Null will ich - wie versprochen - noch ein paar Details nachliefern. Dabei werde ich insbesondere die Fragen beantworten:

  • In welchen Anwendungsfällen werden Coroutinen gerne eingesetzt?
  • Welche Konzepte setzen Coroutinen um?
  • Welche Design Ziele wurden mit Coroutinen verfolgt?
  • Durch was wird eine Funktion zur Coroutine?
  • Was zeichnet die zwei neuen Schlüsselwörter co_wait und co_yield aus?

Mehr Details

Zuerst zur einfachsten Fragen.

In welchen Anwendungsfällen werden Coroutinen gerne eingesetz?

Coroutinen sind eine natürliche Art Event-getriebene Applikationen zu schreiben. Das können Simulationen, Spiele, Server, Guis oder einfach nur Algorithmen sein. Sie werden auch gerne für kooperatives Multitasking eingesetzt. Beim kooperativen Multitasking nehmen sich die Tasks soviel Zeit heraus, wie sie benötigen. Dem entgegen steht das präemptive Multitasking. Hier entscheidet ein zentraler Scheduler, wie lange jede Task die CPU erhält.

Coroutinen gibt es in verschiedene Variationen.

Welche Konzepte setzen Coroutinen um?

Coroutinen in C++20 sind asymmetrisch, First-Class Coroutinen und stackless. 

Bei einer asymmetrisch Coroutine kann der Kontrollfluss nur wieder zum Aufrufer zurückgehen. Eine symmetrische Coroutine muss nicht zum Aufrufer zurückspringen. Sie kann sie zum Beispiel den Kontrollfluss an eine andere Coroutine weitergeben.

First-Class Coroutinen sind in Anlehnung and First-Class Funktionen Coroutinen, die sich wie Daten verhalten. Das heißt, sie können an Funktionen übergeben oder von Funktionen zurückgegeben werden. Natürlich können sie auch in eine Variable gespeichert werden.

Eine stackless Coroutine erlaubt es nur, die Top-Level Coroutine zu unterbrechen. Diese Coroutine kann hingegen nicht selber eine weitere Coroutine aufrufen.

Das Proposal n4402 bringt die Vorteile Design Ziele von Coroutinen schön auf den Punkt.

Welche Design Ziele wurden mit Coroutinen verfolgt?

Coroutinen sollen

  • hoch skalierbar sein (Bis zu Milliarden von gleichzeitigen Coroutinen).
  • hoch effizient ihren Ablauf unterbrechen und wieder aufnehmen können (Vergleichbar zu einem gewöhnlichen Funktionsaufruf).
  • sich nahtlos in bestehende Applikationen integrieren lassen. So sieht ein Aufruf einer Coroutine nicht, dass der Aufruf tatsächlich ein Aufruf einer Coroutine ist. Dies zeigt schön der Aufruf des Generators in der main-Funktion des einführenden Beispiels.  
  • eine offene Architektur anbieten, so dass sie es ermöglichen, Coroutinen Bibliotheken zu implementieren, die höherer Abstraktionen anbieten.
  • auch in Umgebungen verwendet werden können, in den Ausnahmen nicht erlaubt sind oder verfügbar stehen.

Es gibt vier Gründe, warum eine Funktion ein Coroutine wird.

Durch was wird eine Funktion zur Coroutine?

Eine Funktion wird zu Coroutine, wenn sie einen

  • co_return Ausdruck verwendet,
  • co_await Ausdruck verwendet,
  • co_yield Ausdruck verwendet,
  • oder ein co_await Ausdruck in einer Range-bases for-Schleife verwendet.

Die Antwort auf die Frage gibt der Proposal n4628.

Zum Abschluss noch die neuen Schlüsselwörter co_return, co_yield und co_await.

co_return, co_yield und co_await

co_return: Duch co_return verlässt die Coroutine ihren Funktionskörper.

co_yield: Dank co_yield lässt sich ein Generator schreiben. Damit kann die Coroutine wie im Beispiel lazyGenerator.cpp eine unendlichen Datenstrom zurückgeben, dessen Elemente sukzessive angefordert werden. Der Rückgabetyp der Coroutine generator<int> generatorForNumbers(int begin, int inc= 1) ist in diesem konkreten Fall generator<int>. generator<int> hält intern einen speziellen Promise p, so dass ein Aufruf der Form co_yield i äquivalent zu einem Aufruf der Form co_await p.yield_value(i) ist. Der Aufruf von co_yield i ist beliebig oft möglich. Unmittelbar nach jedem Aufruf wird der Programmfluss der Coroutine unterbrochen.

co_await: co_await führt in einer Coroutine dazu, dass ihr Programmfluss eventuell nur einmal angehalten und wieder aufgenommen wird. In dem Ausdruck co_await e muss e ein sogenannter awaitable Ausdruck sein. Als awaitable Ausdruck muss er ein vorgegebenes Interface unterstützen, dass insbesondere aus den drei Methoden e.await_ready, e.await_suspend und e.await_resume besteht.

Das klassische Beispiel zu co_await ist ein Server, der blockierend auf seine Events wartet.

1
2
3
4
5
6
7
Acceptor acceptor{443};
while (true){
  Socket socket= acceptor.accept();              // blocking
  auto request= socket.read();                   // blocking
  auto response= handleRequest(request);     
  socket.write(response);                        // blocking  
}

 

Der Server ist denkbar einfach, denn er beantwortet seine Anfragen sequentiell im gleichen Thread. Dabei lauscht er auf den Port 443 (Zeile 1), nimmt Verbindungen an (Zeile 3), liest die ankommenden Daten vom Client (Zeile 4) und schreibt seine Antwort an den Client zurück (Zeile 6). Die Aufrufe in den Zeile 3, 4 und 6 sind blockierend.

Dank drei co_await Ausdrücken werden aus den blockierenden Aufrufen Aufrufe, die unterbrochen und gegebenenfalls wieder aufgenommen werden.

1
2
3
4
5
6
7
Acceptor acceptor{443};
while (true){
  Socket socket= co_await acceptor.accept();           
  auto request= co_await socket.read();              
  auto response= handleRequest(request);     
  co_await socket.write(responste);                 
}

Wie geht's weiter?

Transactional Memory basiert auf der Idee der Transaktion aus der Datenbanktheorie. Dabei ist eine Transaktion eine Aktion, die sich durch die Eigenschaften Atomicity, Consistency, Isolation und Durability (ACID) auszeichnet. C++20 kennt Transactional Memory. Genau darum geht es im nächsten Artikel.

 

 

 

 

 

 

 

 

title page smalltitle page small Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library".   Hole dir dein E-Book. Unterstütze meinen Blog.

Tags: C++20

Kommentar schreiben


Abonniere den Newsletter (+ pdf Päckchen)

Beiträge-Archiv

Sourcecode

Neuste Kommentare