CompletableFuture: Fibers in Java 8

Fibers, also leichtgewichtige Threads, erhöhen die Nebenläufigkeit und reduzieren die Anfälligkeit für Deadlocks und Race Conditions. Java 8 bietet mit der neuen Klasse CompletableFuture einen einfachen Weg, Fibers in eigenen Anwendungen zu nutzen.

Sprachen  –  3 Kommentare

Jahrzehntelang haben sich Anwendungsentwickler wenig Gedanken um Performance machen müssen, da mit jeder Generation noch schnellere Prozessoren zur Verfügung standen. Der Trend hat sich in den letzten Jahren leider nicht weiter fortgesetzt. Statt höherer Taktraten bieten moderne CPUs eine Vielzahl von Rechenkernen. Um diese zu nutzen, sind Änderungen an den entwickelten Programmen zwingend notwendig. Hierzu bedienten sich Entwickler üblicherweise entweder Prozessen oder Threads. Beide sind jedoch nur bedingt geeignet, eine zeitgemäße Anwendung massiv zu beschleunigen.

Der Grund hierfür liegt in der Natur der Sache: Prozesse, wie auch Threads, sind Betriebssystemartefakte. Im Vergleich zum inzwischen nahezu "unbegrenzt" verfügbaren (virtuellen) Arbeitsspeicher ist die Menge der effektiv erzeugbaren Prozesse und Threads endlicher Natur. Ebenso setzt das durch das Betriebssystem kontrollierte Multitasking Grenzen. Zur Herstellung einer gewissen Fairness zwischen den Threads wendet das Betriebssystem typischerweise ein Zeitscheibenverfahren an. Das hierzu notwendige Anhalten und Fortführen der Threads durch das Betriebssystem benötigt eine ziemlich lange Zeit, zumindest im Vergleich mit einem einfachen Funktionsaufruf innerhalb der Anwendung.

Diese begründet sich unter anderem im Speichern des aktuellen Prozessorzustands im RAM beziehungsweise dem entsprechend umgekehrten Weg wie auch dem Verlust des jeweiligen Cache-Inhalts. Je nach Art des Wechsels ist der Effekt unterschiedlich ausgeprägt: stark bei Prozesswechseln, da Prozesse sich typischerweise keinen gemeinsamen Speicher teilen oder schwächer bei Thread-Wechseln, da sich alle Threads eines Prozesses den zugeteilten Heap-Speicher teilen und lediglich über getrennte Stack-Speicher verfügen. Dieser Overhead wird in vielen Anwendungen zunehmend zum Ballast, der stärker zutage tritt, je mehr Prozesse beziehungsweise Threads eine Anwendung nutzen möchte.

Ebenso ist es ein bekannter Nachteil der von Prozessen wie auch Threads zur Synchronisierung eingesetzten, blockierenden Mechanismen (Monitore, Semaphoren etc.), dass sie nicht nur einen vorzeitigen Kontextwechsel des jeweiligen Rechenkerns erzwingen (und somit umso mehr Overhead erzeugen), sondern auch komplex in der Umsetzung sind: Deadlocks und Race Conditions sind allseits bekannte Probleme nebenläufiger Programmierung.

Um gegenüber Prozessen und Threads verbesserte, also effizientere und einfachere Nebenläufigkeit zu erhalten, sind Fibers ein probates Mittel: Weil sie keinen Kontextwechsel des Rechenkerns
benötigen und somit vom Betriebssystem nicht wahrgenommen werden, gelten sie als erheblich "leichtgewichtiger". Da sie im optimalen Fall nur einen Thread pro CPU-Kern nutzen, reduzieren sie statistisch gesehen zudem die Anfälligkeit für Deadlocks und Race Conditions – weniger Threads, weniger Locks.

Deadlocks können bei Fibers nur noch auftreten, wenn zwei Fibers auf das gleiche Objekt zugreifen und dabei von getrennten Threads ausgeführt werden würden. Fibers, die hingegen von ein und demselben Thread ausgeführt werden, können sich nicht gegenseitig blockieren und sind somit immun, da sich ein Thread üblicherweise nicht selbst blockieren kann. Race Conditions werden zumindest reduziert, da sich Fibers, die der gleiche Thread ausführt, nicht gegenseitig überholen können. Das liegt daran, dass sie eher mit gleicher Geschwindigkeit ausgeführt werden, als das bei getrennten Threads der Fall ist. Das soll keinesfalls dazu anregen, Code zu schreiben, der potenziell solche Probleme verursachen kann; vielmehr sollte man in ernsthaften Anwendungen diese Tatsache als statistisch willkommenen Seiteneffekt betrachten.

Faden, Zwirn, Seil

Der Begriff "Fibers" ist in der Informatik relativ lange bekannt und wird gerne mit "leichtgewichtigen Threads" oder "User-Threads" gleichgesetzt. Er war jedoch eine gewisse Zeit in Vergessenheit geraten – bis sich eben Multicore-CPUs verbreiteten. Stellt man sich Prozesse als ein Seil vor, besteht es aus verschränktem Zwirn, den Threads. Jeder Zwirn wiederum besteht aus verschränkten Fäden, den Fibers. Diese wiederum bestehen aus Fasern, kurzen Code-Sequenzen, die nicht weiter unterteilt sind.

Während sich das Betriebssystem um Lebenszeit und Ausführungsfairness von Prozessen und Threads kümmert, übernimmt diese Aufgaben die Anwendung bei Fibers hingegen selbst. Sie hat die absolute und alleinige Kontrolle darüber, welche Fiber beziehungsweise welche Code-Sequenz aktuell ausgeführt wird und wann ein Wechsel auf eine andere Fiber oder Code-Sequenz
stattfindet. Technisch vereinfacht ausgedrückt liegt der Anwendung eine Liste mit Code-Sequenzen vor, welche die Anwendung Schritt für Schritt durch eine limitierte Anzahl an Threads abarbeiten lässt. Wie die Anwendung die Aufgabe umsetzt, bleibt ihr überlassen.

Unter anderem kann Windows mit Fibers nativ umgehen, was im Folgenden jedoch nicht weiter beachtet sei. Die diesem Artikel zugrunde liegenden, per CompletableFuture erzeugten Fibers sind hingegen weder dem Betriebssystem noch der Java Virtual Machine bekannt: Es sind somit "echte" Fibers, deren Existenz lediglich die Anwendung selbst wahrnimmt und kontrolliert. Theoretisch geht es auch ohne CompletableFuture, mit viel Aufwand sowie schwer lesbarem Code, also sogar prinzipiell bereits in Java 1. Das Konstrukt CompletableFuture vereinfacht aber das Leben dermaßen, dass sich Fibers erst mit Java 8 wieder zu einem diskutierten Thema auf der JVM entwickelt haben.