Kolumne EnterpriseTales

Die Jagd nach Performance: Ist Reactive Programming der richtige Weg?

Arne Limburg
Funktionale Programmierung

Reactive Programming verspricht eine höhere Performance von Enterprise-Java-Anwendungen bei geringerem Speicherbedarf. Erreicht wird dieses Versprechen, indem blockierende Aufrufe vermieden werden. Blockierende Aufrufe führen im Betriebssystem immer zu Prozess- und damit zu Kontextwechseln. Solche Kontextwechsel haben einen hohen CPU- und Speicher-Overhead. Dieser Overhead wird durch weniger Kontextwechsel reduziert. Erkauft wird dieser Performancegewinn von Reactive Programming allerdings durch schlechtere Wartbarkeit der Software. Ist die höhere Performance aber diesen Preis wert, und was sind die Alternativen? Dieses Thema wollen wir uns einmal genauer anschauen.

In den Anfängen von Java war die Threading-Abstraktion ein großer Pluspunkt gegenüber den anderen damaligen Programmiersprachen. Sie bietet den Entwicklern noch heute leichten Zugriff auf parallele Programmierung und Synchronisation. Web-Frameworks konnten damals auf dieser Basis sehr leicht implementiert werden, da durch die Bindung eines Web-Requests an einen Thread im Servlet-API die Abarbeitung eines Requests quasi imperativ realisiert werden konnte, ohne sich um Nebenläufigkeit und Synchronisation zu kümmern. Bevor es Tabbed Browsing und Ajax gab, konnte man sich auch quasi per (Webseiten-)Design sicher sein, dass niemals zwei Requests derselben User-Session parallel ausgeführt wurden. Dadurch musste sich der normale Entwickler auch auf User-Session-Ebene praktisch keine Gedanken um parallele Verarbeitung machen.

Die oben erwähnte Realisierung der Java-Threading-Abstraktion hat aktuell allerdings einen gravierenden Nachteil: Java-Threads sind als Betriebssystemprozesse realisiert, sodass jeder Threadwechsel einen (sehr teuren) Kontextwechsel im Betriebssystem bedeutet. Zu den Zeiten, als Webapplikationen nur mehrere tausend Requests pro Minute verarbeiten mussten, war das noch kein Problem. Mittlerweile sind die Anforderungen an Webapplikationen allerdings deutlich höher. Steigende Nutzerzahlen und interaktivere SPAs (mit mehr Client-Server-Kommunikation) sorgen dafür, dass eine heutige Enterprise-Applikation deutlich mehr Requests pro Minute verarbeiten muss als noch vor fünfzehn Jahren. Das Modell, bei dem ein Request von einem Betriebssystemthread abgearbeitet wird, der womöglich zwischendurch noch blockiert, wenn zum Beispiel eine Datenbankabfrage abgesetzt oder ein weiterer Microservice aufgerufen wird, stößt an seine Grenzen.

W-JAX 2019 Java-Dossier für Software-Architekten

Kostenlos: Java-Dossier für Software-Architekten 2019

Auf über 30 Seiten vermitteln Experten praktisches Know-how zu den neuen Valuetypes in Java 12, dem Einsatz von Service Meshes in Microservices-Projekten, der erfolgreichen Einführung von DevOps-Praktiken im Unternehmen und der nachhaltigen JavaScript-Entwicklung mit Angular und dem WebComponents-Standard.

 

Der Grad der Parallelität ist deutlich höher als zu den Zeiten, als die Entscheidung getroffen wurde, Java-Threads als Betriebssystemprozesse zu realisieren. Dafür sind die Requests beziehungsweise der darin ausgeführte Code mittlerweile deutlich kürzer. Das passt nicht zu den teuren Kontextwechseln der Betriebssystemprozesse.

Hier setzt Reactive Programming an. Das Paradigma ist genau entgegengesetzt zum Java-Threading-Modell. Während das Threading-Modell versucht, Asynchronität vom Benutzer fernzuhalten („Alles passiert in einem Thread“) ist bei Reactive Programming die Asynchronität quasi das Prinzip. Der Programmablauf wird als eine Sequenz von Ereignissen angesehen, die natürlich asynchron auftreten können. Jedes dieser Ereignisse wird von einem Publisher veröffentlicht. Auf welchem Thread der Publisher das tut, ist dabei unerheblich. Der Programmcode besteht in einer reaktiven Anwendung aus Funktionen, die auf diese asynchrone Veröffentlichung von Ereignissen hören, sie verarbeiten und gegebenenfalls neue Ereignisse veröffentlichen.

