Jusqu’où peut aller un simple ordinateur de bureau avec une application web java réactive ?

Programmation réactiveLa tendance actuelle : de plus en plus d’internautes connectés partout, tout le temps et souvent depuis plusieurs machines en même temps (ordinateur de bureau, tablette, mobile).

Les promesses de la programmation réactive : permettre de gérer sur une même machine beaucoup plus de connexions en parallèle et de traiter plus de requêtes par seconde, ceci avec peu de threads donc beaucoup moins de mémoire et de CPU qu’avec les modes de programmation classiques.

Nous avons réalisé 3 versions d’une application de test :

  • version servlet classique : servlet et appel d’un web service avec Apache HttpClient
  • version servlet asynchrone 3.0 : servlet asynchrone et appel d’un web service avec Apache HttpAsyncClient
  • version 100% réactive : serveur HttpCore NIO et appel d’un web service avec Apache HttpAsyncClient

Ensuite nous avons soumis ces trois versions à des tests de charge particulièrement agressifs pour voir ce qu’elles pouvaient supporter.

Comment tester ?

Les tests ont été réalisés avec Apache Benchmark

Quelle charge viser ?

Pour se donner une idée, voici quelques chiffres provenant des géants du web. Par seconde :

  • Google : 33 000  recherches
  • Facebook : 41 000 post, 30 000 like
  • Twitter : 4 600 twits

Source : what happens in just ONE minute on the internet

En termes de connexions simultanées, on considère habituellement que le défi ultime pour un serveur web est d’arriver à supporter 10 000 connections en parallèle : c’est le fameux C10k problem. On peut aller un peu plus loin mais pas beaucoup en fait car le nombre de connexions simultanées vers une même machine est en principe limité par le nombre de ports TCP disponibles soit 65 536. D’ailleurs Apache Benchmark lui-même supporte au maximum 20 000 connections simultanées.

Notre objectif  : 10 000 connexions simultanées et 50 000 requêtes / seconde.

Les tests réalisés

Le principe est de simuler une application web java. Pour chaque requête utilisateur, cette application va interroger un web service et afficher le résultat à l’utilisateur.

Schema architecture

Tout le code utilisé pour les tests est disponible ici : https://github.com/fxbonnet/nio-benchmark

La machine : un PC portable de bureau (pas un serveur !)

  • Processeur Intel Core i7-3517U 1.90GHz 2 coeurs, 4 hard-threads
  • Ubuntu 13.10 (3.11.0-12.19 Ubuntu Linux kernel)
  • Réseau : tests effectués en local
  • OpenJDK 1.7.0_51 (OpenJDK Runtime Environment (IcedTea 2.4.4) (7u51-2.4.4-0ubuntu0.13.10.1) OpenJDK 64-Bit Server VM (build 24.45-b08, mixed mode))

Préparation de l’injecteur

D’abord un petit test à vide d’Apache Benchmark :

ab -c10000 -n1000000 http://localhost:8081/war/hello
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)

socket: Too many open files (24)

La machine n’est pas prête à supporter autant de connexions en même temps. Quelques réglages s’imposent en commençant par augmenter les valeurs dans le fichier /etc/security/limits.conf

# Increases the number of open files by user
*      hard  nofile   65536
*      soft  nofile   65536
root   hard  nofile   65536
root   soft  nofile   65536

Deuxième essai :

ab -r -c10000 -n1000000 http://localhost:8081/war/hello
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)

Test aborted after 10 failures

apr_socket_connect(): Cannot assign requested address (99)

Des réglages plus approfondis s’imposent pour permettre un recyclage des ports plus rapide. On modifie donc le fichier /etc/sysctl.conf (le fichier est disponible avec le code source des tests sur Github) et on en profite pour ajuster les tailles de buffers et autres paramètres recommandés pour des machines devant supporter un fort traffic réseau.

Pour la suite on va aussi ajouter l’argument -k dans la ligne de commande afin de permettre à Apache Benchmark de faire du keep-alive sur les connexions ce qui devrait aussi permettre d’aller plus loin.

Troisième essai :

ab -r -k -c10000 -n1000000 http://localhost:8080/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100000 requests
Completed 200000 requests
Completed 300000 requests
Completed 400000 requests
Completed 500000 requests
Completed 600000 requests
Completed 700000 requests
Completed 800000 requests
Completed 900000 requests
Completed 1000000 requests
Finished 1000000 requests

