Reactive Programming, Virtual Threads und Native Images in unserer MaaS Plattform
Latenz und Performance sind wichtige Metriken im Bereich von Mobility as a Service. Wenn User mit Autos interagieren oder auf einer Karte nach Ladestationen suchen, muss die Zeit zwischen Client-Anfrage und Server-Antwort minimal bleiben. Dies erhöht die User Experience, aber auch die Bedienbarkeit der Client-Applikation immens. Der Wunsch, User Experience und Bedienbarkeit stetig zu erhöhen, war der Grund warum wir mit Reactive Programming und Native Images im Backend experimentiert haben. Diese Technologien sollen die eingangs erwähnten Metriken verbessern.
Wenn ein User beispielsweise ein Auto laden möchte, muss das Service zwei Dinge ausführen:
- Daten von einem externen Service beziehen, z.B.
- Informationen über die Ladesäule
- Preise, um den Ladevorgang später verrechnen zu können
- Einen Datenbank-Eintrag für den Ladevorgang erstellen
Beide Aktionen sind von einer externen Entität abhängig, sei es die Datenbank, oder ein anderer Service. Wenn im Backend ein HTTP-Request an einen externen Service abgeschickt wird, wartet ein Thread darauf, dass eine Antwort zurückkommt. Das klingt fürs Erste nur halb so wild, bei genauerer Betrachtung fällt allerdings auf, dass dabei Computer-Ressourcen blockiert werden. Dies limitiert folglich die Skalierbarkeit eines Services. Dasselbe tritt bei Datenbankabfragen ein: Das Service sendet die Anfrage und wartet bis die Operation vollendet ist. Solche Art von Interaktionen werden meist I/O-Aufgaben genannt.
I/O-Threads können die Skalierbarkeit limitieren
Es gibt keine offizielle Definition von I/O-Threads. Meistens wird unter diesem Begriff verstanden, dass Threads von oder zu einem Ort Daten lesen oder schreiben und dann auf eine Bestätigung der Ausführung warten.
Diese I/O-Threads warten also die meiste Zeit nur; sie sind nicht sehr CPU-intensiv. Dennoch werden, vor allem wenn sich mehrere Anfragen gleichzeitig häufen, entsprechend viele Threads benötigt. Hierbei wird für jede Anfrage entweder ein neuer Thread erstellt oder es wird einem Thread aus einem Pool diese Aufgabe zugeteilt.
Um mit den vielen Threads, die durch eine große Menge an Anfragen innerhalb eines kurzes Zeitfensters notwendig werden, umzugehen, braucht man sowohl CPU- als auch Arbeitsspeicherressourcen. Eine große Parallelität an I/O-Anfragen erfordern dann z.B., dass eben diese Ressourcen für das Service erhöht werden. Dabei handelt es sich um klassisches, vertikales Skalieren.
(Anmerkung: Im Gegensatz zu den Ressourcen-armen I/O-Threads, gibt es natürlich auch sehr CPU-intensive Threads. Im Allgemeinen kann hier nicht davon ausgegangen werden, dass das erhöhen der Threadanzahl zu einer schnelleren Abarbeitung führt. Dies hängt stark mit der Parallelisierbarkeit des Programmcodes zusammen, wodurch man viel schneller an die Performance-Grenzen eines Services gelangen könnte.)
Reactive Programming im Kontext moderner Virtual Threads
Das Hauptziel von Reactive Programming ist es, asynchrone Programmabläufe ohne dem großen Overhead von Thread-Management zu ermöglichen. Obwohl sich die Situation mit Java 19 (bzw. Java 21 im Long Term Support) und der Einführung von Virtual Threads verbessert hat, ist Reactive Programming immer noch eine relevante Alternative, die eigene Vorteile mit sich bringt. Virtual Threads ermöglichen, vereinfacht gesagt, eine große Menge I/O-Threads abzuarbeiten, ohne dass hinter jedem Virtual Thread ein echter, ressourcenfressender Betriebssystem-Thread steckt (mehr dazu kann in den großartigen Blog Posts von Spring oder InfoWorld gelesen werden; siehe auch die vorhergehende Abbildung). Auch Reactive Programming bringt diesen Vorteil mit sich, aber, wie versprochen, auch weitere.
Zunächst kommt mir da das Thema “Backpressure” in den Sinn. Üblicherweise kann ein Service nur begrenzt viele Ressourcen in einem gewissen Zeitraum verarbeiten. Wenn also nun von einem anderen Service, zu viele Daten zur Verarbeitung geschickt werden, kommt die gesamte Verarbeitungskette ins Stocken. Mittels Reactive Programming kann ein Service seinen Datenquellen mitteilen, wie viele Daten es verarbeiten kann, um nur eine entsprechende Menge zugesandt zu bekommen. Dadurch lassen sich Pipelines besser optimieren.
Als zweiter großer Vorteil fällt mir das gleichzeitige Senden von Requests ein. Anstatt auf den vorhergehenden Request zu warten, bevor der nächste abgeschickt wird, können mehrere Requests gleichzeitig gesendet werden. Dadurch verkürzt sich die Gesamtwartezeit auf die Dauer des langsamsten Requests. Es muss nicht mehr Schritt für Schritt auf jeden Request gewartet werden. Virtual Threads unterstützen dies zwar mittlerweile ebenso, aber bieten nur einen Bruchteil der Konfigurationsmöglichkeiten wie z.B. bei Project Reactor, einem Framework für Reactive Programming (diese Technologie ist, wie Spring Boot, Teil von VMWare Tanzu und wird entsprechend von ihnen empfohlen).
Zuletzt gibt es noch eine Sache, die zumindest aus meiner persönlichen Sicht ein großer Vorteil ist: Der Code wird eher funktional gehalten, wodurch die Lesbarkeit und die Testbarkeit stark erhöht ist im Vergleich zur typischen objektorientierten Programmierung.
Nun darf man allerdings nicht außer Acht lassen, dass Reactive Programming auch Nachteile hat. Zum Beispiel ist die Lernkurve von Project Reactor oder RxJava doch recht steil. Man erhält zwar viele nützliche Features um alles aus asynchroner Abarbeitung herauszuholen, benötigt aber auch viel Einarbeitungszeit. Man muss bei Reactive Programming nämlich nicht nur den üblichen Business-Teil verstehen, sondern auch das korrekte Subscription-Handling.
Letzten Endes muss es aber auch nicht zwangsläufig entweder Reactive Programming oder Virtual Threads heißen, auch kombinierte Ansätze sind möglich. Normalerweise steckt hinter Reactive Programming ein Thread-Pool, der eine limitierte Anzahl an Threads hat welchen ein Dispatcher Aufgaben zuweist. Mit Virtual Threads kann ein Dispatcher dann viel mehr Aufgaben an dieselbe Anzahl an Threads verteilen, ohne dass man eigens aufwändig Logik dazu bauen muss.
Welcher Ansatz für konkrete Projekte der sinnvollste ist, kann sich üblicherweise erst zeigen, nachdem man sich mit Reactive Programming ein wenig auseinander gesetzt hat. Ist aber einmal die richtige Benützung von Reactive Programming verstanden und der Code ordentlich strukturiert, kann man im Detail evaluieren, ob der Aufwand den Nutzen übersteigt.
Erfahrungen mit Native Images
Skalierbarkeit bedeutet oft sorgsamer Umgang mit Ressourcen. Dabei ist sparsames Thread-Management nicht die einzige Möglichkeit um Ressourcen zu schonen. So kann z.B. ein Augenmerk darauf gelegt werden, wie bestehende Server-Ressourcen auf unterschiedliche Services aufgeteilt werden. Beispielsweise benötigt eine übliche Spring Boot Applikation vor allem beim Starten sehr viel CPU, während dem laufenden Betrieb aber nur mehr einen Bruchteil davon. Einem Spring Boot Service also viele Computing-Ressourcen zuzuweisen sorgt zwar für einen schnellen Start, danach verbleiben die Ressourcen jedoch ungenutzt.
Sogenannte “Native Images” lösen dieses Problem. Dadurch brauchen Spring Boot Services auch beim Starten wenig CPU-Ressourcen. Dadurch können dem Service genauso viele Ressourcen wie für den Betrieb notwendig zugewiesen werden, aber man hat trotzdem schnelle Start-Up-Zeiten. Diese vertauscht man sich durch langsamere Build-Zeiten des Native Images. Im Gegensatz zu normalen Images brauchen Native Images viel mehr CPU-Ressourcen um sie zu Bauen. Die Startup-Zeit reduziert sich also, aber die Build-Zeit erhöht sich. Da Builds hingegen seltener passieren als Startups, und die oben angesprochenen Ressourcen-schonenden Maßnahmen ermöglicht werden, können Native Images ein hilfreiches Mittel sein.
Mit Native Images gehen aber oft andere Schwierigkeiten einher. Beispielsweise ist die Kompatibilität mit anderen Libraries, auf denen ein Service aufbaut, oft nicht gegeben. In unseren eigenen Projekten haben wir deshalb bereits das ein oder andere Mal Zeit investieren müssen, um die reflection-config.json zu adaptieren und zu hoffen, dass die Applikation zur Laufzeit richtig funktioniert. Dies kann ein mühsamer Trial-and-Error-Prozess sein.
Unsere Erfahrung zeigt allerdings, dass Native Images sehr gut mit dem Spring Cloud Projekt funktioniert und es auf jeden Fall wert ist, die erhöhte Build-Zeit in Kauf zu nehmen. In eher üblichen Applikationen, die viele Datenbankoperationen durchführen und gleichzeitig wenig Business-Logik haben oder sehr abhängig von Libraries sind, welche GraalVM nicht unterstützen, ist die Umstellung unserer Meinung vermutlich nicht wert. Allerdings haben wir bislang selbst auch noch keine bestehenden Services auf Native Images umgestellt, weshalb wir bei Umstellungen wenig Erfahrungswerte aus der Praxis haben.
Performance messen
Wir haben zwei neue Services geschrieben welche Reactive Programming, Native Images und Virtual Threads benutzen und konnten auch in der Praxis feststellen, dass Builds viel länger brauchen, dafür aber sehr viel weniger Ressourcen im laufenden Betrieb notwendig sind. Diese zwei Services verbrauchen nur mehr ca. 20% der CPU und des RAMs wie andere Services ohne Verlust an Performance. Allerdings benötigen die Builds dieser Services nun doppelt so lange.
Weiters haben wir auch Lasttests für die meisten unserer Services durchgeführt und dabei sind mir überraschende Ergebnisse untergekommen: Reactive Programming hat nicht sehr viel zur Performance beigetragen. Die beste Performance konnte erreicht werden, wenn wir Virtual Threads und Native Images kombinierten (egal ob das Service Reactive Programming nutzt oder nicht).
Fazit
Virtual Threads sind super einfach zu aktivieren und helfen auf jeden Fall bei I/O-Threads, wie sie bei Webservern häufig verwendet werden. Native Images und Reactive Programming haben ihre Vor- und Nachteile und müssen deshalb für jeden konkreten Use-Case evaluiert werden. Wenn die Vorteile überwiegen, dann kann die Umstellung darauf auf jeden Fall sinnvoll sein.
In unseren eigenen Projekten werden wir auf jeden Fall Native Images weiterhin nutzen, machen es aber davon abhängig, welche Libraries wir verwenden möchten und wie gut diese die GraalVM unterstützen. Virtual Threads werden bei jedem unserer Webserver aktiviert werden.
Wir sehen uns im nächsten Blogpost!