Mit der Java Version 21 wurden die virtuellen Threads nun endlich offiziell als Feature fertiggestellt und können zur Programmierung einer nebenläufigen Anwendung verwendet werden. Virtuelle Threads sind wesentlich leichtgewichtiger als die bisher zur Verfügung stehenden Platform-Threads. Doch wie kann man in Enterprise Anwendungen von ihnen profitieren und sie sinnvoll einsetzen? Ich möchte euch in diesem Beitrag anhand einer Quarkus Rest-Anwendung die Möglichkeiten aufzeigen.
Daten aus unterschiedlichen Quellen nutzen
Um ein besseres Verständnis für die Nutzung von Nebenläufigkeit zu bekommen, möchte ich zunächst ein einfaches Szenario skizzieren. Wir haben eine Liste von Kunden, auf die wir direkt zugreifen können bspw. über einen Datenbank-Aufruf. In einem weiteren Schritt möchten wir an dieses Kunden-Objekt noch weitere Informationen hängen, die in einem anderen System zur Verfügung gestellt werden. Hierfür kann ich einen einfachen Rest-Client-Aufruf nutzen, der diese Daten dem Kunden-Objekt hinzufügt.
Im untenstehenden Beispiel werden die Basisdaten des Kunden einfach über den Konstruktor erzeugt.
@Data
public class Customer {
private Long number;
private String name;
private BigDecimal sales;
public Customer(Long number) {
this.number = number;
this.name = "Customer" + number;
}
}
Im Rest-Client simuliere ich dann einen langsamen Aufruf über ein einfaches Thread.sleep(delay). Wobei die delay Variable (Standard 200ms) über einen Property-Eintrag verändert werden kann.
public void getDetails(Customer customer) {
var random = Math.random() * 1000;
customer.setSales(BigDecimal.valueOf(random));
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
Um nun in Quarkus virtuelle Threads nutzen zu können verwenden wir die Annotation @RunOnVirtualThread. Diese Annotation kann jedoch nicht an einer beliebigen Stelle eingesetzt werden. Daher nutzen wir in unserem Beispiel die Methode des Rest-Endpunkts dafür.
@GET
@Path("virtual")
@RunOnVirtualThread
@SneakyThrows
public List<Customer> getAllCustomersVirtual() {
...
}
Das Erzeugen der virutellen Threads und der nebenläufige Aufruf des RestClients erfolgt in einem separatem Service. Dazu lässt sich der ExecutorService bequem per Inject nutzen.
@Inject
@VirtualThreads
ExecutorService executorService;
public void getDetailsVirtual(Customer customer) {
executorService.execute(() -> customerRestClient.getDetails(customer));
}
Aufruf der Rest-Endpunkte
Wenn wir den Endpunkt nun klassisch aufrufen, bekommen wir folgende Log-Ausgabe:
2024-06-03 12:07:17,130 INFO [de.ged.sho.vth.CustomerResource] (executor-thread-1) executor-thread-1
2024-06-03 12:07:17,135 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Starte Customer(number=1, name=Customer1, sales=657.0995634934278)
2024-06-03 12:07:17,336 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Beende Customer(number=1, name=Customer1, sales=657.0995634934278)
2024-06-03 12:07:17,337 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Starte Customer(number=2, name=Customer2, sales=103.30044405089455)
2024-06-03 12:07:17,538 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Beende Customer(number=2, name=Customer2, sales=103.30044405089455)
2024-06-03 12:07:17,539 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Starte Customer(number=3, name=Customer3, sales=111.8827699955257)
2024-06-03 12:07:17,739 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Beende Customer(number=3, name=Customer3, sales=111.8827699955257)
2024-06-03 12:07:17,740 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Starte Customer(number=4, name=Customer4, sales=521.2613134303721)
2024-06-03 12:07:17,941 INFO [de.ged.sho.vth.CustomerRestClient] (executor-thread-1) Beende Customer(number=4, name=Customer4, sales=521.2613134303721)
Man kann erkennen, dass ein Executor Thread für die gesamte Ausführung der Aufrufe zuständig ist. Der Aufruf erfolgt dabei synchron hintereinander, so dass bei 4 Kunden der Vorgang 800+ms dauert.
Verwenden wir für den Abruf der Details nun virtuelle Threads, bekommen wir folgende Log-Ausgabe:
2024-06-03 12:07:48,809 INFO [de.ged.sho.vth.CustomerResource] (quarkus-virtual-thread-0) quarkus-virtual-thread-0
2024-06-03 12:07:48,811 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-1) Starte Customer(number=1, name=Customer1, sales=778.4656731823097)
2024-06-03 12:07:48,811 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-3) Starte Customer(number=3, name=Customer3, sales=338.8230603545126)
2024-06-03 12:07:48,811 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-2) Starte Customer(number=2, name=Customer2, sales=235.9506199995366)
2024-06-03 12:07:48,811 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-4) Starte Customer(number=4, name=Customer4, sales=329.02252118005805)
2024-06-03 12:07:49,012 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-1) Beende Customer(number=1, name=Customer1, sales=778.4656731823097)
2024-06-03 12:07:49,012 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-2) Beende Customer(number=2, name=Customer2, sales=235.9506199995366)
2024-06-03 12:07:49,012 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-3) Beende Customer(number=3, name=Customer3, sales=338.8230603545126)
2024-06-03 12:07:49,013 INFO [de.ged.sho.vth.CustomerRestClient] (quarkus-virtual-thread-4) Beende Customer(number=4, name=Customer4, sales=329.02252118005805)
Bereits der Endpunkt wird innerhalb eines virutellen Threads gestartet. Darüber hinaus werden nun 4 virutelle Threads für das Sammeln der Detailinformationen verwendet. Damit ergibt sich nun eine Laufzeit von 200+ms.
Fazit
Durch den Einsatz von virtuellen Threads lassen sich in einigen Fällen Aufrufe parallelisieren und damit eine bessere Laufzeit erzielen. Der Vorteil bei der Verwendung von Quarkus ist die Möglichkeit sowohl blocking, als auch non-blocking in Form von virtuellen Threads in einer Anwendung nutzen zu können. Es kann also je nach Anwendungsfall entschieden werden, welche Variante für die Aufrufe sinnvoll ist. Der Quellcode für das Beispiel befindet sich wie immer auf Github.