Blick in die fernere Zukunft

Project Loom: Besser skalieren durch virtuelle Threads

Lutz Hühnken

© oatawa/Shutterstock.com

Das Project Loom ist eine experimentelle Version des JDKs. Es erweitert Java um virtuelle Threads, die leichtgewichtige Nebenläufigkeit erlauben. Preview Releases sind schon jetzt verfügbar und zeigen, was möglich ist.

Serverseitige Java-Applikationen sollten in der Lage sein, viele Requests parallel abzuarbeiten. Das Modell, das in der serverseitigen Java-Programmierung bis heute am verbreitetsten ist, ist Thread-per-Request. Hierbei wird einem eingehenden Request ein Thread zugeordnet und alles, was getan werden muss, um mit einer passenden Response zu antworten, wird auf diesem Thread abgearbeitet. Allerdings schränkt das die maximale Anzahl nebenläufig bearbeiteter Requests stark ein. Denn die Java-Threads, die jeweils einen nativen Betriebssystemthread beanspruchen, sind keine Leichtgewichte. Zum einen wegen des Speicherbedarfs: Jeder einzelne Thread beansprucht standardmäßig mehr als ein Megabyte Speicher. Zum anderen wegen der Kosten für den Wechsel zwischen den Threads, dem Context Switch.

Als Reaktion auf diese Nachteile sind in den letzten Jahren viele asynchrone Libraries, die zum Beispiel CompletableFutures verwenden, aber auch ganze „reactive“ Frameworks, wie z. B. RxJava, Reactor oder Akka Streams entstanden. Zwar nutzen sie allesamt die Ressourcen weit effektiver, fordern den Entwickler aber auf, sich auf ein deutlich anderes Programmiermodell umzustellen. Hierbei stöhnen einige über den kognitiven Ballast, da sie lieber wie gewohnt sequenziell auflisten, was das Programm tun soll, statt mit Callbacks, Observables oder Flows zu hantieren. Hier stellt sich die Frage, ob es nicht möglich wäre, die Vorteile beider Welten zu vereinen: So effektiv wie die asynchrone Programmierung und dennoch in gewohnter, sequenzieller Befehlsabfolge programmieren können. Das Project Loom von Oracle will mit einem modifizierten JDK genau diese Option erkunden. Es bringt zum einen ein neues, leichtes Konstrukt für die Nebenläufigkeit mit, nämlich die virtuellen Threads, und zum anderen eine angepasste Standardbibliothek, die auf diesen aufsetzt.

Virtual Threads

Erinnert sich noch jemand an Multithreading in Java 1.1? Damals kannte Java nur sogenannte Green Threads. Die Möglichkeit, mehrere Betriebssystemthreads zu verwenden, wurde gar nicht genutzt. Threads wurden lediglich innerhalb der JVM emuliert. Ab Java 1.2 wurde schließlich für jeden Java-Thread auch wirklich ein nativer Betriebssystemthread gestartet.

Jetzt feiern die Green Threads ein Revival. Zwar in deutlich veränderter, modernisierter Form, aber im Grunde sind virtuelle Threads nichts anderes: Es sind Threads, die innerhalb der JVM verwaltet werden. Allerdings sind sie nun nicht mehr Ersatz für die nativen Threads, sondern Ergänzung. Eine relativ geringe Menge nativer Threads wird als Carrier eingesetzt, um eine nahezu beliebig große Menge virtueller Threads abzuarbeiten. Der Overhead der virtuellen Threads ist so gering, dass der Programmierer sich keine Gedanken darüber machen muss, wie viele davon er startet.

Ein nativer Thread reserviert in einer 64 Bit JVM mit Defaulteinstellungen schon einmal ein Megabyte für den Stack (der Thread Stack Size, der mit der Option -Xss auch explizit gesetzt werden kann). Dazu kommen noch ein paar weitere Metadaten. Und wenn nicht der Speicher die Grenze bildet, wird das Betriebssystem bei ein paar tausend Schluss machen.

