Koroutinen: Weniger warten, asynchron arbeiten
Moderne verteilte Systeme erfordern eine asynchrone Verarbeitung, um lange Wartezeiten beim Zugriff auf weit entfernte Daten mit anderen Aufgaben sinnvoll zu überbrücken. Dieser Artikel erklärt, wie Koroutinen dabei helfen.
- Jörn Dinkla
Services und die zugehörigen Daten sind in modernen IT-Architekturen in der Regel verteilt organisiert und über Netzwerke miteinander verbunden. Mit jedem Zugriff auf entfernte Daten ist daher eine Wartezeit (Latenz) verbunden, die in der traditionellen Programmierung jedoch unberücksichtigt blieb. Beim Aufruf einer Funktion innerhalb eines Programms wird erwartet, dass sie bei der Rückkehr die Berechnung abgeschlossen hat und das fertige Ergebnis ausliefert.
Die Welt ist asynchron, Programmiersprachen traditionell aber nicht
Im folgenden Beispiel ist httpClient.send() ein Funktionsaufruf und das Programm wartet auf die Beendigung der Funktion, bevor der Ablauf mit der nächsten Anweisung in der nächsten Zeile fortgesetzt wird.
val response: HttpResponse<String> = httpClient.send("https://swapi.co/api/planets/")
val planets: List<Planet> = parse(response.body())
Der Programmablauf erreicht die Zeile mit planets also erst, wenn der angesprochene HTTP-Server geantwortet hat. Die Abarbeitung der Funktion erfolgt synchron. Dieses Beispiel ist in der Programmiersprache Kotlin verfasst. Als noch recht junge objekt-funktionale JVM-Programmiersprache hat Kotlin einige spezielle Syntaxeigenheiten. Im folgenden Pseudocode ist beispielhaft eine Konstante val (AbkĂĽrzung von "value") mit dem Datentyp Typ mit dem Ergebnis des angegebenen Ausdrucks definiert.
val name: Typ = Ausdruck
Variablen, die auch wieder geändert werden können ("mutable"), sind in Kotlin nicht als val, sondern mit dem Schlüsselwort var definiert.
To block or not to block
Typischerweise stellt ein Betriebssystem Funktionen zur DurchfĂĽhrung von IO bereit und Programmiersprachen wie Java oder Kotlin greifen ĂĽber die Java Virtual Machine (JVM) darauf zu.
Wenn ein Thread eine IO-Funktion durchführen möchte, ist es am einfachsten, auf die Beendigung des IOs zu warten. Der Thread ist so lange blockiert – daher spricht man in diesem Fall von "blockierendem IO".
Im obigen Beispiel dauert die Thread-Blockade innerhalb der Funktion httpClient.send, bis das Ergebnis vom Server zurĂĽckgekommen ist. Dieses Verhalten zieht jedoch erhebliche Nachteile bei der Skalierung des Programms mit sich, wenn beispielsweise viele IO-Requests gleichzeitig durchzufĂĽhren sind:
- Der Scheduler des Betriebssystems könnte einen anderen Thread ausführen. Das Wechseln von Threads benötigt allerdings ebenfalls Zeit und bringt Performanceeinbußen mit sich, weil der Kontext des geblockten Threads gespeichert und der des neu angefangenen Threads wiederhergestellt werden muss.
- Darüber hinaus belegt jeder Thread Speicherressourcen des Betriebssystems, die beim Blockieren brachliegen und nicht genutzt werden können. Bei der JVM sind es bis zu 2 MByte pro Thread.
Auch die Erstellung eines Threads und dessen Beendigung benötigen Zeit zur Synchronisation mit dem Betriebssystem. Daher ist es aus Performancegründen ratsam, einen sogenannten Thread-Pool anzulegen. Dabei handelt es sich um eine Gruppe fertiger Threads, denen ein eigener Scheduler Aufgaben zuweist. Allerdings hilft auch ein Thread-Pool mit k Threads nichts, wenn diese alle gerade blockierenden IO durchführen. Daher ist es wichtig, die Größe des Thread-Pools richtig zu wählen.