Tomcat 11 & Virtual Threads đ§”

Apache Tomcat est le plus cĂ©lĂšbre des conteneurs de Servlets Java. Les versions se succĂšdent au fil des annĂ©es. Avec Spring Boot, et son utilisation de la version «embedded», son usage en tant que serveur «installé» a diminuĂ©, mais il reste encore au cĆur de la majoritĂ© de nos micro-services, parfois sans que les dĂ©veloppeurs sâen rendent compte.
Chaque version majeure de Tomcat apporte le support des nouvelles versions des API Java EE
ou JEE
.
La version ayant eu le plus dâimpact sur les dĂ©veloppeurs est la version 10, qui a intĂ©grĂ© le support des API jakarta
, en remplacement des anciennes API javax
. Cette version 10 de Tomcat était liée à Java 11, dans laquelle la suppression des packages javax
liés à Java EE a eu lieu. Les modules supprimés sont documentés dans la JEP 320. On y retrouve les tristement célÚbres java.xml.bind
, javax.transaction
et javax.activation
, qui ont donné du fil à retordre aux développeurs souhaitant migrer leurs applications.
Les versions de Tomcat sont donc Ă chaque fois compatibles avec une version minimale de Java, et des API jakarta
.
Le tableau ci-dessous reprend la liste des versions compatibles :
Servlet Spec | Apache Tomcat Version | Supported Java Versions | Release date |
---|---|---|---|
6.1 | 11.0.x | 21 and later | (version alpha) |
6.0 | 10.1.x | 11 and later | dec. 2020 |
4.0 | 9.0.x | 8 and later | oct. 2017 |
3.1 | 8.5.x | 7 and later | jan. 2014 |
3.0 | 7.0.x (archived) | 6 and later | jan. 2011 |
La version 11 de Tomcat est donc destinĂ©e Ă la version 21 de Java. Cette stratĂ©gie nâest pas surprenante en soi, la version 21 Ă©tant la derniĂšre version LTS Ă date.
MĂȘme si Tomcat 11 nâest pas encore en version finale, les travaux pour son dĂ©veloppement durent depuis dĂ©jĂ plus dâun an Ă lâĂ©criture de ces lignes. La premiĂšre version milestone de Tomcat 11 a Ă©tĂ© publiĂ©e en dĂ©cembre 2022 ! La premiĂšre version Ă©tait prĂ©vue pour supporter Java 11 (cf. la release note Tomcat 11.0.0-M1). La version Java 17 a ensuite Ă©tĂ© choisie Ă partir de la milestone 3 de Tomcat 11 (cf. la release note Tomcat 11.0.0-M3). La version 21 a Ă©tĂ© choisie Ă partir de la milestone 7 de Tomcat 11, publiĂ©e en juin 2023, soit 3 mois avant la sortie de Java 21 (cf. la release note Tomcat 11.0.0-M7).
La version actuelle est la milestone 16, publiĂ©e le 9 janvier 2024. Câest cette version qui sera testĂ©e dans cet article.
Un des principaux avantages de cette version 11, avec le support de Java 21, est le support des Virtual Threads. Bien que le code nĂ©cessaire ait Ă©tĂ© ajoutĂ© Ă Tomcat en version 10.1, on peut considĂ©rer que le support nâĂ©tait quâexpĂ©rimental, puisque les Virtual Threads nâont Ă©tĂ© intĂ©grĂ©s quâen version preview Ă partir de Java 19, et en version finale en Java 21.
Câest quoi les Virtual Threads ?
Avant dâexplorer lâimplĂ©mentation de Tomcat et son usage des Virtual Threads, un rapide rappel de ce quâils sont et de la maniĂšre dont ils fonctionnent.
Les Virtual Threads sont des Thread dits «lĂ©gers», parfois appelĂ©s «Green Threads» ou «Routines/Coroutines» dans dâautres langages. Ils sont mis en opposition aux Threads dits «Plateforme». Les Threads plateforme sont des Threads gĂ©rĂ©s directement par le systĂšme dâexploitation.
Une excellente conférence de José Paumard sur le projet Loom, qui introduit les Virtual Threads en Java, est visible sur Youtube. Cette vidéo est une trÚs bonne introduction à ce sujet.
Les Threads Plateforme
Lorsquâun programme demande la crĂ©ation dâun Thread, le systĂšme dâexploitation stoppe lâexĂ©cution du code et crĂ©e le Thread, avec sa mĂ©moire attribuĂ©e, appelĂ©e la Stack. Il redonne ensuite la main au programme pour quâil continue son exĂ©cution. Ces deux Ă©tapes impliquent, Ă chaque fois, que le CPU sauvegarde lâĂ©tat courant de lâexĂ©cution du programme, et le restaure ensuite. Câest ce quâon appelle un context switch, un changement de contexte dâexĂ©cution.
Lors de la crĂ©ation dâun Thread, le systĂšme dâexploitation doit donc effectuer plusieurs context switches, et allouer un peu de mĂ©moire au Thread. Ces Ă©tapes ont donc un coĂ»t, en temps et en mĂ©moire.
Le coût en temps
Le temps de crĂ©ation dâun Thread dĂ©pend principalement du systĂšme dâexploitation et de sa charge actuelle. Pour mesurer ce temps, un benchmark Ă©crit avec lâoutil JMH (dont lâutilisation vaudrait un article Ă elle seule) permet dâestimer le temps de dĂ©marrage dâun Thread Java sur une machine :
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ThreadsBenchmark {
@Benchmark
public void computeInMainThread(){
// un calcul quelconque
Blackhole.consumeCPU(1024);
}
@Benchmark
public void computeInPlatformThread() throws InterruptedException {
// exécution dans un thread plateforme
var thread = Thread.ofPlatform().start(() -> {
Blackhole.consumeCPU(1024);
});
thread.join(); // attente de la fin de l'exécution
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(ThreadsBenchmark.class.getSimpleName())
.warmupIterations(1) // une itération de préchauffage de la JVM
.measurementIterations(3) // 3 itérations de mesure
.forks(1)
.build();
new Runner(opt).run();
}
}
Les deux méthodes annotées @Benchmark
sont exécutées en boucle pendant 10 secondes pour mesurer le temps moyen de leur exécution, et cela 4 fois en tout : une premiÚre fois pour préchauffer la JVM (warm-up), et 3 fois pour mesurer les performances réelles. La ligne forks(1)
permet de prĂ©ciser de crĂ©er une JVM destinĂ©e Ă lâexĂ©cution des tests.
La premiÚre méthode effectue un calcul Î combien inutile, à travers la classe Blackhole
fournie par JMH. La seconde mĂ©thode effectue ce mĂȘme calcul, mais dans un Thread plateforme et attend la fin de son exĂ©cution. De cette maniĂšre, on peut extrapoler le surcoĂ»t de lâexĂ©cution de la tĂąche dans un Thread, surcoĂ»t qui comprend donc la crĂ©ation du Thread, et sa suppression.
Le rĂ©sultat de lâexĂ©cution du benchmark est le suivant :
Benchmark Mode Cnt Score Error Units
ThreadsBenchmark.computeInMainThread avgt 3 0.002 ± 0.001 ms/op
ThreadsBenchmark.computeInPlatformThread avgt 3 0.038 ± 0.015 ms/op
On observe que le Blackhole.consumeCPU(1024)
du premier benchmark sâexĂ©cute en moyenne en 0,002 millisecondes. LâexĂ©cution de la mĂȘme instruction dans un Thread plateforme se fait en 0,038 millisecondes. Le surcoĂ»t liĂ© Ă la crĂ©ation et destruction du Thread est donc de 0,036 millisecondes.
CrĂ©er un Thread pour effectuer un calcul peut donc ĂȘtre contre-productif ! đ±
Le coût en mémoire
Le coĂ»t en mĂ©moire dâun Thread est connu Ă lâavance et contrĂŽlĂ© par les paramĂštres -Xss
ou -XX:ThreadStackSize
de la JVM. Cependant, attention aux confusions. On parle bien ici de mémoire réservée, et non pas de mémoire effectivement utilisée. Pour un_Thread_qui ne remplit pas sa Stack, sa consommation réelle sera bien moindre.
La commande suivante permet de constater les valeurs par dĂ©faut de la mĂ©moire dâun Thread Java :
$ java -XX:+PrintFlagsFinal --version | grep -i ThreadStack
intx CompilerThreadStackSize = 1024 {pd product} {default}
intx ThreadStackSize = 1024 {pd product} {default}
intx VMThreadStackSize = 1024 {pd product} {default}
La valeur est exprimée en kilo-octets. Un Thread réservera donc 1 024 ko de RAM, soit 1 Mo. 200 Threads réserveront donc 200 Mo de RAM native, en plus de la RAM allouée à la heap Java.
Les Virtual Threads
Les Virtual Threads sont créés, orchestrĂ©s et exĂ©cutĂ©s directement par la JVM, qui se charge de gĂ©rer leur Stack et leur exĂ©cution de maniĂšre interne. La crĂ©ation dâun Virtual Thread nâimplique donc pas forcĂ©ment la crĂ©ation dâun Thread plateforme. Le coĂ»t de crĂ©ation dâun Virtual Thread est donc bien infĂ©rieur au coĂ»t dâun Thread plateforme, puisquâil ne nĂ©cessite pas de context switch, ni dâallocation dâun bloc de mĂ©moire.
On peut mesurer le coĂ»t temporel de la crĂ©ation dâun Virtual Thread en ajoutant cette mĂ©thode Ă notre benchmark prĂ©cĂ©dent :
@Benchmark
public void computeInVirtualThread() throws InterruptedException {
var thread = Thread.ofVirtual().start(() -> {
Blackhole.consumeCPU(1024);
});
thread.join();
}
Notez lâusage de Thread.ofVirtual()
pour créer un Virtual Thread en lieu et place du Thread.ofPlatform()
.
Les durĂ©es dâexĂ©cution observĂ©es sont les suivantes :
Benchmark Mode Cnt Score Error Units
ThreadsBenchmark.computeInMainThread avgt 3 0.002 ± 0.001 ms/op
ThreadsBenchmark.computeInPlatformThread avgt 3 0.037 ± 0.013 ms/op
ThreadsBenchmark.computeInVirtualThread avgt 3 0.005 ± 0.002 ms/op
Le benchmark utilisant les Virtual Threads prĂ©sente un surcoĂ»t dâexĂ©cution de 0,003 millisecondes par rapport Ă lâexĂ©cution dans le Thread principal, mais est largement infĂ©rieur au surcoĂ»t liĂ© Ă lâexĂ©cution dans un Thread plateforme.
Le coĂ»t de crĂ©ation en temps dâun Virtual Thread est donc 15 fois infĂ©rieur Ă un Thread plateforme.
Notez quâavant lâavĂšnement des Virtual Threads, le problĂšme du coĂ»t de crĂ©ation des Threads plateforme Ă©tait souvent adressĂ© par lâutilisation de pools de Threads, qui permettent de rĂ©utiliser des Threads existants (vive le recyclage â»ïž), plutĂŽt que de les recrĂ©er.
LâimplĂ©mentation de Tomcat
Dans le code de Tomcat, lâinterface Executor
dĂ©crit les objets qui ont pour responsabilitĂ© dâexĂ©cuter les requĂȘtes entrantes. Depuis la version 10.1 de Tomcat, cette interface a deux implĂ©mentations. LâimplĂ©mentation historique StandardThreadExecutor
, qui sâappuie sur un pool de Threads workers et une BlockingQueue
de taille fixe pour les requĂȘtes entrantes, et la nouvelle implĂ©mentation StandardVirtualThreadExecutor
qui utilise un Virtual Thread pour exĂ©cuter chaque requĂȘte entrante.
En fouillant dans le code de Tomcat, on peut observer cette implémentation dans la classe VirtualThreadExecutor
, qui est utilisée par le StandardVirtualThreadExecutor
:
public class VirtualThreadExecutor extends AbstractExecutorService {
private Thread.Builder threadBuilder;
public VirtualThreadExecutor(String namePrefix) {
threadBuilder = Thread.ofVirtual().name(namePrefix, 0);
}
@Override
public void execute(Runnable command) {
if (isShutdown()) {
throw new RejectedExecutionException();
}
threadBuilder.start(command);
}
}
Il est par ailleurs surprenant que Tomcat ait choisi de développer son propre
ExecutorService
, au lieu dâutiliser celui construit parExecutors.newVirtualThreadPerTaskExecutor()
. Il semble que ce choix soit liĂ© Ă la gestion de lâarrĂȘt de lâExecutorService
qui est implémentée du cÎté duThreadPoolExecutor
.
Le benchmark
Dans cette section, nous allons tester les performances de deux versions de Tomcat :
- la version 10.1, sans support des Virtual Threads ;
- la version 11.0.0-M16, avec support des Virtual Threads activés.
Pour monter lâenvironnement de test, jâai installĂ© une version 21 de Java, en particulier le build eclipse-temurin disponible chez adoptium.net :
java --version
openjdk 21.0.1 2023-10-17 LTS
OpenJDK Runtime Environment Temurin-21.0.1+12 (build 21.0.1+12-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.1+12 (build 21.0.1+12-LTS, mixed mode, sharing)
Jâai aussi installĂ© les versions 10 et 11 de Tomcat :
- la derniĂšre version disponible de Tomcat 10, la 10.1.18 ;
- la derniĂšre version disponible de Tomcat 11, la 11.0.0-M16.
Ma machine de test est Ă©quipĂ©e dâun CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80 GHz et de 64 Go de RAM (!).
Les JVM sont démarrées avec les options -Xms512m -Xmx512m
pour positionner une taille de la heap à 512 Mo directement consommée.
Lâoption -XX:NativeMemoryTracking=summary
permet dâobserver la consommation mĂ©moire de la JVM, pour analyser plus finement les tailles de mĂ©moire rĂ©servĂ©es et consommĂ©es auprĂšs du systĂšme dâexploitation.
export CATALINA_OPTS='-Xms512m -Xmx512m -XX:NativeMemoryTracking=summary'
Je nâai pas positionnĂ© de paramĂ©trage propre au GC ou dâautres options, ce qui mâintĂ©resse ce sont uniquement la consommation de RAM liĂ©e aux Threads et les performances liĂ©es Ă des temps de rĂ©ponse aux requĂȘtes.
La configuration de Tomcat 11
Pour utiliser les Virtual Threads dans Tomcat 11, il faut paramĂ©trer lâExecutor de Tomcat pour activer la classe qui instancie les Virtual Threads, en lieu et place de lâimplĂ©mentation standard qui utilise un pool de Threads plateforme, et assigner lâExecutor au Connector en charge dâĂ©couter sur le port HTTP. Ce paramĂ©trage nâest pas actif par dĂ©faut. Il se fait dans le fichier settings.xml
, dans la balise <Service>
, comme indiqué dans la documentation :
<Service name="Catalina">
<Executor
name="virtualThreadsExecutor"
className="org.apache.catalina.core.StandardVirtualThreadExecutor" />
<Connector executor="virtualThreadsExecutor"
port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
...
</Service>
On paramĂštre donc le StandardVirtualThreadExecutor
comme devant traiter les requĂȘtes allouĂ©es au Connector Ă©coutant sur le port 8080
.
Aucune autre configuration nâest nĂ©cessaire sur le Tomcat 11. Aucune configuration particuliĂšre nâest faite sur le Tomcat 10.1 pour les tests.
Les performances attendues
On sâattend, entre Tomcat 10.1 et Tomcat 11, avec lâutilisation des Virtual Threads, Ă avoir une empreinte mĂ©moire rĂ©servĂ©e infĂ©rieure, ainsi que de meilleures performances Ă lâexĂ©cution des requĂȘtes. En principe, les Virtual Threads utilisĂ©s par Tomcat 11 ne devraient utiliser que quelques Threads plateforme hĂŽtes pour leur exĂ©cution, et donc limiter les context switches en cas de charge importante.
Démarrage et empreinte mémoire à vide
Tomcat 10.1
Tomcat 10.1 est démarré avec la commande startup.sh
:
./startup.sh
Using CATALINA_BASE: /opt/apache-tomcat-10.1.18
Using CATALINA_HOME: /opt/apache-tomcat-10.1.18
Using CATALINA_TMPDIR: /opt/apache-tomcat-10.1.18/temp
Using JRE_HOME: /opt/jdk-21.0.2+13
Using CLASSPATH: /opt/apache-tomcat-10.1.18/bin/bootstrap.jar:/opt/apache-tomcat-10.1.18/bin/tomcat-juli.jar
Using CATALINA_OPTS: -Xms512m -Xmx512m -XX:NativeMemoryTracking=summary
Tomcat started.
La rĂ©cupĂ©ration de lâempreinte mĂ©moire de notre Tomcat se fait Ă lâaide des commandes jps
et jcmd
:
# listing des JVM en cours d'exécution
$ jps -l
# récupération directe de l'identifiant lié à Tomcat
$ jps -l | grep -v 'jps' | cut -d ' ' -f 1
# récupération de l'empreinte mémoire
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2014475KB, committed=635935KB
malloc: 26831KB #72747
mmap: reserved=1987644KB, committed=609104KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=42108KB, committed=2792KB)
(thread #41)
(stack: reserved=41984KB, committed=2668KB)
(malloc=78KB #251) (peak=89KB #261)
(arena=46KB #80) (peak=317KB #52)
On observe que notre Heap est bien réservée à 512 Mo (524 288 KB), et que 41 Threads ont été démarrés (dont les 25 Threads liés à notre Executor
), pour une consommation de 41 Mo supplĂ©mentaires. Nous avons un total de mĂ©moire consommĂ©e de prĂšs de 630 Mo, car dâautres espaces sont rĂ©servĂ©s par la JVM (espaces de code, etc.).
En générant un peu de charge sur les applications exemples par défaut, on force Tomcat à instancier les Threads supplémentaires pour atteindre les 200 Threads.
La charge est générée avec la commande hey
, en utilisant 400 workers pour envoyer un million de requĂȘtes Ă la Servlet dâexemple.
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
On rĂ©cupĂšre ensuite lâempreinte mĂ©moire de notre Tomcat pour observer les nouvelles valeurs :
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2214510KB, committed=671762KB
malloc: 32306KB #93366
mmap: reserved=2182204KB, committed=639456KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=237307KB, committed=24319KB)
(thread #231)
(stack: reserved=236544KB, committed=23556KB)
(malloc=494KB #1403) (peak=506KB #1413)
(arena=269KB #460) (peak=317KB #52)
On observe que le nombre de Threads est passĂ© Ă 231, et quâon a maintenant plus de 230 Mo rĂ©servĂ©s pour les Threads.
Tomcat 11
Comme pour Tomcat 10.1, Tomcat 11 est démarré :
$ ./bin/startup.sh
Using CATALINA_BASE: /opt/apache-tomcat-11.0.0-M16
Using CATALINA_HOME: /opt/apache-tomcat-11.0.0-M16
Using CATALINA_TMPDIR: /opt/apache-tomcat-11.0.0-M16/temp
Using JRE_HOME: /opt/jdk-21.0.2+13
Using CLASSPATH: /opt/apache-tomcat-11.0.0-M16/bin/bootstrap.jar:/opt/apache-tomcat-11.0.0-M16/bin/tomcat-juli.jar
Using CATALINA_OPTS: -Xms512m -Xmx512m -XX:NativeMemoryTracking=summary
Tomcat started.
La consommation mémoire observée :
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2004350KB, committed=635010KB
malloc: 26946KB #72371
mmap: reserved=1977404KB, committed=608064KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=31835KB, committed=1719KB)
(thread #31)
(stack: reserved=31744KB, committed=1628KB)
(malloc=57KB #191) (peak=67KB #201)
(arena=34KB #60) (peak=317KB #52)
On observe quâĂ froid, moins de Threads sont allouĂ©s au dĂ©marrage, seulement 31 au lieu des 41 Threads dĂ©marrĂ©s par Tomcat 10.1.
AprÚs avoir passé une charge identique au test du Tomcat 10.1, toujours avec la commandehey
:
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
On rĂ©cupĂšre Ă nouveau lâempreinte mĂ©moire de Tomcat 11 :
$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2022976KB, committed=655120KB
malloc: 37380KB #88191
mmap: reserved=1985596KB, committed=617740KB
- Java Heap (reserved=524288KB, committed=524288KB)
(mmap: reserved=524288KB, committed=524288KB)
- Thread (reserved=40054KB, committed=2798KB)
(thread #39)
(stack: reserved=39936KB, committed=2680KB)
(malloc=74KB #239) (peak=87KB #255)
(arena=44KB #76) (peak=317KB #52)
On observe que Tomcat a instancié quelques Threads en plus, pour passer à 39 et on atteint donc les 39 Mo de stack alloués. On économise donc pas loin de 200 Mo comme attendu.
Attention, cette mĂ©moire est bien de la mĂ©moire rĂ©servĂ©e, et non pas lâempreinte de la mĂ©moire rĂ©elle consommĂ©e (dĂ©nommĂ©e committed). Les OS utilisent des mĂ©canismes de mĂ©moire virtuelle qui permettent de promettre de la mĂ©moire Ă un process qui la demande, mĂȘme si la mĂ©moire nâest pas disponible physiquement. Cette mĂ©moire nâest pas Ă©crite sur la RAM tant quâelle nâest pas rĂ©ellement consommĂ©e.
Comme on pouvait sây attendre, lâempreinte de la mĂ©moire rĂ©servĂ©e par Tomcat pour les Threads est plus faible. Cependant, comme cette mĂ©moire nâest pas utilisĂ©e, lâimpact sur les performances est faible. LâintĂ©rĂȘt des Virtual Threads ne rĂ©side pas principalement dans cette Ă©ventuelle Ă©conomie.
Performances avec une Servlet simple
Pour mesurer les performances de Tomcat 10 et 11, jâutilise la commande hey
, pour exĂ©cuter 1 million de requĂȘtes, dans 400 workers diffĂ©rents.
Notez que je lance cette commande sur la mĂȘme machine que ma machine de test, ce qui nâest clairement pas idĂ©al, mais câest suffisant pour ces tests.
Je requĂȘte la Servlet HelloWorldExample
, qui est fournie avec Tomcat. Cette Servlet affiche simplement une page web contenant le message Hello World.
Tomcat 10.1 - Threads Plateforme
hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample Summary: Total: 8.4899 secs Slowest: 0.0997 secs Fastest: 0.0000 secs Average: 0.0034 secs Requests/sec: 117787.3647 Total data: 387000000 bytes Size/request: 387 bytes Response time histogram: 0.000 [1] | 0.010 [991307] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.020 [8096] | 0.030 [167] | 0.040 [16] | 0.050 [22] | 0.060 [5] | 0.070 [0] | 0.080 [223] | 0.090 [129] | 0.100 [34] | Latency distribution: 10% in 0.0017 secs 25% in 0.0023 secs 50% in 0.0030 secs 75% in 0.0040 secs 90% in 0.0054 secs 95% in 0.0066 secs 99% in 0.0097 secs
Sur ce premier tir avec Tomcat 10.1, le temps moyen dâexĂ©cution est de 3,4 millisecondes, et 99 % des requĂȘtes ont reçu une rĂ©ponse en moins de 9,7 millisecondes.
Tomcat 11 - Virtual Threads
Le mĂȘme test a Ă©tĂ© lancĂ© sur Tomcat 11 configurĂ© avec des Virtual Threads :
hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample Summary: Total: 7.7188 secs Slowest: 0.1194 secs Fastest: 0.0000 secs Average: 0.0031 secs Requests/sec: 129554.4854 Total data: 387000000 bytes Size/request: 387 bytes Response time histogram: 0.000 [1] | 0.012 [998863] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.024 [588] | 0.036 [138] | 0.048 [10] | 0.060 [0] | 0.072 [102] | 0.084 [245] | 0.096 [13] | 0.107 [36] | 0.119 [4] | Latency distribution: 10% in 0.0015 secs 25% in 0.0021 secs 50% in 0.0028 secs 75% in 0.0037 secs 90% in 0.0048 secs 95% in 0.0056 secs 99% in 0.0080 secs
Le temps moyen dâexĂ©cution est de 3,1 millisecondes, et 99 % des rĂ©ponses ont Ă©tĂ© donnĂ©es en moins de 9 millisecondes. On a une amĂ©lioration des performances de prĂšs de 10 % pour une simple Servlet !
Nombre de requĂȘtes par seconde
Le plus élevé est le meilleur
Temps de réponse moyen
Le plus petit est le meilleur
On peut facilement interprĂ©ter cette amĂ©lioration. Les performances accrues sont probablement liĂ©es au fait que le systĂšme dâexploitation ne doit pas switcher entre lâexĂ©cution de 200 Threads en paralĂšlle dans le cas de Tomcat 11, ce qui occasionne donc plus de temps disponible, et donc des meilleurs temps de rĂ©ponse.
Performances avec une Servlet effectuant un appel bloquant
Pour aller un peu plus loin, nous allons exécuter un tir de performances similaire, avec une Servlet effectuant un appel bloquant de 50 millisecondes avec Thread.sleep(50)
:
public class ThreadInfo extends HttpServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
try {
Thread.sleep(50L); // fais dodo
} catch (InterruptedException ex) {
}
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
doGet(request, response);
}
}
Quel est lâimpact attendu ? Pour Tomcat 10.1, qui dispose de 200 Threads maximum, on sâattend Ă obtenir un dĂ©bit de 4 000 requĂȘtes par seconde maximum (200 Threads * 1 000 ms / 50 ms), donc un temps dâexĂ©cution total de 250 secondes (1 million de requĂȘtes / 4 000 req / s).
Pour Tomcat 11, non limitĂ© par des Threads, on sâattend Ă obtenir un dĂ©bit similaire au test de la Servlet prĂ©cĂ©dente sans les appels bloquants.
Tomcat 10.1 - Threads plateforme - appels bloquants
Le tir de performances sur Tomcat 10.1 donne le résultat suivant :
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo Summary: Total: 252.0313 secs Slowest: 0.1661 secs Fastest: 0.0501 secs Average: 0.1006 secs Requests/sec: 3967.7610 Total data: 133460003 bytes Size/request: 133 bytes Response time histogram: 0.050 [1] | 0.062 [24721] |â 0.073 [73] | 0.085 [12] | 0.097 [3051] | 0.108 [943320] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.120 [3356] | 0.131 [4993] | 0.143 [4193] | 0.155 [16063] |â 0.166 [217] | Latency distribution: 10% in 0.1002 secs 25% in 0.1004 secs 50% in 0.1006 secs 75% in 0.1010 secs 90% in 0.1016 secs 95% in 0.1024 secs 99% in 0.1451 secs
Les 250 secondes attendues pour le temps dâexĂ©cution sont bien rĂ©elles et on observe un dĂ©bit Ă 3 967 requĂȘtes par seconde. 99 % des requĂȘtes ont une rĂ©ponse en moins de 145 millisecondes. Cette performance nâest pas terrible, quand on met en lumiĂšre le fait que lâopĂ©ration bloquante nâest que de 50 millisecondes. La requĂȘte la plus rapide a bien Ă©tĂ© exĂ©cutĂ©e en 50 millisecondes, mais en moyenne, lâexĂ©cution est de 100 millisecondes.
Cette lenteur supplĂ©mentaire est liĂ©e au temps dâattente des requĂȘtes pour obtenir un Thread disponible. PassĂ© le premier lot de 200 requĂȘtes, les autres attendent 50 millisecondes avant dâobtenir un Thread, qui lui mĂȘme bloque pendant 50 millisecondes le traitement dâautres requĂȘtes. Le dĂ©bit observĂ© de moins de 4 000 requĂȘtes par seconde est bien liĂ© Ă la contrainte des 200 Threads bloquĂ©s et occupĂ©s pendant 50 millisecondes chacun.
Tomcat 11 - Virtual Threads - appels bloquants
Le mĂȘme tir de performances sur Tomcat 11 configurĂ© avec les Virtual Threads donne un rĂ©sultat complĂštement diffĂ©rent :
$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo Summary: Total: 126.8828 secs Slowest: 0.3636 secs Fastest: 0.0501 secs Average: 0.0507 secs Requests/sec: 7881.2884 Total data: 129884989 bytes Size/request: 129 bytes Response time histogram: 0.050 [1] | 0.081 [999544] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.113 [115] | 0.144 [93] | 0.175 [114] | 0.207 [132] | 0.238 [0] | 0.270 [0] | 0.301 [0] | 0.332 [0] | 0.364 [1] | Latency distribution: 10% in 0.0502 secs 25% in 0.0503 secs 50% in 0.0504 secs 75% in 0.0507 secs 90% in 0.0512 secs 95% in 0.0517 secs 99% in 0.0544 secs
On observe que le temps moyen de rĂ©ponse Ă une requĂȘte est bien de 50 millisecondes. Aucune surcharge liĂ©e Ă du context switch nâest observĂ©e ici. 99 % des requĂȘtes obtiennent une rĂ©ponse en 54 millisecondes.
Attention cependant, on observe que le dĂ©bit est de seulement 7 900 requĂȘtes par seconde. La limitation ici est liĂ©e au nombre de workers positionnĂ© Ă 400 sur la ma commande hey
. La commande nâenvoie pas suffisamment de requĂȘtes pour atteindre le dĂ©bit thĂ©orique maximum.
Un second test avec le nombre de workers Ă 1 000 permet dâobserver la diffĂ©rence de dĂ©bit :
$ hey -c 1000 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo Summary: Total: 50.8318 secs Slowest: 0.2331 secs Fastest: 0.0501 secs Average: 0.0507 secs Requests/sec: 19672.7068 Total data: 128987581 bytes Size/request: 130 bytes Response time histogram: 0.050 [1] | 0.068 [983411] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.087 [277] | 0.105 [59] | 0.123 [28] | 0.142 [27] | 0.160 [0] | 0.178 [124] | 0.197 [271] | 0.215 [309] | 0.233 [205] | Latency distribution: 10% in 0.0501 secs 25% in 0.0502 secs 50% in 0.0502 secs 75% in 0.0504 secs 90% in 0.0512 secs 95% in 0.0523 secs 99% in 0.0568 secs
Avec 1 000 workers, le temps moyen de rĂ©ponse reste autour de 50 millisecondes. 99 % des requĂȘtes reçoivent une rĂ©ponse en moins de 58 millisecondes. Le dĂ©bit passe Ă 19 000 requĂȘtes par seconde !
On atteint malheureusement ici les limites de ma machine, puisque à ce stade quelques erreurs sont observées : dial tcp 127.0.0.1:8080: socket: too many open files
.
Cependant, ces performances laissent deviner quâil serait possible dâaller encore plus loin.
Bonus, avec Spring Boot 3
đ€ « Julien, tu es bien gentil avec tes Servlets, mais plus personne nâen dĂ©veloppe. »
Cette partie «bonus» teste le mĂȘme comportement, mais avec Spring Boot 3 !
Configurer Spring Boot 3
Malheureusement, il nâest pas possible pour le moment dâutiliser Tomcat 11 avec Spring Boot 3. NĂ©anmoins, Spring Boot 3 a intĂ©grĂ© le support des Virtual Threads et de lâExecutor VirtualThreadExecutor Ă Tomcat 10 !
Pour utiliser les Virtual Threads dans Spring Boot 3, il faut positionner la properties suivante :
spring.threads.virtual.enabled=true
Aucune autre modification nâest nĂ©cessaire !
Pour comprendre comment cette properties opĂšre sa magie, il faut parcourir le code de Spring Boot.
Cette properties est interprĂ©tĂ©e par lâannotation @ConditionalOnThreading
et configure un TomcatVirtualThreadsWebServerFactoryCustomizer
:
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Tomcat.class, UpgradeProtocol.class })
public static class TomcatWebServerFactoryCustomizerConfiguration {
@Bean
@ConditionalOnThreading(Threading.VIRTUAL)
TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
return new TomcatVirtualThreadsWebServerFactoryCustomizer();
}
}
Le TomcatVirtualThreadsWebServerFactoryCustomizer
configure le Tomcat embedded pour utiliser lâExecutor VirtualThreadExecutor
:
public class TomcatVirtualThreadsWebServerFactoryCustomizer
implements WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>, Ordered {
@Override
public void customize(ConfigurableTomcatWebServerFactory factory) {
factory.addProtocolHandlerCustomizers(
(protocolHandler) -> protocolHandler.setExecutor(new VirtualThreadExecutor("tomcat-handler-")));
}
}
Dans notre code, un simple @Controller
Spring permet de recréer le comportement équivalent à la Servlet utilisée pour le benchmark précédent :
@RestController
public class ThreadController {
@GetMapping("/")
String getThreadName() throws InterruptedException {
Thread.sleep(50L); // gros dodo
return Thread.currentThread().getName();
}
}
Avec la properties spring.threads.virtual.enabled=false
, on obtient les performances suivantes, similaires Ă ce quâon avait en utilisant Tomcat 10.1, sans support des Virtual Threads :
$ hey -c 400 -n 1000000 http://localhost:8080 Summary: Total: 253.9172 secs Slowest: 0.2498 secs Fastest: 0.0501 secs Average: 0.1013 secs Requests/sec: 3938.2910 Total data: 21459960 bytes Size/request: 21 bytes Response time histogram: 0.050 [1] | 0.070 [22735] |â 0.090 [353] | 0.110 [949895] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.130 [7069] | 0.150 [19591] |â 0.170 [67] | 0.190 [74] | 0.210 [99] | 0.230 [46] | 0.250 [70] | Latency distribution: 10% in 0.1004 secs 25% in 0.1008 secs 50% in 0.1014 secs 75% in 0.1021 secs 90% in 0.1036 secs 95% in 0.1058 secs 99% in 0.1352 secs
Les temps de rĂ©ponse sont autour de 100 millisecondes, pour un dĂ©bit de moins de 4 000 requĂȘtes par seconde, et 99 % des requĂȘtes reçoivent une rĂ©ponse en moins de 135 millisecondes.
Avec la properties spring.threads.virtual.enabled=true
, on obtient les performances suivantes, qui sont similaires aux performances de Tomcat 11 avec les Virtual Threads :
$ hey -c 400 -n 1000000 http://localhost:8080 Summary: Total: 126.7462 secs Slowest: 0.1738 secs Fastest: 0.0501 secs Average: 0.0507 secs Requests/sec: 7889.7847 Total data: 20941836 bytes Size/request: 20 bytes Response time histogram: 0.050 [1] | 0.062 [999571] |â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â â 0.075 [15] | 0.087 [68] | 0.100 [0] | 0.112 [65] | 0.124 [0] | 0.137 [61] | 0.149 [57] | 0.161 [16] | 0.174 [146] | Latency distribution: 10% in 0.0502 secs 25% in 0.0503 secs 50% in 0.0504 secs 75% in 0.0507 secs 90% in 0.0513 secs 95% in 0.0519 secs 99% in 0.0539 secs
Les temps de rĂ©ponse sont autour de 50 millisecondes, pour un dĂ©bit dâun peu moins de 8 000 requĂȘtes par seconde, et 99 % des requĂȘtes obtiennent une rĂ©ponse en moins de 53 millisecondes !
Nombre de requĂȘtes par seconde
Le plus élevé est le meilleur
Conclusion
Les résultats sont impressionnants. En utilisant le VirtualThreadExecutor
, dans Tomcat 11, on observe dĂ©jĂ 10 % de gains de performances sans rien faire de particulier, pour des Servlets nâeffectuant pas dâappel bloquant.
Mais câest vraiment Ă partir du moment oĂč des appels bloquants sont effectuĂ©s que les gains de performances sont les plus importants. Sur un Tomcat avec 200 Threads plateforme, une fois les 200 Threads bloquĂ©s, les autres requĂȘtes sont mises en attente, ce qui occasionne des temps de rĂ©ponse moyens plus longs. Ces impacts semblent purement annulĂ©s avec lâutilisation des Virtual Threads, puisque le nombre de Threads nâest plus limitĂ©. Le dĂ©bit thĂ©orique dâune application nâest maintenant plus limitĂ© par son nombre de Threads.
Pour aller plus loin, lâutilisation des Virtual Threads dans Tomcat rendrait presque inutile lâutilisation des approches de programmation rĂ©active. Le fait de rendre les Threads peu coĂ»teux Ă instancier, liĂ© Ă leur mode dâexĂ©cution sur un _Thread_hĂŽte, limite la charge dĂ©portĂ©e sur le systĂšme dâexploitation en context switches, et maximise lâutilisation du CPU.
Il nâest maintenant plus problĂ©matique de bloquer un Thread.
On peut déjà bénéficier de ces améliorations de performances avec Spring Boot 3 et Tomcat 10.1, à condition de bien utiliser une JVM 21. Donc pourquoi se priver ?
Ă suivre lors de la sortie future de Tomcat 11, quelle en sera lâintĂ©gration dans Spring Boot. Spring Boot ayant annoncĂ© supporter Java 17 en version de base, la properties spring.threads.virtual.enabled
restera toujours disponible, avec probablement une valeur false
par défaut.
Liens et références
- JEP 320 - Suppression des modules Java EE et CORBA
- JEP 444 - Virtual Threads
- Documentation de Tomcat
- RELEASE-NOTES Tomcat 11.0.0-M16
- Tomcat 11 Virtual Thread Implementation - Configuration des Virtual Threads dans Tomcat
- Virtual Threads - Configuration des Virtual Threads dans Spring Boot
- Programmation Concurrente et Asynchrone : Loom en Java 20 et 21 - José Paumard
- JMH : Java Microbenchmark Harness
- hey : HTTP load generator, ApacheBench (ab) replacement
- Photo de couverture par Ache Dipro sur Unsplash