Dieses Vorgehen ist vor allem dann sinnvoll, wenn mit externen Ressourcen wie z. B. einer Datenbank gearbeitet wird. Bei einer klassischen Enterprise-Java-Anwendung würde auf ein abgesetztes SQL Statement blockierend gewartet, bis die Datenbank das Ergebnis liefert. Bei Reactive Programming ist es so, dass das Statement abgesetzt wird, ohne auf das Ergebnis zu warten. Die Methode zum Absetzen der Datenbankabfrage liefert stattdessen unmittelbar (also ohne zu blockieren) einen Publisher zurück, auf den sich der Aufrufer registrieren kann, um informiert zu werden, wenn das Datenbankergebnis verfügbar ist. Das Datenbankergebnis wird dann später als Ereignis auf dem Publisher veröffentlicht. Dieses API ist eine sinnvolle Alternative zu den Callback-Höllen, die sonst bei asynchroner Programmierung üblich sind.

Der Vorteil von Reactive Programming liegt also darin, dass eine Entkopplung von auszuführendem Code und dem ausführenden Thread entsteht. Damit gibt es weniger teure Kontextwechsel auf Betriebssystemebene.

Noch essenzieller ist ein solches Vorgehen bei Serverarchitekturen, in denen es nur genau einen Thread gibt, der den Applikationscode verarbeitet, wie das bei NodeJS der Fall ist. Wenn dieser blockiert ist, steht der ganze Server still und kann keine Requests mehr verarbeiten. Daher wird in JavaScript jeder Call von vornherein asynchron realisiert. Ein Reactive API oder eine andere sinnvolle Abstraktion für die sonst entstehende Callback-Hölle ist da also auf jeden Fall sinnvoll.

Reactive Programming hat allerdings, wie einleitend angedeutet, ein paar gravierende Nachteile. Die Entkopplung von geschriebenen Funktionen und ausgeführtem Code führt zu einem erhöhten Schwierigkeitsgrad beim Lesen und Schreiben des Codes. Außerdem ist es kompliziert, Unit-Tests für solch asynchronen Code zu schreiben. Noch schwieriger wird es, den Code zu debuggen.

Weitergehende Probleme entstehen bei der Integration in klassische Enterprise-Anwendungen. Dort hängen die klassischen Themen wie Security, Transaktionen oder Tracing bisher noch immer am aktuellen Thread. Beginnt man mit Reactive Programming, funktioniert dieses Konstrukt nicht mehr und es müssen andere Lösungen gefunden werden.

Project Reactor, die Basis des Reactive Web Framework von Spring, hat hierzu bereits einige Hilfskonstrukte, die Testing, Debugging und Kontext-Propagation ermöglichen (Abschnitte Testing, Debugging und Kontext). Allerdings zeigt allein die Tatsache, dass solche Hilfskonstrukte nötig sind, bereits die Komplexität von Reactive Programming. Es muss also die Frage erlaubt sein, ob es nicht sinnvollere Alternativen gibt, die das Problem der teuren Kontextwechsel von Java-Threads beheben.

Alternativen zu Reactive Programming

Wie oben erwähnt, passt der Grad der Parallelität von aktuellen Webanwendungen in Kombination mit der geringen Größe der auszuführenden Codeschnipsel nicht zur aktuellen Threading-Implementierung von Java, bei der jedem Java-Thread eins zu eins ein Betriebssystemprozess zugeordnet ist. Zwar gibt es in Java Threadpools, mit denen es möglich ist, mehrere dieser Codeschnipsel auf demselben Thread auszuführen. Diese entschärfen das Problem auch, letztendlich sind sie aber nur ein unschöner Workaround für das Problem.

Andere Programmiersprachen wie C#, JavaScript oder Kotlin sind hier bereits einen Schritt weiter und haben Konstrukte in die Sprache aufgenommen, um solche kurzen Codeschnipsel asynchron (nach dem Warten auf ein blockierendes Ereignis) auszuführen. In C# und JavaScript sind es die Konstrukte async and await (C#, JavaScript), in Go und Kotlin gibt es das Konzept der Coroutines (Go, Kotlin).

