Reactive Programming im Java Umfeld
Ein kurze Beschreibung von Reactive Programming und eine Übersicht welche Frameworks es gibt und für welche Anwendungsfälle es sich lohnt
Ich durfte 2015 an der Spring I/O teilnehmen. Dort war Spring Reactor, die Reactive Programming Antwort von Spring, noch ein Thema für Early Adoptors. Heute 8 Jahre später wird der Großteil der Spring Boot Anwendungen immer noch mit dem thread-per-request Model (also non-reactive) umgesetzt. Wir stellen hier einmal Reactive Programming im Bereich Java vor und zeigen auf für welche Anwendungen es sich lohnt.
Was ist Reactive Programming?
Um Reactive Programming zu verstehen, sollte man zunächst ein gewöhnliches Programm betrachten, z.B. einen Web-Server. Hier werden Web-Anfragen synchron von Worker-Threads abgearbeitet. Stößt der Worker-Thread dabei auf eine Datenbankanfrage oder muss eine Datei von einem anderen Server herunterladen, dann wartet der Thread auf die Antwort und ist blockiert.
Sind alle Worker-Threads mit blockierenden Anfragen, z.B. Datenbankanfragen, beschäftigt, läuft die Abarbeitung-Queue vom Webserver voll und neue Anfragen laufen in ein Timeout oder können gar nicht mehr beantwortet werden. Es ist also möglich, dass alle Worker-Threads warten (blockiert sind) und quasi nichts tun und trotzdem das System keine Anfragen mehr beantworten kann.
Reactive Programming versucht dieses Problem über Events zu lösen. Das Ganze funktioniert dabei ähnlich, wie bei einer Service-Kraft, die in einem Restaurant über eine Klingel von der Küche informiert wird, dass eine Bestellung fertig ist und nicht vor der Küche warten muss. Die meisten Frameworks setzen dabei auf asynchrone Event-Datenströme („asynchronous data streams“), welche von einem oder mehreren Event-Loops-Threads (z.B. Vert.x) an Worker-Threads verteilt werden.
Vereinfacht kann man sich das Ganze so vorstellen:
- Der Event-Loop verwaltet eine Event-Queue. Hier werden eingehende Events inklusive Event-Type und zugehörige Kontext-Daten abgespeichert.
- Der Event-Loop kontrolliert dabei kontinuierlich die Event-Queue auf neue Events in einer Schleife - daher der Name - zur Abarbeitung.
- Der Event-Loop entfernt das erste Event aus der Event-Queue und bestimmt auf Basis des Event-Typs und der Meta-Daten einen Handler der das Event verarbeiten soll.
- Der Handler bearbeitet dann das Event - im besten Fall - asynchron und schreibt das z.B. über einen Callback zurück in die Event-Queue.
Dabei entstehen häufig die erwähnten Event-Datenströme, d.h. eine Anwendung wird in kleine Tasks (mapping functions) gestückelt, die ein Event als Eingabe in ein anderes Event als Ausgabe überführen. Und der Event-Loop verteilt die einzelen Tasks dann auf verschiedene Worker-Threads. Trifft eine Mapping-Function dann auf einen Datenbank-Aufruf, dann ist dieser im besten Fall selbst wieder reaktiv programmiert, d.h. der Worker-Thread muss nicht auf die Datenbank-Antwort warten, sondern übergibt dem reaktiven Datenbank-Treiber einfach einen Callback und ist sofort wieder frei für neue Arbeit. Bekommt der Datenbank-Treiber dann seine Antwort übergibt er diese direkt der nächsten Funktion oder schreibt sie verpackt als Event in die Event-Queue.
Wieso programmiert man nicht nur Reactive?
Jetzt haben wir also ungefähr eine Idee von Reactive Programming. Jetzt stellt man sich unweigerlich die Frage, warum entwickelt man nicht nur so.
Wie in vielen Bereichen so gilt auch beim reactive programming, dass nichts wirklich kostenfrei ist. Dadurch das eine Anfrage zusätzlich durch das Event-Loop-System wandern muss, ist eine einzelne Anfrage in einer reaktiven Anwendung eher langsamer als schneller verglichen mit dem non-reactive Pendant. Erst unter Last kann ein solches System seine Vorteile wirklich ausspielen, da man blockierende Anfragen vermeidet. Zusätzlich spart man sich das ständige auf- und abbauen von Threads, welches eines der Probleme im thread-per-request model ist. Unter Last wird also die durchschnittliche Abarbeitungszeit in einem reaktiven System mit steigender Last weniger steil steigen, so dass das System mehr Anfragen in einer für den Anwender akzeptablen Zeit liefern kann. Des Weiteren werden so natürlicherweise die CPUs besser ausgelastet, so dass man in einem Hochlast-System Hardware sparen kann.
Auch wenn ein reaktives Programm unter Last in der Regel mehr Anfragen pro Sekunde bedienen kann, skaliert ein solches Programm nicht endlos, d.h. irgendwann ist man doch wieder darauf angewiesen neue Hardware bzw. neue Instanzen des Programms ins Spiel zu springen.
Die Callback-Hölle
Des Weiteren bringen reaktive Frameworks auch noch zusätzliche Konzepte mit sich, die ein gewisses Umdenken bei der Programmierung erfordert. Hier kann man pauschal sagen, dass Entwickler die mit funktionaler Programmierung gut zurecht kommen, sich auch in der reaktiven Programmierung schnell zu recht finden.
Dadurch das asynchron gearbeitet wird, kann eben nicht mit den eigentlichen Antwortobjekten gearbeitet werden, sondern man muss mit Callbacks arbeiten. Durch die Verschachtelung von mehreren Callbacks kommt man schnell in die sog. Callback-Hölle:
doA((resultFromA) => { | |
console.log("doA() abgeschlossen. Mit Ergebnis: ", resultFromA); | |
doB(resultFromA, (resultFromB) => { | |
console.log("doB() abgeschlossen. Mit Ergebnis: ", resultFromB); | |
doC(resultFromB, (resultFromC) => { | |
console.log("doC() abgeschlossen. Mit Ergebnis: ", resultFromC); | |
doD(resultFromC, (resultFromD) => { | |
console.log("Alle Schritte abgeschlossen", resultFromD); | |
}); | |
}); | |
}); | |
}); |
Um der Callback-Hell (oder Callback-Pyramide) zu entkommen, setzen viele Frameworks auf Container-Objekte, die die zukünftige Antwort oder einen Fehler kapseln: bei Spring Webflux sind das z.B. Flux für eine Sequenz von Daten und Mono als Container für genau einen Wert. Diese Container-Objekte ermöglichen es die asynchronen Sequenzen oder Daten mit verschiedenen Funktionen, wie z.B. map
, flatMap
, merge
oder doOnError
zu kombinieren.
Mono<String> result = doA() | |
.map(resultA -> doB(resultA)) | |
.map(resultB -> doC(resultB)) | |
.map(resultC -> doD(resultC)) |
Diese Container heißen in den unterschiedlichen Frameworks anders, mal Observables, Promises oder Futures, dahinter steckt aber immer das gleiche Publisher - Subscriber Prinzip, d.h. ohne Subscriber wird auch nichts geliefert. Man spricht hier häufig von cold observables und hot observables. In dem obrigen Beispiel ist der Mono-Container noch kalt, erst durch subscribe()
wird er heiß und ein Ergebnis würde produziert werden.
Reactive Programming bringt also neue Konzepte mit sich, die über die verschiedenen Programmiersprachen zwar etabliert sind, dennoch ein gewisses Umdenken erfordern.
Für welche Anwendungen lohnt sich Reactive Programming?
Wenn man mal die Komplexität der Programmierung außer Acht lässt, dann kann man grundsätzlich alle Anwendungen reaktiv umsetzen.
Um zu verstehen, welche Anwendungen sich besonders gut eignen reaktiv umgesetzt zu werden, kann man sich nochmals ein Restaurant vorstellen. Der nicht-reaktive Ansatz würde hier also z.B. bedeuten, dann Servicekräfte nach der Bestellung des Gastes vor der Küche warten bis die Bestellung abgearbeitet wurde. Wenn jetzt ein Restaurant 10 Service-Kräften und 10 Köche hat und es kommen nie mehr als 9 Gäste gleichzeitig ins Restaurant, dann ist das kein Problem. Kommen hier aber schon die 20 Gäste, hat man ein großes Problem, wenn man nicht asynchron arbeitet.
Spring Boot in Verbindung mit Tomcat z.B. reserviert in der Standardkonfiguration 200 Threads für die Abarbeitung von Anfragen (thread-per-request model). Um in einen Zustand zukommen, in dem Anfragen nicht mehr beantwortet können, benötigt es schon ein relativ viele Requests pro Sekunde. Stellt man sich also eine Datenbank in einem solchem System vor, welche alle Anfragen in max. 200ms beantwortet. Dann kommt man rechnerisch auf 1000ms / 200ms = 5 Requests pro Sekunde für einen Thread. Macht theoretisch 1000 Request pro Sekunde für das Gesamtsystem, selbst wenn man mit 30% Overhead durch das Framework rechnen würde, sind das immer noch 700 Requests pro Sekunde. Klar spielen hier Faktoren wie Anzahl der CPUs und die Gesamtinfrastruktur ein große Rolle. Zu dem sind Rechner nicht teuer, d.h. kommt man wirklich an die Grenzen, dann kann man sich recht schnell einen weiteren Service daneben stellen, vor allem wenn man seinen Service bei einem Cloud-Anbieter betreibt.
Festhalten lässt sich also, das reaktive Anwendung sich immer dann lohnen wenn: - Resourcen knapp sind, d.h. man kann nicht gut mit Hardware skalieren, z.B. ein IoT-Device, welches viele Sensordaten verarbeiten soll - Effizient gearbeitet werden soll, z.B. ein Service läuft auf einer VM und soll die maximal möglichen Req./s abarbeiten - Anfragen nicht schnell abgearbeitet werden können, z.B. ein Service der einen weit entfernten Webservice anfragen muss und ggf. mehrere Sekunden auf eine Antwort warten muss. - Echtzeitdaten angezeigt werden sollen, z.B. eine Aktienticker App, die viele User per Push über Preisänderungen informieren soll
Eine Besonderheit, die noch erwähnt werden soll, sind Anwendungen bei denen die Push-Nachrichten an z.B. ein Frontend geschickt werden sollen. Eine solche Anwendung setzt eine ständige Verbindung zwischen Client und Server voraus, z.B. über WebSockets. Hier stößt man mit einem thread-per-request model sehr schnell an Grenzen, weshalb sich reactive Programming besonders gut eignet.
Welche Frameworks existieren für Java?
Für Java-Entwickler existieren inzwischen einige Reactive Frameworks, wie z.B.:
- RxJava, die ReactiveX Referenz-Implementation für Java
- Kotlin Flow, JetBrains reactive Framework, stark inspiriert von Reactive Streams
- Vert.x, die Antwort von Eclipse für Reactive applications
- Spring Project Reactor, hier zu erwähnen ist sicherlich Spring WebFlux, als reactives Web-Framework im Spring-Kosmos
- akka, ein Toolkit für Reactive Applications von der Firma Lightbend
- Ratpack, ein Open-Source-Framework gebaut auf Java und Nettys Event getriebener Netzwerk-Engine
Die Wahl eines Frameworks hängt von verschiedenen Kriterien ab und muss individuell bestimmt werden. Bei allen Fragen rund um reactive Programming oder der Wahl eines Frameworks können wir Sie sehr gerne unterstützen: info[at]youniverse.com
Fazit
Reactive Programming hat sicherlich seine Anwendungsgebiete, allerdings stoßen auch diese Anwendungen irgendwann an ihre Grenzen und man kommt nicht drumherum mit Hardware zu skalieren. Da nimmt der Großteil es einfach hin, dass er etwas früher einen weiteren Knoten in seinen Cluster integrieren muss, als dass er sich ein reaktives Framework ans Bein bindet.
Wenn Websockets eine Rolle spielen oder für IoT-Geräte, die mit Java entwickelt werden sollen, dann sind Reactive Frameworks sicherlich sehr interessant. Für "normalen" CRUD-Backend-Services, die nicht auf Ressourcenoptimierung primär angelegt sind, wird man wohl entweder eine von Haus aus reactive Programmiersprache setzen oder bleibt eben beim thread-per-request-Modell.
Man muss jedoch sagen, dass durch Kotlin Flow, Spring Webflux und R2DBC es noch nie einfacher war mit Java bzw. Kotlin einen reaktiven Webservice aufzusetzen.