// Achtung, kann Rechner einfrieren...
void excessiveThreads(){
  ThreadFactory factory = Thread.builder().factory();
  ExecutorService executor = Executors.newFixedThreadPool(10000, factory);
  IntStream.range(0, 10000).forEach((num) -> {
    executor.submit(() -> {
      try {
          out.println(num);
          // Wir warten ein bisschen, damit die Threads wirklich alle gleichzeitig laufen
          Thread.sleep(10000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      });
    });
  executor.shutdown();
  executor.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS);
}

Der Versuch in Listing 1, 10 000 Threads zu starten, wird die meisten Rechner in die Knie (bzw. die JVM zum Absturz) zwingen. Achtung: eventuell erreicht das Programm das Threadlimit Ihres Betriebssystems und folglich könnte Ihr Rechner einfrieren.

Mit virtuellen Threads ist es hingegen kein Problem, eine ganze Million an Threads zu starten. Listing 2 wird auf der Project Loom JVM ohne Probleme durchlaufen!

void virtualThreads(){
  // Factory für virtuelle Threads
  ThreadFactory factory = Thread.builder().virtual().factory();
  ExecutorService executor = Executors.newFixedThreadPool(1000000, factory);
  IntStream.range(0, 1000000).forEach((num) -> {
    executor.submit(() -> {
      try {
        out.println(num);
        // Thread.sleep schickt hier nur den virtuellen Thread schlafen
        Thread.sleep(10000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    });
  });
  executor.shutdown();
  executor.awaitTermination(Integer.MAX_VALUE, TimeUnit.SECONDS);
}

JDK APIs

Wir könnten nun also eine Million Threads gleichzeitig starten. Das ist vielleicht ein schöner Effekt, aber der Nutzen ist dennoch begrenzt. Wirklich interessant wird es, wenn all diese virtuellen Threads die CPU nur kurzzeitig beanspruchen. Die meisten serverseitigen Anwendungen sind weniger CPU-lastig als vielmehr I/O-lastig. Es wird vielleicht ein bisschen Input validiert, aber dann werden vor allem Daten über das Netz geholt (oder geschrieben), zum Beispiel aus der Datenbank, oder über HTTP von einem anderen Service.

Im Thread-per-Request-Modell mit synchroner I/O führt das dazu, dass der Thread für die Dauer der I/O-Operation geblockt ist. Das Betriebssystem erkennt, dass der Thread auf I/O wartet, worauf der Scheduler direkt zum nächsten schaltet. Das scheint erst einmal nicht weiter schlimm, da der geblockte Thread die CPU nicht belegt. Jeder Wechsel zwischen Threads bringt allerdings einen Overhead mit sich. Dieser Effekt hat sich übrigens durch die modernen, komplexen CPU-Architekturen mit mehreren Cache-Layern (non-uniform Memory Access, NUMA) noch deutlich verschärft.

Die Anzahl der Context Switches sollte minimiert werden, um die CPU tatsächlich effektiv auszulasten. Aus CPU-Sicht wäre es perfekt, wenn auf jedem Core genau ein Thread dauerhaft laufen und nie ausgewechselt würde. Diesen Zustand werden wir in der Regel nicht erreichen können, schließlich laufen neben der JVM auch noch andere Prozesse auf dem Server. Aber es bleibt festzuhalten: „Viel hilft viel“ gilt zumindest bei nativen Threads nicht – man kann es hier durchaus übertreiben.

Um viele parallele Requests mit wenigen nativen Threads ausführen zu können, gibt der in Project Loom eingeführte virtuelle Thread freiwillig die Kontrolle ab, wenn er auf I/O wartet und pausiert. Den darunterliegenden nativen Thread, der als Worker den virtuellen Thread ausführt, blockiert er aber dadurch nicht. Der virtuelle Thread signalisiert vielmehr, dass er gerade nichts machen kann, während sich der native Thread den nächsten virtuellen Thread greifen kann, ohne CPU-Kontextwechsel. Wie aber geht das vonstatten, ohne asynchrone I/O APIs zu verwenden? Denn Project Loom hat sich schließlich auf die Fahnen geschrieben, die Programmierer vor der Callback-Wüste zu bewahren.

Hier zeigt sich der Vorteil dessen, die neue Funktionalität in Form einer neuen JDK-Version bereitzustellen. Eine Third-Party Library für ein derzeit aktuelles JDK ist darauf angewiesen, ein asynchrones Programmiermodell zu verwenden. Project Loom hat stattdessen eine angepasste Standardbibliothek im Gepäck. Viele I/O Libraries sind so umgeschrieben worden, dass sie nun intern virtuelle Threads verwenden (Kasten: „Geänderte Standardbibliotheken“). Ein gewohnter Netzwerkaufruf ist – ohne jegliche Veränderung des Programmcodes – plötzlich keine blocking I/O mehr. Lediglich der virtuelle Thread pausiert. Durch diesen Kniff profitieren auch bestehende Programme von den virtuellen Threads, ohne dass Anpassungen notwendig sind.

Geänderte Standardbibliotheken
Die folgenden Klassen wurden angepasst, sodass blockierende Aufrufe darin nicht mehr den nativen Thread blockieren, sondern lediglich den virtuellen Thread.

  • java.net.Socket
  • java.net.ServerSocket
  • java.net.DatagramSocket/MulticastSocket
  • java.nio.channels.SocketChannel
  • java.nio.channels.ServerSocketChannel
  • java.nio.channels.DatagramChannel
  • java.nio.channels.Pipe.SourceChannel
  • java.nio.channels.Pipe.SinkChannel
  • java.net.InetAddress

Continuations

Das Konzept, das die Basis für die Implementierung der virtuellen Threads bildet, nennt sich Delimited Continuations. Die meisten werden wohl schon einmal einen Debugger benutzt haben. Dazu setzt man einen Breakpoint im Code. Wenn dieser Punkt erreicht ist, wird die Ausführung angehalten und der aktuelle Zustand des Programms im Debugger dargestellt. Es wäre nun doch vorstellbar, diesen Zustand einzufrieren. Das ist die Grundidee der Continuation: halte an einer Stelle im Ablauf an, nimm den Zustand (des aktuellen Threads, also den Call Stack, die aktuelle Position im Code etc.) und wandle diesen in eine Funktion um, die „Mach-dort-weiter-wo-du-aufgehört-hast-Funktion“. Diese kann dann zu einem späteren Zeitpunkt aufgerufen und der begonnene Ablauf wieder aufgenommen werden. Genau das also, was für die virtuellen Threads benötigt wird: Die Möglichkeit, ein Programm zu einem beliebigen Zeitpunkt anzuhalten und später im gemerkten Zustand fortzusetzen.

Continuations haben auch abseits von virtuellen Threads ihre Berechtigung und sind ein mächtiges Konstrukt, um den Fluss eines Programms beliebig zu beeinflussen. Project Loom stellt ein API für die Arbeit mit Continuations zur Verfügung. Für die Anwendungsentwicklung sollte es nicht notwendig sein, damit direkt zu arbeiten. Es ist in erster Linie das Low-Level-Konstrukt, das die virtuellen Threads möglich macht. Wer damit experimentieren möchte, hat aber die Möglichkeit dazu (Listing 3).

void continuationDemo() {
  // Der scope ist ein Hilfsmittel, um geschachtelte Continuations zu   // ermöglichen.
  ContinuationScope scope = new ContinuationScope("demo");
  Continuation a = new Continuation(scope, () -> {
    out.print("To be");
    // hier wird die Funktion eingefroren und gibt die Kontrolle an den     // Aufrufer.
    Continuation.yield(scope);
    out.println("continued!");
  });
  a.run();
  out.print(" ... ");
  // die Continuation kann von dort, wo sie angehalten wurde, fortgesetzt   // werden.
  a.run();
  // ...
  }

Virtuelle Threads sind übrigens eine Form des kooperativen Multitaskings. Nativen Threads wird vom Betriebssystem die CPU entzogen, unabhängig davon, was sie gerade tun (präemptives Multitasking). Auch eine Endlosschleife wird den CPU-Kern so nicht blockieren, es werden trotzdem andere drankommen. Auf der Ebene der virtuellen Threads gibt es aber keinen solchen Scheduler – der virtuelle Thread muss selbst die Kontrolle an den nativen Thread zurückgeben.

Noch nicht im Paket: Tail-Call Elimination

Der Vollständigkeit halber sei erwähnt, dass zu den Features, die im Project Loom implementiert werden sollen, auch die Optimierung von Endrekursion gehört. Wenn eine rekursive Funktion als letzte Aktion sich selbst aufruft, kann sie vom Compiler in eine nichtrekursive Schleife umgewandelt werden. Das geschieht bereits in einigen anderen Sprachen als Java. Auf der JVM unterstützen etwa Scala, Kotlin (mit tailrec) oder Clojure (mit recur) diese Tail-Call Elimination.

Auch Project Loom will eine Direktive einführen, die den Compiler zu dieser Optimierung, welche die Verwendung vieler rekursiver Algorithmen erst ermöglicht, anweist. In den derzeitigen Previews ist das aber noch nicht enthalten. Es ist sogar bisher noch gar nicht spezifiziert, wie es aussehen soll.

Jenseits virtueller Threads

Die eingangs geschilderten Probleme mit Threads beziehen sich allein auf die Effizienz. Noch gar nicht betrachtet wurde dabei eine ganz andere Herausforderung: die Kommunikation zwischen Threads. Die Programmierung mit den aktuellen Java-Mechanismen ist nicht ganz einfach und folglich fehleranfällig. Threads kommunizieren über geteilte Variablen (shared mutable State). Um dabei Race Conditions zu vermeiden, müssen diese durch synchronized oder explizite Locks geschützt werden. Treten hier Fehler auf, sind sie durch den Nichtdeterminismus zur Laufzeit besonders schwer zu finden. Und selbst wenn alles richtig gemacht wurde, stellen diese Locks oft einen Point of Contention dar, einen Flaschenhals in der Ausführung. Denn potenziell müssen dann viele auf genau den einen warten, der gerade das Lock in Anspruch nimmt.

Es gibt durchaus alternative Modelle. Im Kontext von virtuellen Threads sind hier insbesondere Channels zu nennen. Kotlin und Clojure (Kasten: „Was machen die anderen?“) bieten diese als bevorzugtes Kommunikationsmodell für ihre Coroutines an. Statt auf einen geteilten, veränderbaren Zustand setzen sie auf unveränderliche Nachrichten, die (bevorzugt asynchron) in einen Kanal geschrieben und von dort vom Empfänger aufgenommen werden. Ob Channels Teil von Project Loom werden, ist allerdings noch offen.

Was machen die anderen?
Die virtuellen Threads mögen neu für Java sein, neu auf der JVM sind sie nicht. Wer Clojure oder Kotlin kennt, fühlt sich wahrscheinlich an Coroutines erinnert. In der Tat sind sie diesen technisch sehr ähnlich und lösen das gleiche Problem. Mindestens einen kleinen, aber interessanten Unterschied gibt es jedoch aus Entwicklerperspektive: Für Coroutines gibt es spezielle Schlüsselwörter in den jeweiligen Sprachen. In Clojure ein Makro für einen Go-Block, in Kotlin das suspend-Schlüsselwort.Die virtuellen Threads in Loom kommen ohne zusätzliche Syntax aus. Dieselbe Methode kann unverändert von einem virtuellen, oder direkt von einem nativen Thread ausgeführt werden.

Es ist vielleicht aber auch gar nicht nötig, dass Project Loom alle Probleme löst – eventuelle Lücken werden sicher von neuen Third-Party Libraries gefüllt werden, die auf den virtuellen Threads basierend Lösungen auf einer höheren Abstraktionsebene bieten. Das Experiment Fibry ist zum Beispiel eine Aktoren-Library für Loom (Kasten: „War da nicht was mit Fibers?“).

War da nicht was mit Fibers?
Wer früher schon einmal von Project Loom gehört hat, kennt den Begriff der Fibers (Faser). Zusammen mit dem Faden (Thread) führte das wohl auch zum Projektnamen, denn Loom bedeutet Webstuhl. In den ersten Versionen von Project Loom war Fiber der Name für den virtuellen Thread. Er geht zurück auf ein vorheriges Projekt des jetzigen Loom-Projektleiters Ron Pressler, die Quasar Fibers. Der Name Fiber wurde aber, ebenso wie die Alternative Coroutine, Ende 2019 verworfen, durchgesetzt hat sich Virtual Thread.

Das Alleinstellungsmerkmal von Project Loom

Für das Problem, das Project Loom löst, gibt es im Grunde schon eine etablierte Lösung: Asynchrone I/O, entweder durch Callbacks oder durch „reactive“ Frameworks. Diese zu verwenden heißt aber, sich auf ein anderes Programmiermodell einzulassen. Nicht allen Entwicklern fällt es leicht, auf eine asynchrone Denkweise umzusteigen. Es mangelt teilweise auch an Unterstützung in gängigen Libraries – alles, was Daten in ThreadLocal speichert, ist plötzlich unbrauchbar. Und im Tooling: Das Debuggen asynchronen Codes hat oft mehrere Aha-Erlebnisse zur Folge. Auch in dem Sinne, dass der Code, der untersucht werden soll, nicht auf dem Thread ausgeführt wird, den man gerade den Debugger in Einzelschritten durchgehen lässt.

Der besondere Reiz von Project Loom liegt darin, dass es die Änderungen auf JDK-Ebene vornimmt, sodass der Programmcode unverändert bleiben kann. Ein heute ineffizientes Programm, das für jede HTTP Connection einen nativen Thread verbraucht, könnte unverändert auf dem Project Loom JDK laufen und wäre plötzlich effizient und skalierbar (Kasten: „Wann kommen virtuelle Threads für alle?“). Der veränderten, jetzt auf virtuellen Threads basierenden java.net Library sei Dank.

Wann kommen virtuelle Threads für alle?
Project Loom hält sich sehr bedeckt, wenn es um die Frage geht, in welches Java-Release die Features einfließen sollen. Im Moment ist alles noch experimentell und APIs können sich noch ändern. In JDK 15 sollte man wohl noch nicht damit rechnen.Wer es ausprobieren möchte, kann aber entweder den Source Code von GitHub auschecken und sich das JDK selbst bauen, oder fertige Preview Releases herunterladen.

Geschrieben von
Lutz Hühnken
Lutz Hühnken
Lutz Huehnken ist Solutions Architect bei Lightbend. Aktuell beschäftigt er sich mit der Entwicklung von Microservices mit Scala, Akka und Lagom. Er tweeted als @lutzhuehnken und blogged unter https://huehnken.de.
Kommentare

1
Hinterlasse einen Kommentar

avatar
4000
1 Kommentar Themen
0 Themen Antworten
0 Follower
 
Kommentar, auf das am meisten reagiert wurde
Beliebtestes Kommentar Thema
1 Kommentatoren
Derp Letzte Kommentartoren
  Subscribe  
Benachrichtige mich zu:
Derp
Gast
Derp

Wo ist die eigentliche Verbesserung? Ich glaube, Loom verbessert Libraries wie Cats Effect bzgl. Callbacks and blocking issues unterlegen in Leistung und Ergonomie nicht wirklich. Der Java-Programmierung ist doch besser mit Akka, was die reine Handhabung und Effizienz betrifft, bedient. Und in der FP, wissen die richtigen Leute auch Libs wie Cats und ZIO zu schätzen…