Die Idee ist hier überall dieselbe. Wenn ich weiß, dass ich einen langlaufenden Aufruf ausführe (z. B. ein Datenbank-Statement absetze), soll mein Code nicht blockieren. Vielmehr möchte ich auf eine einfach zu schreibende und zu lesende Art und Weise den Code angeben können, der ausgeführt werden soll, wenn der langlaufende Aufruf beendet ist und das Ergebnis zur Verfügung steht. Der entstehende Code sollte zudem einfach zu debuggen und zu testen sein.

Green Threads in Java 1

Betrachtet man den Aufwand, der betrieben werden muss, nur um dafür zu sorgen, dass der Entwickler sein reaktives Programm genauso leicht schreiben, testen, warten und debuggen kann, wie er es aus der imperativen Welt mit der Standard-Threading-Abstraktion gewohnt ist, stellt sich eine Frage. Und zwar die Frage, ob die bessere Laufzeitperformance den Einsatz von Reactive Programming überhaupt rechtfertigt. Wenn man dann noch einen Blick auf die gerade genannten alternativen Ansätze wirft, scheint es umso fraglicher, ob Reactive Programming die richtige Lösung für das Problem ist. Allerdings muss man zugeben, dass es sich bei den vorgestellten Alternativen anderer Programmiersprachen (also async/await und Coroutines) jeweils um Sprachkonstrukte handelt und nicht um Third-Party-Bibliotheken. Würden also solche alternativen Sprachkonstrukte in Java Reactive Programming überflüssig machen? Ein Blick in die Vergangenheit von Java fördert in diesem Zusammenhang einen interessanten Aspekt zutage:

In Java 1.1 war das gesamte Threading-Modell als sogenannte „Green Threads“ implementiert, d. h. die gesamte Java VM lief in nur einem Betriebssystemprozess. Java-Threads wurden innerhalb der VM mit einem eigenen Scheduling-Algorithmus realisiert. Threadwechsel und damit Kontextwechsel innerhalb von Java konnten aufgrund des virtuellen Speichermanagements extrem schnell und mit wenig Speicheroverhead durchgeführt werden.

Vorteil dieser Lösung war zudem, dass die Synchronisation von Datenzugriffen innerhalb der Java-Applikationen nicht so kompliziert war. Ein „echter“ paralleler Zugriff auf eine Variable konnte gar nicht passieren, da alles in einem Betriebssystemprozess ausgeführt wurde und daher nur „virtuell parallel“ war.

Der Nachteil dieser Implementierung ist allerdings, dass Java mit Green Threads bei Mehrkern- oder Mehrprozessorsystemen eben auch nur einen Kern bzw. Prozessor verwenden konnte. Mit Java-Programmen war es also nie möglich, die komplette Leistung der Rechner auszunutzen. Im Praxiseinsatz wurde schnell klar, dass dieser Nachteil gravierender als der genannte Vorteil dieser Implementierung war.

Daher wurde die Verwendung von Green Threads schnell beendet. In Java 1.2 konnte man per Command-Line-Schalter zwischen Green und Natives Threads wechseln. In Java 1.3 wurden nur noch native Threads unterstützt. Nun konnten zwar alle Kerne und Prozessoren eines Rechners verwendet werden. Der Aufwand für Threadwechsel (die ja nun Prozesswechsel waren) ist seither aber wie beschrieben deutlich erhöht. So kommt es, dass Programmierparadigmen wie Reactive Programming deutlich höhere Performancewerte erzielen – eben weil sie nicht blockieren und daher deutlich weniger Kontextwechsel erzeugen.

Project Loom vereint die Threading-Modelle

Im letzten Jahr wurde Projekt Loom ins Leben gerufen. Die Idee hinter diesem JVM-Projekt ist die erneute Entwicklung von Java-Support für Green Threads. Diese sollen im Gegensatz zu früher die bisherigen Betriebssystemthreads nicht ersetzen, sondern ergänzen. Beide Threading-Modelle sollen also parallel auf der JVM existieren und gleichzeitig im Programmablauf zum Einsatz kommen.

