···11+---
22+created: "2024-02-05"
33+date: "2024-02-05"
44+language: fr
55+tags:
66+ - Java
77+title: Tomcat 11 & Virtual Threads
88+---
99+1010+
1111+1212+_Apache Tomcat_ est le plus célèbre des conteneurs de _Servlets_ Java.
1313+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 coeur de la majorité de nos micro-services, parfois sans que les développeurs ne s'en rendent compte.
1414+1515+Chaque version majeure de _Tomcat_ apporte le support des nouvelles versions des API 'Java EE' ou 'JEE'.
1616+1717+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 suppression des packages `javax` liés à _Java EE_ a eu lieu. Les modules supprimés sont documentés dans la [JEP 320](https://openjdk.org/jeps/320). on y retrouve les tristement célèbres `java.xml.bind`, `javax.transcation` et `javax.activation`, qui ont donné du fil à retordre aux développeurs souhaitant migrer leurs applications.
1818+1919+Les versions de _Tomcat_ sont donc à chaque fois compatibles avec une version minimale de Java, et des API `jakarta`.
2020+Le tableau ci-dessous reprend la liste des versions compatibles :
2121+2222+| **Servlet Spec** | **Apache Tomcat Version** | **Supported Java Versions** | **Release date** |
2323+| ---------------- | ------------------------- | --------------------------- | ---------------- |
2424+| 6.1 | 11.0.x | 21 and later | (version alpha) |
2525+| 6.0 | 10.1.x | 11 and later | dec. 2020 |
2626+| 4.0 | 9.0.x | 8 and later | oct. 2017 |
2727+| 3.1 | 8.5.x | 7 and later | jan. 2014 |
2828+| 3.0 | 7.0.x (archived) | 6 and later | jan. 2011 |
2929+3030+La version 11 de _Tomcat_ est donc dédiée à la version 21 de Java.
3131+Cette stratégie n'est pas surprenante en soit, la version 21 étant la dernière version LTS en date.
3232+3333+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](https://archive.apache.org/dist/tomcat/tomcat-11/v11.0.0-M1/RELEASE-NOTES)). 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](https://archive.apache.org/dist/tomcat/tomcat-11/v11.0.0-M3/RELEASE-NOTES)).
3434+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](https://archive.apache.org/dist/tomcat/tomcat-11/v11.0.0-M7/RELEASE-NOTES)).
3535+3636+La version actuelle est la _milestone_ 16, publiée le 9 janvier 2024. C'est cette version qui sera testée dans cet article.
3737+3838+Un des principaux avantages de cette version 11, liée à Java 21 est le support des _Virtual Threads_. Bien que le code nécessaire a é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.
3939+4040+## C'est quoi les _Virtual Threads_ ?
4141+4242+Avant d'explorer l'implémentation de _Tomcat_ et son usage des _Virtual Threads_, un rapide rappel de ce qu'ils sont et de comment ils fonctionnent.
4343+4444+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.
4545+4646+> Une excellente conférence de José Paumard sur le projet Loom, qui introduit les _Threads Virtuels_ en Java est visible sur [Youtube](https://www.youtube.com/watch?v=v7DzKOniNh0). Cette vidéo est une très bonne introduction à ce sujet.
4747+4848+### Les Threads _Plateforme_
4949+5050+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 dédiée, appellée la _Stack_. Il redonne ensuite la main au programme pourqu'il continue son exécution.
5151+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.
5252+5353+Lors de la création d'un _Thread_, le système d'exploitation doit donc effectuer plusieurs _context switchs_, et allouer un peu de mémoire au _Thread_. Ces étapes ont donc un coût, en temps et en mémoire.
5454+5555+#### Le coût en temps
5656+5757+Le temps de création d'un _Thread_ dépend principalement du système d'exploitation et de sa charge actuelle.
5858+Pour mesurer ce temps, un benchmark écrit avec l'outil [JMH](https://github.com/openjdk/jmh) (dont l'utilisation vaudrait un article à elle seule) permet d'estimer le temps de démarrage d'un _Thread_ Java sur une machine :
5959+6060+```java
6161+@BenchmarkMode(Mode.AverageTime)
6262+@OutputTimeUnit(TimeUnit.MILLISECONDS)
6363+public class ThreadsBenchmark {
6464+6565+ @Benchmark
6666+ public void computeInMainThread(){
6767+ // un calcul quelconque
6868+ Blackhole.consumeCPU(1024);
6969+ }
7070+7171+ @Benchmark
7272+ public void computeInPlatformThread() throws InterruptedException {
7373+ // exécution dans un thread plateforme dédié
7474+ var thread = Thread.ofPlatform().start(() -> {
7575+ Blackhole.consumeCPU(1024);
7676+ });
7777+ thread.join(); // attente de la fin de l'exécution
7878+ }
7979+8080+ public static void main(String[] args) throws RunnerException {
8181+ Options opt = new OptionsBuilder()
8282+ .include(ThreadsBenchmark.class.getSimpleName())
8383+ .warmupIterations(1) // une itération de pré-chauffage de la JVM
8484+ .measurementIterations(3) // 3 itérations de mesure
8585+ .forks(1)
8686+ .build();
8787+8888+ new Runner(opt).run();
8989+ }
9090+9191+}
9292+```
9393+9494+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 (warmup), et 3 fois pour mesurer les performances réelles. Le `fork(1)` permet de créer une JVM dédiée à l'exécution des tests.
9595+9696+La première méthode effectue un calcul au combien inutile, à travers la classe `Blackhole` fournie par JMH. La seconde méthode effectue ce même calcul, mais dans un _Thread_ plateforme dédié et attend la fin 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.
9797+9898+Le résultat de l'exécution du benchmark est le suivant :
9999+100100+```bash
101101+Benchmark Mode Cnt Score Error Units
102102+ThreadsBenchmark.computeInMainThread avgt 3 0.002 ± 0.001 ms/op
103103+ThreadsBenchmark.computeInPlatformThread avgt 3 0.038 ± 0.015 ms/op
104104+```
105105+106106+On observe que le `Blackhole.consumeCPU(1024)` s'exécute en moyenne en 0.002 millisecondes. L'exécution de la même instruction dans un _Thread_ dédié se fait en 0.038 millisecondes. Le surcoût lié à la création et destruction du _Thread_ est donc de 0.036 millisecondes.
107107+108108+> Créer un Thread pour effectuer un calcul peut donc être contre-productif !
109109+110110+#### Le coût en mémoire
111111+112112+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.
113113+114114+La commande suivante permet de constater les valeurs par défaut de la mémoire d'un _Thread_ Java :
115115+116116+```bash
117117+$ java -XX:+PrintFlagsFinal --version | grep -i ThreadStack
118118+119119+intx CompilerThreadStackSize = 1024 {pd product} {default}
120120+intx ThreadStackSize = 1024 {pd product} {default}
121121+intx VMThreadStackSize = 1024 {pd product} {default}
122122+```
123123+124124+La valeur est exprimé en kilo-octets. Un _Thread_ réservera donc 1024 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.
125125+126126+### Les Threads _Virtuels_
127127+128128+Les _Threads Virtuels_ 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 _Thread Virtuel_ n'implique donc pas forcément la création d'un _Thread_ plateforme.
129129+Le coût de création d'un _Thread Virtuel_ est donc bien inférieur à un _Thread_ plateforme, puisqu'il ne nécessite pas de _context switch_, ni d'allocation d'un bloc de mémoire dédié.
130130+131131+On peut mesurer le coût temporel de la création d'un _Thread Virtuel_ en ajoutant cette méthode à notre benchmark précédent :
132132+133133+```java
134134+@Benchmark
135135+public void computeInVirtualThread() throws InterruptedException {
136136+ var thread = Thread.ofVirtual().start(() -> {
137137+ Blackhole.consumeCPU(1024);
138138+ });
139139+ thread.join();
140140+}
141141+```
142142+143143+Notez l'usage de `Thread.ofVirtual()` pour créer un _Thread Virtuel_ en lieu et place du `Thread.ofPlatform()`.
144144+145145+Les durées d'exécution observées sont les suivantes :
146146+147147+```bash
148148+Benchmark Mode Cnt Score Error Units
149149+ThreadsBenchmark.computeInMainThread avgt 3 0.002 ± 0.001 ms/op
150150+ThreadsBenchmark.computeInPlatformThread avgt 3 0.037 ± 0.013 ms/op
151151+ThreadsBenchmark.computeInVirtualThread avgt 3 0.005 ± 0.002 ms/op
152152+```
153153+154154+Le benchmark utilisant les _Threads Virtuels_ présente un surcoût d'exécution de 0.002 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_.
155155+156156+> Le coût d'exécution en temps d'un _Thread Virtuel_ est donc 15 fois inférieur à un Thread plateforme.
157157+158158+Notes qu'avant l'avènement des _Threads Virtuels_, 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 re-créer.
159159+160160+## L'implémentation de Tomcat
161161+162162+
163163+164164+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 _Thread Virtuel_ pour exécuter chaque requête entrante.
165165+166166+En farfouillant dans le code de Tomcat, on peut observer cette implémentation dans la classe `VirtualThreadExecutor`, qui est utilisée par le `StandardVirtualThreadExecutor` :
167167+168168+```java
169169+public class VirtualThreadExecutor extends AbstractExecutorService {
170170+171171+ private Thread.Builder threadBuilder;
172172+173173+ public VirtualThreadExecutor(String namePrefix) {
174174+ threadBuilder = Thread.ofVirtual().name(namePrefix, 0);
175175+ }
176176+177177+ @Override
178178+ public void execute(Runnable command) {
179179+ if (isShutdown()) {
180180+ throw new RejectedExecutionException();
181181+ }
182182+ threadBuilder.start(command);
183183+ }
184184+}
185185+```
186186+187187+> Il est par ailleurs surprenant que Tomcat aie 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`.
188188+189189+## Le benchmark
190190+191191+Dans cette section, nous allons tester les performances de deux versions de Tomcat :
192192+193193+- la version 10.1, sans support des _Threads Virtuels_
194194+- la version 11.0.0-M16, avec support des _Threads Virtuels_ activés
195195+196196+Pour monter l'environnement de test, j'ai installé une version 21 de Java, en particulier le build _eclipse-temurin_ disponible chez [adoptium.net](https://adoptium.net/temurin/releases/?version=21) :
197197+198198+```bash
199199+java --version
200200+openjdk 21.0.1 2023-10-17 LTS
201201+OpenJDK Runtime Environment Temurin-21.0.1+12 (build 21.0.1+12-LTS)
202202+OpenJDK 64-Bit Server VM Temurin-21.0.1+12 (build 21.0.1+12-LTS, mixed mode, sharing)
203203+```
204204+205205+J'ai aussi installé les version 10 et 11 de Tomcat :
206206+207207+- la dernière version disponible de [Tomcat 10](https://tomcat.apache.org/download-10.cgi), la 10.1.18
208208+- la dernière version disponible de [Tomcat 11](https://tomcat.apache.org/download-11.cgi), la 11.0.0-M16
209209+210210+Ma machine de test est équipée d'un CPU _11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz_ et de 64Go de RAM (!).
211211+212212+Les JVM sont démarrées avec l'otion suivantes `-Xms512m -Xmx512m` pour positionner une taille de la heap à 512 Mo directement consommée.
213213+L'option `-XX:NativeMemoryTracking=summary` permet d'observer la consommation mémoire de la JVM.
214214+215215+```bash
216216+export CATALINA_OPTS='-Xms512m -Xmx512m -XX:NativeMemoryTracking=summary'
217217+```
218218+219219+> 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.
220220+221221+### La configuration de Tomcat 11
222222+223223+_Pour_ utiliser les _Threads Virtuels_ dans Tomcat 11, il faut paramétrer l'_Executor_ de Tomcat pour utiliser la classe qui instancie de _Threads Virtuels_, en lieu et place de l'implémentation standard qui utilise un pool de _Threads Plateforme_, et assigner l'exécutor au _Connector_. 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](https://tomcat.apache.org/tomcat-11.0-doc/config/executor.html#Virtual_Thread_Implementation) :
224224+225225+```xml
226226+<Service name="Catalina">
227227+228228+ <Executor
229229+ name="virtualThreadsExecutor"
230230+ className="org.apache.catalina.core.StandardVirtualThreadExecutor" />
231231+232232+233233+ <Connector executor="virtualThreadsExecutor"
234234+ port="8080" protocol="HTTP/1.1"
235235+ connectionTimeout="20000"
236236+ redirectPort="8443" />
237237+238238+ ...
239239+240240+</Service>
241241+```
242242+243243+On paramètre donc le `StandardVirtualThreadExecutor` comme devant traiter les requêtes allouées au _Connector_ écoutant sur le port `8080`.
244244+245245+Aucune autre configuration n'est nécessaire. Aucune configuration particulière n'est fait sur le Tomcat 10.1.
246246+247247+### Les perfs attendues
248248+249249+On s'attend, entre Tomcat 10.1 et Tomcat 11, avec l'utilisation des _Threads Virtuels_, d'avoir une empreinte mémoire réservée inférieure, ainsi que de meilleures performances à l'exécution des requêtes.
250250+En principe, les _Threads Virtuels_ ne devraient utiliser que quelques _Threads Plateforme_, et donc limiter les _context switch_ en cas de charge importante.
251251+252252+### Démarrage et empreinte mémoire à vide
253253+254254+#### Tomcat 10.1
255255+256256+Le Tomcat 10.1 est démarré avec la commande `startup.sh` :
257257+258258+```bash
259259+./startup.sh
260260+Using CATALINA_BASE: /opt/apache-tomcat-10.1.18
261261+Using CATALINA_HOME: /opt/apache-tomcat-10.1.18
262262+Using CATALINA_TMPDIR: /opt/apache-tomcat-10.1.18/temp
263263+Using JRE_HOME: /opt/jdk-21.0.2+13
264264+Using CLASSPATH: /opt/apache-tomcat-10.1.18/bin/bootstrap.jar:/opt/apache-tomcat-10.1.18/bin/tomcat-juli.jar
265265+Using CATALINA_OPTS: -Xms512m -Xmx512m -XX:NativeMemoryTracking=summary
266266+Tomcat started.
267267+```
268268+269269+L'empreinte mémoire de notre _Tomcat_ se fait avec la séquence de commandes :
270270+271271+```bash
272272+# listing des JVM en cours d'exécution
273273+$ jps -l
274274+275275+# récupération directe de l'identifiant lié à Tomcat
276276+$ jps -l | grep -v 'jps' | cut -d ' ' -f 1
277277+# récupération de l'empreinte mémoire
278278+$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
279279+280280+Native Memory Tracking:
281281+282282+(Omitting categories weighting less than 1KB)
283283+284284+Total: reserved=2014475KB, committed=635935KB
285285+ malloc: 26831KB #72747
286286+ mmap: reserved=1987644KB, committed=609104KB
287287+288288+- Java Heap (reserved=524288KB, committed=524288KB)
289289+ (mmap: reserved=524288KB, committed=524288KB)
290290+291291+- Thread (reserved=42108KB, committed=2792KB)
292292+ (thread #41)
293293+ (stack: reserved=41984KB, committed=2668KB)
294294+ (malloc=78KB #251) (peak=89KB #261)
295295+ (arena=46KB #80) (peak=317KB #52)
296296+297297+```
298298+299299+On observe que notre Heap est bien réservée à 512 Mo (524288KB), 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...).
300300+301301+En générant un peu de charge sur les applis exemples par défaut, on force _Tomcat_ à instancier les _Threads_ supplémentaires pour atteindre les 200 _Threads_.
302302+303303+```bash
304304+$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
305305+306306+$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
307307+308308+Native Memory Tracking:
309309+310310+(Omitting categories weighting less than 1KB)
311311+312312+Total: reserved=2214510KB, committed=671762KB
313313+ malloc: 32306KB #93366
314314+ mmap: reserved=2182204KB, committed=639456KB
315315+316316+- Java Heap (reserved=524288KB, committed=524288KB)
317317+ (mmap: reserved=524288KB, committed=524288KB)
318318+319319+- Thread (reserved=237307KB, committed=24319KB)
320320+ (thread #231)
321321+ (stack: reserved=236544KB, committed=23556KB)
322322+ (malloc=494KB #1403) (peak=506KB #1413)
323323+ (arena=269KB #460) (peak=317KB #52)
324324+```
325325+326326+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_.
327327+328328+#### Tomcat 11
329329+330330+Comme pour le Tomcat 10.1, le Tomcat 11 est démarré :
331331+332332+```bash
333333+$ ./bin/startup.sh
334334+Using CATALINA_BASE: /opt/apache-tomcat-11.0.0-M16
335335+Using CATALINA_HOME: /opt/apache-tomcat-11.0.0-M16
336336+Using CATALINA_TMPDIR: /opt/apache-tomcat-11.0.0-M16/temp
337337+Using JRE_HOME: /opt/jdk-21.0.2+13
338338+Using CLASSPATH: /opt/apache-tomcat-11.0.0-M16/bin/bootstrap.jar:/opt/apache-tomcat-11.0.0-M16/bin/tomcat-juli.jar
339339+Using CATALINA_OPTS: -Xms512m -Xmx512m -XX:NativeMemoryTracking=summary
340340+Tomcat started.
341341+```
342342+343343+La consommation mémoire observée :
344344+345345+```bash
346346+$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
347347+348348+Native Memory Tracking:
349349+350350+(Omitting categories weighting less than 1KB)
351351+352352+Total: reserved=2004350KB, committed=635010KB
353353+ malloc: 26946KB #72371
354354+ mmap: reserved=1977404KB, committed=608064KB
355355+356356+- Java Heap (reserved=524288KB, committed=524288KB)
357357+ (mmap: reserved=524288KB, committed=524288KB)
358358+359359+- Thread (reserved=31835KB, committed=1719KB)
360360+ (thread #31)
361361+ (stack: reserved=31744KB, committed=1628KB)
362362+ (malloc=57KB #191) (peak=67KB #201)
363363+ (arena=34KB #60) (peak=317KB #52)
364364+```
365365+366366+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.
367367+368368+Après avoir passé une charge identique au test du Tomcat 10.1 :
369369+370370+```bash
371371+$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
372372+373373+$ jcmd $(jps -l | grep -v 'jps' | cut -d ' ' -f 1) VM.native_memory
374374+375375+Native Memory Tracking:
376376+377377+(Omitting categories weighting less than 1KB)
378378+379379+Total: reserved=2022976KB, committed=655120KB
380380+ malloc: 37380KB #88191
381381+ mmap: reserved=1985596KB, committed=617740KB
382382+383383+- Java Heap (reserved=524288KB, committed=524288KB)
384384+ (mmap: reserved=524288KB, committed=524288KB)
385385+386386+- Thread (reserved=40054KB, committed=2798KB)
387387+ (thread #39)
388388+ (stack: reserved=39936KB, committed=2680KB)
389389+ (malloc=74KB #239) (peak=87KB #255)
390390+ (arena=44KB #76) (peak=317KB #52)
391391+392392+```
393393+394394+On observe que Tomcat a instancié quelques _Threads_ en plus, pour passer à 39 et on atteint donc les 39 Mo de stack allouée.
395395+On économise donc pas loin de 200 Mo comme attendu.
396396+397397+> 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. Cette mémoire n'est pas écrite sur la RAM tant qu'elle n'est pas réellement consommée.
398398+399399+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 _Threads Virtuels_ ne réside pas principalement dans cette éventuelle économie.
400400+401401+### Performances avec une Servlet simple
402402+403403+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.
404404+405405+> 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.
406406+407407+Je requête la servlet `HelloWorldExample`, qui est fournie avec Tomcat. Cette servlet affiche simplement une page web contenant le message _Hello World_.
408408+409409+#### Tomcat 10.1 - Threads Plateforme
410410+411411+```
412412+hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
413413+414414+Summary:
415415+ Total: 8.4899 secs
416416+ Slowest: 0.0997 secs
417417+ Fastest: 0.0000 secs
418418+ Average: 0.0034 secs
419419+ Requests/sec: 117787.3647
420420+421421+ Total data: 387000000 bytes
422422+ Size/request: 387 bytes
423423+424424+Response time histogram:
425425+ 0.000 [1] |
426426+ 0.010 [991307] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
427427+ 0.020 [8096] |
428428+ 0.030 [167] |
429429+ 0.040 [16] |
430430+ 0.050 [22] |
431431+ 0.060 [5] |
432432+ 0.070 [0] |
433433+ 0.080 [223] |
434434+ 0.090 [129] |
435435+ 0.100 [34] |
436436+437437+438438+Latency distribution:
439439+ 10% in 0.0017 secs
440440+ 25% in 0.0023 secs
441441+ 50% in 0.0030 secs
442442+ 75% in 0.0040 secs
443443+ 90% in 0.0054 secs
444444+ 95% in 0.0066 secs
445445+ 99% in 0.0097 secs
446446+447447+```
448448+449449+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.
450450+451451+#### Tomcat 11 - Threads Virtuels
452452+453453+Le même test a été lancé sur Tomcat 11 configuré avec des _Threads Virtuels_ :
454454+455455+```
456456+hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/HelloWorldExample
457457+458458+Summary:
459459+ Total: 7.7188 secs
460460+ Slowest: 0.1194 secs
461461+ Fastest: 0.0000 secs
462462+ Average: 0.0031 secs
463463+ Requests/sec: 129554.4854
464464+465465+ Total data: 387000000 bytes
466466+ Size/request: 387 bytes
467467+468468+Response time histogram:
469469+ 0.000 [1] |
470470+ 0.012 [998863] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
471471+ 0.024 [588] |
472472+ 0.036 [138] |
473473+ 0.048 [10] |
474474+ 0.060 [0] |
475475+ 0.072 [102] |
476476+ 0.084 [245] |
477477+ 0.096 [13] |
478478+ 0.107 [36] |
479479+ 0.119 [4] |
480480+481481+482482+Latency distribution:
483483+ 10% in 0.0015 secs
484484+ 25% in 0.0021 secs
485485+ 50% in 0.0028 secs
486486+ 75% in 0.0037 secs
487487+ 90% in 0.0048 secs
488488+ 95% in 0.0056 secs
489489+ 99% in 0.0080 secs
490490+```
491491+492492+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.
493493+On a une amélioration de performances de près de 10% pour une simple servlet !
494494+495495+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.
496496+497497+### Performances avec une Servlet effectuant un appel bloquant
498498+499499+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 : `Thread.sleep(50)` :
500500+501501+```java
502502+public class ThreadInfo extends HttpServlet {
503503+504504+ @Override
505505+ public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
506506+ response.setContentType("text/html");
507507+ response.setCharacterEncoding("UTF-8");
508508+509509+ PrintWriter out = response.getWriter();
510510+ out.println("<!DOCTYPE html><html>");
511511+ out.println("<head>");
512512+ out.println("<meta charset=\"UTF-8\" />");
513513+514514+ out.println("<title>Thread info</title>");
515515+ out.println("</head>");
516516+ out.println("<body><h1>" + Thread.currentThread().getName() + "</h1></body>");
517517+518518+ try {
519519+ Thread.sleep(50L); // fais dodo
520520+ } catch (InterruptedException ex) {
521521+ }
522522+ }
523523+524524+ @Override
525525+ public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
526526+ doGet(request, response);
527527+ }
528528+529529+}
530530+```
531531+532532+Quel est l'impact attendu ?
533533+Pour Tomcat 10.1, qui dispose de 200 _Threads_ maximum, on s'attend à obtenir un débit de 4000 requêtes par secondes maximum (200 threads \* 1000 ms / 50 ms ), donc un temps d'exécution total de 250 secondes (1 million de requêtes / 4000 req / seconde ).
534534+Pour Tomcat 11, non limité par des _Threads_, on s'attend à obtenir un débit similaire au test précédent.
535535+536536+#### Tomcat 10.1 - Threads Plateforme - Appels Bloquants
537537+538538+Le tir de performances sur le Tomcat 10.1 donne le résultat suivant :
539539+540540+```
541541+$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo
542542+543543+Summary:
544544+ Total: 252.0313 secs
545545+ Slowest: 0.1661 secs
546546+ Fastest: 0.0501 secs
547547+ Average: 0.1006 secs
548548+ Requests/sec: 3967.7610
549549+550550+ Total data: 133460003 bytes
551551+ Size/request: 133 bytes
552552+553553+Response time histogram:
554554+ 0.050 [1] |
555555+ 0.062 [24721] |■
556556+ 0.073 [73] |
557557+ 0.085 [12] |
558558+ 0.097 [3051] |
559559+ 0.108 [943320] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
560560+ 0.120 [3356] |
561561+ 0.131 [4993] |
562562+ 0.143 [4193] |
563563+ 0.155 [16063] |■
564564+ 0.166 [217] |
565565+566566+567567+Latency distribution:
568568+ 10% in 0.1002 secs
569569+ 25% in 0.1004 secs
570570+ 50% in 0.1006 secs
571571+ 75% in 0.1010 secs
572572+ 90% in 0.1016 secs
573573+ 95% in 0.1024 secs
574574+ 99% in 0.1451 secs
575575+```
576576+577577+Les 250 secondes attendues sont bien réelles et on observe un débit à 3967 requêtes par secondes. 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.
578578+579579+Cette lenteur supplémentaire est probablement liée au _context switch_ que doit exécuter le système d'exploitation. Le débit observé de moins de 4000 requêtes par seconde est bien lié à la contrainte des 200 s;threads bloqués et occupés pendant 50 millisecondes chacun.
580580+581581+#### Tomcat 11
582582+583583+Le même tir de performances sur Tomcat 11 configuré avec les _Threads Virtuels_ donne un résultat complètement différent :
584584+585585+```
586586+$ hey -c 400 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo
587587+588588+Summary:
589589+ Total: 126.8828 secs
590590+ Slowest: 0.3636 secs
591591+ Fastest: 0.0501 secs
592592+ Average: 0.0507 secs
593593+ Requests/sec: 7881.2884
594594+595595+ Total data: 129884989 bytes
596596+ Size/request: 129 bytes
597597+598598+Response time histogram:
599599+ 0.050 [1] |
600600+ 0.081 [999544] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
601601+ 0.113 [115] |
602602+ 0.144 [93] |
603603+ 0.175 [114] |
604604+ 0.207 [132] |
605605+ 0.238 [0] |
606606+ 0.270 [0] |
607607+ 0.301 [0] |
608608+ 0.332 [0] |
609609+ 0.364 [1] |
610610+611611+612612+Latency distribution:
613613+ 10% in 0.0502 secs
614614+ 25% in 0.0503 secs
615615+ 50% in 0.0504 secs
616616+ 75% in 0.0507 secs
617617+ 90% in 0.0512 secs
618618+ 95% in 0.0517 secs
619619+ 99% in 0.0544 secs
620620+```
621621+622622+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 sont répondues en 54 millisecondes.
623623+624624+Attention cependant, on observe que le débit est de seulement 7900 requêtes par seconde. La limitation ici est liée au nombre de workers de ma commande `hey` positionné à 400.
625625+626626+Un test rapide permet d'augmenter le nombre de workers à 1000 pour observer la différence :
627627+628628+```
629629+$ hey -c 1000 -n 1000000 http://localhost:8080/examples/servlets/servlet/ThreadInfo
630630+631631+Summary:
632632+ Total: 50.8318 secs
633633+ Slowest: 0.2331 secs
634634+ Fastest: 0.0501 secs
635635+ Average: 0.0507 secs
636636+ Requests/sec: 19672.7068
637637+638638+ Total data: 128987581 bytes
639639+ Size/request: 130 bytes
640640+641641+Response time histogram:
642642+ 0.050 [1] |
643643+ 0.068 [983411] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
644644+ 0.087 [277] |
645645+ 0.105 [59] |
646646+ 0.123 [28] |
647647+ 0.142 [27] |
648648+ 0.160 [0] |
649649+ 0.178 [124] |
650650+ 0.197 [271] |
651651+ 0.215 [309] |
652652+ 0.233 [205] |
653653+654654+655655+Latency distribution:
656656+ 10% in 0.0501 secs
657657+ 25% in 0.0502 secs
658658+ 50% in 0.0502 secs
659659+ 75% in 0.0504 secs
660660+ 90% in 0.0512 secs
661661+ 95% in 0.0523 secs
662662+ 99% in 0.0568 secs
663663+```
664664+665665+Avec 1000 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 à 1900 requêtes par seconde !
666666+667667+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`.
668668+669669+Cependant, ces performances laissent deviner qu'il serait possible d'aller encore plus loin.
670670+671671+## Bonus, avec Spring Boot 3
672672+673673+> "Julien, tu es bien gentil avec tes Servlets, mais plus personne n'en développe."
674674+675675+Cette partie "bonus" teste le même comportement, mais avec Spring Boot 3 !
676676+677677+### Configurer Spring Boot 3
678678+679679+Malheureusement, il n'est pas possible pour le moment d'utiliser Tomcat 11 avec Spring Boot 3.
680680+Néanmoins, Spring Boot 3 a intégré le support des _Threads Virtuels_ et de l'Exécutor Tomcat à Tomcat 10 !
681681+682682+Pour utiliser les Virtual Threads dans Spring Boot 3, il faut positionner la properties suivante :
683683+684684+```properties
685685+spring.threads.virtual.enabled=true
686686+```
687687+688688+Côté Spring Boot, cette properties est interprétée par l'annotation `@ConditionalOnThreading` et configure un `TomcatVirtualThreadsWebServerFactoryCustomizer` :
689689+690690+```java
691691+@Configuration(proxyBeanMethods = false)
692692+@ConditionalOnClass({ Tomcat.class, UpgradeProtocol.class })
693693+public static class TomcatWebServerFactoryCustomizerConfiguration {
694694+695695+ @Bean
696696+ @ConditionalOnThreading(Threading.VIRTUAL)
697697+ TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
698698+ return new TomcatVirtualThreadsWebServerFactoryCustomizer();
699699+ }
700700+701701+}
702702+```
703703+704704+Le `TomcatVirtualThreadsWebServerFactoryCustomizer` configure le Tomcat embedded pour utiliser l'executor `VirtualThreadExecutor` :
705705+706706+```java
707707+public class TomcatVirtualThreadsWebServerFactoryCustomizer
708708+ implements WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory>, Ordered {
709709+710710+ @Override
711711+ public void customize(ConfigurableTomcatWebServerFactory factory) {
712712+ factory.addProtocolHandlerCustomizers(
713713+ (protocolHandler) -> protocolHandler.setExecutor(new VirtualThreadExecutor("tomcat-handler-")));
714714+ }
715715+}
716716+```
717717+718718+Dans notre code, un simple `@Controller` Spring permet de re-créer le comportement équivalent à la servlet utilisée pour le benchmark précédent :
719719+720720+```java
721721+@RestController
722722+public class ThreadController {
723723+724724+ @GetMapping("/")
725725+ String getThreadName() throws InterruptedException {
726726+ Thread.sleep(50L); // gros dodo
727727+ return Thread.currentThread().getName();
728728+ }
729729+}
730730+```
731731+732732+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 Threads Virtuels :
733733+734734+```
735735+hey -c 400 -n 1000000 http://localhost:8080
736736+737737+Summary:
738738+ Total: 253.9172 secs
739739+ Slowest: 0.2498 secs
740740+ Fastest: 0.0501 secs
741741+ Average: 0.1013 secs
742742+ Requests/sec: 3938.2910
743743+744744+ Total data: 21459960 bytes
745745+ Size/request: 21 bytes
746746+747747+Response time histogram:
748748+ 0.050 [1] |
749749+ 0.070 [22735] |■
750750+ 0.090 [353] |
751751+ 0.110 [949895] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
752752+ 0.130 [7069] |
753753+ 0.150 [19591] |■
754754+ 0.170 [67] |
755755+ 0.190 [74] |
756756+ 0.210 [99] |
757757+ 0.230 [46] |
758758+ 0.250 [70] |
759759+760760+761761+Latency distribution:
762762+ 10% in 0.1004 secs
763763+ 25% in 0.1008 secs
764764+ 50% in 0.1014 secs
765765+ 75% in 0.1021 secs
766766+ 90% in 0.1036 secs
767767+ 95% in 0.1058 secs
768768+ 99% in 0.1352 secs
769769+```
770770+771771+Les temps de réponse sont autour de 100 millisecondes, pour un débit de moins de 4000 requêtes par secondes, et 99% des réponses en moins de 135 millisecondes.
772772+773773+Avec la properties `spring.threads.virtual.enabled=true`, on obtient les performances suivantes, qui sont similaires aux performances de Tomcat 11 avec les Threads Virtuels :
774774+775775+```
776776+Summary:
777777+ Total: 126.7462 secs
778778+ Slowest: 0.1738 secs
779779+ Fastest: 0.0501 secs
780780+ Average: 0.0507 secs
781781+ Requests/sec: 7889.7847
782782+783783+ Total data: 20941836 bytes
784784+ Size/request: 20 bytes
785785+786786+Response time histogram:
787787+ 0.050 [1] |
788788+ 0.062 [999571] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
789789+ 0.075 [15] |
790790+ 0.087 [68] |
791791+ 0.100 [0] |
792792+ 0.112 [65] |
793793+ 0.124 [0] |
794794+ 0.137 [61] |
795795+ 0.149 [57] |
796796+ 0.161 [16] |
797797+ 0.174 [146] |
798798+799799+800800+Latency distribution:
801801+ 10% in 0.0502 secs
802802+ 25% in 0.0503 secs
803803+ 50% in 0.0504 secs
804804+ 75% in 0.0507 secs
805805+ 90% in 0.0513 secs
806806+ 95% in 0.0519 secs
807807+ 99% in 0.0539 secs
808808+```
809809+810810+Les temps de réponse sont autour de 50 millisecondes, pour un débit d'un peu moins de 8000 requêtes par seconde, et 99% des requêtes ont une réponse en moins de 53 millisecondes !
811811+812812+## Conclusion
813813+814814+Les résultats sont impressionnants. En utilisant le `VirtualThreadExecutor`, dans Tomcat 10.1 ou 11, on observe 10% de gains de performances sans rien faire de particulier, pour des Servlets n'effectuant aucun 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. Un Tomcat avec 200 _Threads Plateforme_ prend du temps à exécuter des _context switch_ qui ont un impact réel sur les temps de réponse. Ces impacts semblent purement annulés avec l'utilisation des _Threads Virtuels_.
815815+816816+Pour aller plus loin, l'utilisation des _Threads Virtuels_ 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 switch_.
817817+818818+Il n'est maintenant plus problématique de bloquer un _Thread_.
819819+820820+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 ?
821821+822822+À 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 restera probablement toujours disponible, avec une valeur `false` par défaut.
823823+824824+## Liens et références
825825+826826+- [JEP 320](https://openjdk.org/jeps/320) - Suppression des modules Java EE et CORBA
827827+- [JEP 444](https://openjdk.org/jeps/444) - _Virtual Threads_
828828+- Documentation de [Tomcat](https://tomcat.apache.org/)
829829+- [RELEASE-NOTES Tomcat 11.0.0-M16](https://archive.apache.org/dist/tomcat/tomcat-11/v11.0.0-M16/RELEASE-NOTES)
830830+- [Tomcat 11 Virtual Thread Implementation](https://tomcat.apache.org/tomcat-11.0-doc/config/executor.html#Virtual_Thread_Implementation) - Configuration des _Threads Virtuels_ dans Tomcat
831831+- [Virtual Threads](https://docs.spring.io/spring-boot/docs/3.2.2/reference/htmlsingle/#features.spring-application.virtual-threads) - Configuration des _Threads Virtuels_ dans Spring Boot
832832+- [Programmation Concurrente et Asynchrone : Loom en Java 20 et 21](https://www.youtube.com/watch?v=v7DzKOniNh0) - José Paumard
833833+- [JMH](https://github.com/openjdk/jmh) : Java Microbenchmark Harness