Tomcat 11 & Virtual Threads đŸ§”

- Mis Ă  jour le - 23 minutes de lecture

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 par Executors.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Ă© du ThreadPoolExecutor.

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 !

Le plus élevé est le meilleur

Nombre de requĂȘtes par seconde

Le plus élevé est le meilleur

Le plus petit 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 !

Le plus élevé est le meilleur

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