Demzufolge werden Betriebssystemthreads weiterhin durch die Java-Klasse thread implementiert und Green Threads durch die Klasse fiber. Gegebenenfalls wird es eine gemeinsame Basisklasse geben. Der Plan ist, dass der meiste existierende Code ohne Änderungen in einem Fiber laufen kann, ohne den dahinter liegenden Thread zu kennen. Kontextwechsel zwischen Fibers sollen dank des virtuellen Speichermanagements von Java nahezu ohne Overhead erfolgen können. Auch Synchronisation soll dann in Fibers deutlich performanter werden. Eine Idee dazu ist, dass der Scheduler dafür sorgt, dass zwei Fibers, die voneinander abhängen (also z. B. auf dieselbe Variable zugreifen), auf demselben Native Thread ausgeführt werden. So ist sichergestellt, dass sie niemals parallel laufen können. Eine Synchronisation ist dann praktisch nicht mehr nötig.

Zur Realisierung von Fibers soll die Ausführung von Threads in Java in zwei Teile aufgeteilt werden: in die Continuation und den Scheduler. Eine Continuation repräsentiert dabei einen Ausführungszustand, also den auszuführenden Code inklusive des Ausführungskontexts wie Aufrufparameter, Stack usw. Der Scheduler sorgt dann für eine gleichmäßige Ausführung aller Continuations.

Die Trennung von Scheduler und Continuation hat mehrere Vorteile. Bisher werden sowohl Scheduling als auch Ausführungskontext vom Betriebssystem gemanagt. Durch die Trennung ist es nun möglich, eines von beidem (oder beides) in der JVM auszuführen. Green Threads (die Fibers) können so komplett in Java implementiert werden, und auch existierende Java-Scheduler wie z.B. der Fork-Join-Pool können wiederverwendet werden.

Die Trennung von Scheduler und Continuation hat noch einen weiteren interessanten Aspekt: Die dadurch separierten Continuations könnten als eigenes Java-API jedem Entwickler zur Verfügung gestellt werden. Continuation könnte als Sprachfeature (z. B. unter dem Namen Coroutines) in Java einfließen. Wie oben geschrieben, gibt es bereits mehrere Sprachen (auch auf der JVM), in denen dieses Sprachfeature bereits existiert. Project Loom würde es quasi als „Abfallprodukt“ auch für Java mitbringen.

Fazit

Mit Reactive Programming werden Performanceprobleme gelöst, die durch die Verwendung von Native Threads und des Paradigmas „Ein Thread pro Request“ entstehen. Man erkauft sich diese Lösung allerdings mit höherer Entwicklungs- und Wartungskomplexität, da unter anderem Testing und Debugging komplizierter werden.
Green Threads sind eine Möglichkeit, um die Performanceeinbußen, die durch den Prozesswechsel im Betriebssystem entstehen, zu vermeiden. Diese gab es in Java 1.1, doch bereits in Java 1.3 wurden sie wieder verworfen, da mit ihnen die Vorteile von Mehrkern- oder Mehrprozessorsystemen nicht genutzt werden konnten. Mit Project Loom gibt es nun einen neuen Versuch, eine neue Variante von Green Threads (sogenannte Fibers) im JDK einzuführen. Damit ginge, quasi als Abfallprodukt, der Support für Continuations in Java einher. Dieses Feature ist aus anderen Programmiersprachen wie Kotlin und Go unter dem Namen Coroutines bekannt. Auch diese würden mit Project Loom also Einzug in die Java-Welt erhalten.

Man darf gespannt sein, ob und wenn ja, dann wann Project Loom in das JDK einfließt und welche Auswirkungen das auf die Verbreitung von Reactive Programming haben wird. Aus Performancesicht würde es dadurch vermutlich überflüssig.

In diesem Sinne, stay tuned.

Geschrieben von
Arne Limburg
Arne Limburg
Arne Limburg ist Softwarearchitekt bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.
Kommentare

Hinterlasse einen Kommentar

3 Kommentare auf "Die Jagd nach Performance: Ist Reactive Programming der richtige Weg?"

avatar
4000
  Subscribe  
Benachrichtige mich zu:
Philipp Thomas
Gast

Sehr interessanter Artikel und schön strukturiert, Herr Limburg.
Hat bei mir einen großen „Oha“-Effekt ausgelöst. 🙂
Projekt Loom wäre doch eine sehr schöne Lösung für das Problem. Werde mich dazu mehr informieren.

Bughunter
Gast

Aber Fibers wären dann wie die originalen Green Threads auf einen Prozessor Core beschränkt?

Boereck
Gast

Nein, die Fibers werden im Project Loom auf Threads in einem Thread-Pool (bei default ein Fork-Join-Pool) verteilt. Hier ist eine Präsentation die Details erläutert:
https://www.youtube.com/watch?v=vbGbXUjlRyQ