Server Software:
Server Hostname:        localhost
Server Port:            8080

Document Path:          /
Document Length:        0 bytes

Concurrency Level:      10000
Time taken for tests:   12.004 seconds
Complete requests:      1000000
Failed requests:        1529850
  (Connect: 0, Receive: 1019900, Length: 0, Exceptions: 509950)
Write errors:           0
Non-2xx responses:      49
Keep-Alive requests:    49
Total transferred:      5194 bytes
HTML transferred:       0 bytes
Requests per second:    83302.25 [#/sec] (mean)
Time per request:       120.045 [ms] (mean)
Time per request:       0.012 [ms] (mean, across all concurrent requests)
Transfer rate:          0.42 [Kbytes/sec] received

Connection Times (ms)
             min  mean[+/-sd] median   max
Connect:        0    0   0.2      0     227
Processing:     0  116 116.3    120     266
Waiting:        0    0   1.6      0     252
Total:          0  116 116.3    120     477

Percentage of the requests served within a certain time (ms)
 50%    120
 66%    230
 75%    232
 80%    233
 90%    236
 95%    240
 98%    246
 99%    251
100%    477 (longest request)

Cette fois-ci, ça passe. On peut observer l’utilisation CPU :

System monitor

On voit deux choses intéressantes :

  1. Apache Benchmark arrive à monter à plus de 83 000 requêtes/seconde ce qui est largement suffisant par rapport à notre objectif.
  2. Il n’a besoin pour cela d’utiliser qu’un seul hard-thread, nos applications pourront utiliser les 3 hard-threads restants.

Préparation du web service utilisé pour les tests

Ce service est implémenté à l’aide d’Apache HttpCore NIO. Il est lui aussi monothread, ce qui laissera les deux dernier hard-threads pour notre application à tester.

Techniquement le service renvoit une vraie réponse HTTP. On peut le tester à l’aide d’un navigateur.

Firefox

Testons le avec Apache Benchmark :

ab -r -k -c10000 -n1000000 http://localhost:8081/war/hello
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100000 requests
Completed 200000 requests
Completed 300000 requests
Completed 400000 requests
Completed 500000 requests
Completed 600000 requests
Completed 700000 requests
Completed 800000 requests
Completed 900000 requests
Completed 1000000 requests
Finished 1000000 requests

Server Software:
Server Hostname:        localhost
Server Port:            8081

Document Path:          /war/hello
Document Length:        11 bytes

Concurrency Level:      10000
Time taken for tests:   24.268 seconds
Complete requests:      1000000
Failed requests:        0
Write errors:           0
Keep-Alive requests:    1000000
Total transferred:      151000000 bytes
HTML transferred:       11000000 bytes
Requests per second:    41206.84 [#/sec] (mean)
Time per request:       242.678 [ms] (mean)
Time per request:       0.024 [ms] (mean, across all concurrent requests)
Transfer rate:          6076.40 [Kbytes/sec] received

Connection Times (ms)
             min  mean[+/-sd] median   max
Connect:        0   11 300.7      0   15035
Processing:    55  224  52.3    195     594
Waiting:       55  224  52.3    195     594
Total:        165  235 307.3    195   15381

Percentage of the requests served within a certain time (ms)
 50%    195
 66%    248
 75%    274
 80%    279
 90%    294
 95%    332
 98%    351
 99%    369
100%  15381 (longest request)

System monitor

Le service arrive à traiter plus de 40 000 requêtes/seconde et il reste suffisamment de CPU disponible pour nos applications de test.

Préparation des applications de test

Commençons par la version servlet classique :

@Override
protected void doGet(final HttpServletRequest request, final HttpServletResponse response) throws IOException {
	HttpGet httpGet = new HttpGet(HelloServer.SLOW_HELLO_URL);
	CloseableHttpResponse httpClientResponse = httpClient.execute(httpGet);
	try {
		String result = EntityUtils.toString(httpClientResponse.getEntity());
		response.getWriter().write(result);
	} finally {
		httpClientResponse.close();
	}
}

Puis la version servlet asynchrone (les servlets asynchrones sont apparues dans la spécification Servlet 3.0). Pour faire réellement du réactif, elle utilise également un client HTTP asynchrone. Le code est donc un peu plus compliqué :

@Override
protected void doGet(final HttpServletRequest request, final HttpServletResponse response) {
	final AsyncContext asyncContext = request.startAsync();
	HttpGet httpGet = new HttpGet(HelloServer.SLOW_HELLO_URL);
	FutureCallback<HttpResponse> responseCallback = new FutureCallback<HttpResponse>() {
		@Override
		public void completed(final HttpResponse httpClientResponse) {
			try {
				String result = EntityUtils.toString(httpClientResponse.getEntity());
				response.getWriter().write(result);
			} catch (IOException e) {
				sendError(e, response);
			} finally {
				asyncContext.complete();
			}
		}

		@Override
		public void failed(final Exception e) {
			sendError(e, response);
		}

		@Override
		public void cancelled() {
		}
	};
	httpClient.execute(httpGet, responseCallback);
}

Résultats des tests

Configuration serveur Threads Application Hits/s % erreurs1
Tomcat 8.0.3 connecteur HTTP 200 Servlet + HttpClient 4.3.2 3 250 0,8
Tomcat 8.0.3 connecteur NIO 200 AsyncServlet + HttpAsyncClient 4.0.1 6 926 0,3
Tomcat 8.0.3 connecteur HTTP 10 000 Servlet + HttpClient 4.3.2 6 456 1,3
Tomcat 8.0.3 connecteur HTTP 10 000 AsyncServlet + HttpAsyncClient 4.0.1 7 433 1,7
Tomcat 8.0.3 connecteur NIO 10 000 Servlet + HttpClient 4.3.2 7 053 1,5
Tomcat 8.0.3 connecteur NIO 10 000 AsyncServlet + HttpAsyncClient 4.0.1 7 405 1,6
Tomcat 8.0.5 connecteur NIO 10 000 AsyncServlet + HttpAsyncClient 4.0.1 3 206 0,6
Tomcat 8.0.5 connecteur NIO2 10 000 AsyncServlet + HttpAsyncClient 4.0.1 8 108 0,05
Jetty 9.1.3 200 Servlet + HttpClient 4.3.2 11 834 -
Jetty 9.1.3 10 000 Servlet + HttpClient 4.3.2 12 066 -
Jetty 9.1.3 200 AsyncServlet + HttpAsyncClient 4.0.1 14 543 -
Jetty 9.1.3 -Xss228k2 10 000 Servlet + HttpClient 4.3.2 12 255 -
Jetty 9.1.3 -Xss228k -Xconcurrentio3 10 000 Servlet + HttpClient 4.3.2 12 256 -
Jetty 9.1.3 -Xss228k -Xconcurrentio 10 000 AsyncServlet + HttpAsyncClient 4.0.1 12 664 -
Jetty 9.1.3 -Xss228k -Xconcurrentio 10 AsyncServlet + HttpAsyncClient 4.0.1 14 065 -
HttpCore NIO 4.3.2 1 HttpAsyncClient 4.0.1 16 137 -
HttpCore NIO 4.3.2 2 HttpAsyncClient 4.0.1 16 600 -
HttpCore NIO 4.3.2 4 HttpAsyncClient 4.0.1 18 291 -

1 les erreurs correspondent à des socket timeout, c’est à dire quand Apache Benchmark n’a pas reçu de réponse au bout de 30 secondes.

2 l’option -Xss permet de définir la taille de la stack associée à chaque thread. En la réduisant, on réduit la mémoire utilisée par chaque thread. http://www.oracle.com/technetwork/java/hotspotfaq-138619.html#threads_oom

3 l’option -Xconcurrentio améliore en théorie les performances des applications qui utilisent un grand nombre de threads, du moins sur certains systèmes. http://www.oracle.com/technetwork/java/hotspotfaq-138619.html#threads_general

Requests/s graphic

NB : environ 30 s sont nécessaires à Tomcat pour remplir son pool de 10 000 threads et 2 Go de RAM sont nécessaires, il a donc fallu ajouter l’option -Xmx2048m au démarrage de la JVM.

Analyse

  • Le modèle 100% réactif est le plus performant avec environ 50% de requêtes traitées en plus par rapport à la meilleure application classique même bien tunée.
  • Le modèle servlet asynchrone permet de gagner entre 5% et 20% par rapport au modèle classique. Peut-être parce qu’il n’est pas 100% réactif (on ne passe en mode asynchrone qu’en cours de traitement à la fin de la méthode doGet() mais il a quand même fallu initialement allouer un thread du pool du conteneur).
  • A architecture strictement identique une application réactive sera plus performante avec 10 threads qu’avec 10 000 ! (cf. tests avec Jetty, avec 10 threads 14 065 hits/s, avec 10 000 threads 12 664 hits/s soit 10% moins ).
  • Sans surprise, les meilleurs résultats sont obtenus en dimensionnant le nombre de threads au nombre de hard-threads de la machine (cf. tests avec HttpCore NIO, 18 291 hits/s est le meilleur score obtenu parmi tous les tests et il n’utilisait que 4 threads).
  • Là où les applications classiques ont nécessité d’augmenter la taille des pools de threads et la mémoire, l’application réactive sélectionne elle-même par défaut la valeur optimale c’est à dire le nombre de threads disponible sur la machine et consomme très peu de mémoire.
  • Les applications non réactives ont besoin d’un temps de chauffe non négligeable (30s) et de plus de mémoire (2 Go). Dans notre exemple, le pic de charge très brutal engendre des erreurs (timeout) alors que les applications réactives se sont bien comporté.
  • Il y a des écarts importants entre serveur d’application (Tomcat ou Jetty) et même d’une version à l’autre. Le choix du connecteur dans Tomcat a un impact très fort.

Conclusions

Le modèle servlet asynchrone (introduit dans la version 3.0 de la spécification servlet) est intéressant car il permet des gains de performance significatif pour les traitements qui s’y prêtent. Par contre les serveurs d’applications classiques ne sont pas tous parfaitement préparés à ce type d’utilisations, des tests et des réglages sont nécessaires.

Le modèle 100% réactif est celui qui donne les meilleurs résultats (dans nos tests, un gain d’environ 50%).

On n’arrive pas encore aux chiffres des géants du web mais l’ordre de grandeur y est (10 000 connexions en parallèle mais seulement 18 000 requêtes/seconde alors que notre objectif était de 50 000 requêtes/seconde) mais il ne faut pas oublier que les tests ont été fait sur une seule machine 2 coeurs, 4 threads sur laquelle tournaient à la fois l’application de test, le web service de test et l’injecteur Apache Benchmark. C’est donc un résultat très encourageant. Dans des conditions réelles il faudrait aussi que le réseau soit capable de gérer ce flux (ici tous les tests ont été faits en local).

La programmation réactive a tenu ses promesses : plus de connexions en parallèle et plus de requêtes traitées par seconde en utilisant moins de mémoire et de CPU.

Vous voulez tester chez vous ? Rien de plus simple, tout le code utilisé pour les tests est disponible ici : https://github.com/fxbonnet/nio-benchmark

François-Xavier Bonnet

4 commentaires pour “Jusqu’où peut aller un simple ordinateur de bureau avec une application web java réactive ?”

  1. Il est question de 65536 connexions simultanées au maximum, mais est-ce que cela ne concerne pas uniquement les sockets en écoute ? C’est à dire qu’un port n’est pas attribué pour une connexion entrante, et donc le nombre de clients susceptible de se connecté n’est pas limité par cette valeur.

  2. C’est vrai, ce nombre de 65 536 représente le nombre total de ports disponibles à la fois pour les sockets en écoute et pour les connexions sortantes. Donc oui, dans l’absolu si le serveur est juste à l’écoute sur un port, il ne mobilise que cet unique port et peut recevoir plus de 65 536 connexions des clients, cependant dans la pratique, le serveur aura généralement besoin pour répondre aux requêtes des utilisateurs de se connecter à une base de données ou à des web services ou à un ldap … et c’est à ce moment la que cette limite interviendra.

    Dans notre exemple, pour chaque connexion client le serveur émet une requête vers le web service donc une connexion sortante (et donc un port) et pour le besoin du test de charge, AB ouvre lui aussi une connexion par utilisateur. Au total ce sont donc 20 000 ports qui sont occupés lors du test. Sans compter que lorsqu’on ferme une connexion, il y a un délai avant que le système d’exploitation puisse réutiliser le port.

  3. Tests très intéressants, en particulier pour faire du greenit au niveau applicatif.

  4. Intéressantes ces mesures factuelles. On peux s’interroger ce sur les résultats obtenus avec un serveur d’application J2EE qui embarque encore plus « de couches » qu’un TOMCAT.

Laissez un commentaire