Java NIO et Framework Web Haute performance

Comme nous l’avons déjà évoqué sur le blog, à l’occasion du challenge USI 2011, nous nous sommes intéressés à différents serveurs et framework web NIO en Java. Le principe était simple en mettant à plat la spécification du challenge, nous avons identifié quelques besoins techniques :

  • Une solution pour le marshalling JSON
  • Un serveur web NIO supportant le long polling
  • Une solution pour la persistence et le partage des données

Notre démarche a été de réaliser des POCs implémentant la création des utilisateurs et le long polling pour retenir la meilleure solution. La solution devait être simple et rapide à implémenter, et tenir une charge conséquente en la testant à l’aide de ab l’outil de benchmark Apache et de la librairie Async Http Client. Pour le JSON, nous nous sommes tous rapidement mis d’accord sur l’utilisation de la librairie Jackson. Nous étions tous convaincus qu’il nous faudrait un serveur web NIO sans passer par la case Servlet. C’est à partir de là que notre tour d’horizon des API NIO en Java a commencé.

Pourquoi NIO ?

Revenons d’abord à l’essentiel, nous ne pouvons justifier notre choix sans expliquer ce qu’est cette API Java. Cette API existe depuis la version 1.4 du JDK et permet essentiellement de réaliser des traitements non-bloquants sur les entrées-sorties. NIO est en fait l’acronyme de New IO, la nouvelle api java.nio vient en supplément de l’API java.io existante.
Prenons l’exemple d’une Socket en Java, par défaut les opérations de lecture et d’écriture sont bloquante. Le thread lançant une opération de lecture ou d’écriture sur Socket sera bloqué sur cet appel tant que l’opération ne sera pas terminée. Tant qu’il n’y aura rien à lire sur la socket ou qu’il n’y aura pas d’espace disponible pour écrire dessus, le thread sera bloqué. Il sera aussi débloqué en cas d’exception dû à un timeout par exemple.

Depuis java.nio, il est possible de réaliser ces appels sans avoir à attendre qu’il y ait de l’espace libre ou des données à lire sur la Socket. Pour éviter ce temps de bloquage, l’API fournit un système pour écouter les évènements sur les sockets il s’agit du Selector. On enregistre la ou les Sockets dans le selector en précisant si l’on veut lire et/ou écrire, et/ou accepter des connexions sur la socket. Sur chaque appel à select(), le sélecteur créera une liste des Channel pour lesquels une opération est prête à réaliser. Bien sûr, le serveur devra toujours attendre qu’il y ait des opérations disponibles sur ses sockets clients, mais la grande différence est que le serveur attend une fois pour l’ensemble de ses clients au lieu d’attendre une fois par client.

Pendant que le thread de l’API historique java.io attend un client, un Thread utilisant java.nio pourra traiter d’autres clients. Pour traiter les requêtes en parallèle avec l’API java.io, il suffit de multiplier le nombre de Thread avec un thread par client, il n’y aura pas d’attente non plus. Malheureusement on ne peut pas multiplier indéfiniment les Threads sans impact sur l’espace mémoire. Dans le cadre du challenge, nous étions limités en mémoire et nous devions supporter le maximum possible de client concurrents. Avec son faible nombre de Threads (1 par CPU) et donc sa consommation de mémoire inférieur, NIO est rapidement devenu une évidence pour tous.
Pour ces même raisons, nous avions décidé d’éliminer les serveurs de Servlet. L’API Servlet est consommatrice par essence et apporte une quantité non négligeable de raffinements dont nous n’avions pas besoin. Depuis l’API Servlet 3.0, il est possible de réaliser des Servlets Asynchrone qui permettent de libérer le Thread d’exécution de la requête pour envoyer la réponse plus tard à partir d’un autre Thread. Par curiosité nous avons donc testés Tomcat 7 en Servlet asynchrone, avec de forts à priori.

Le test

Afin de départager les différentes API, nous avons réalisé une implémentation du service de création des utilisateurs qui les persistent dans une Map. Quelque soit le framework, le service de mapping du JSON et de persistance sont les mêmes. Ainsi, nous pouvions nous intéresser à notre seul problème: la performance du serveur HTTP. Les tests ont été réalisés sur un macbook pro 2,4 Ghz core i5 sans optimisation de JVM. Les seules modifications réalisées sur le système sont:

  • L’augmentation du nombre de port éphémères via sysctl net.inet.ip.portrange.first
  • La réduction du maximum segment lifetime afin de réduire la durée de réservation des sockets en TIME_WAIT.
    Ces configurations ont pour seule but de permettre à ab d’injecter une charge conséquente sans souffrir de timeout dûe à un nombre trop élevé de socket réservé.

Tour d’horizon des implémentations NIO

Restlet

Restlet est un framework REST qui embarque le serveur HTTP et permet de choisir parmi différentes implémentations de Mina par défaut à Jetty en passant par Grizzly et Netty.
Restlet, semblait donc parfaitement adapté à la réalisation du serveur REST tout en nous offrant la possibilité de choisir le serveur à utiliser. En ultime recours sachez tout de même qu’il est parfaitement possible d’embarquer Restlet dans un war.
Nous avons donc créé un POC pour le service de création des utilisateurs (1Million d’utilisateurs en moins d’une heure). Après quelques tests rapides, l’API est convaincante pour sa simplicité d’utilisation et sa rapidité de prise en main. Mais les performances ne sont pas à la hauteur de nos espérances. Nous étions bien sûr largement capables de créer le million d’utilisateur en moins d’une heure, mais le temps passé dans les API Restlet, Mina et Jetty était trop impactant. Nous avons testé ce POC avec Mina et Jetty sans nous intéresser aux autres solutions que nous souhaitions tester en direct. Pour la solution Jetty, ab indiquait en moyenne 5k requêtes par secondes pour un temps de traitement moyen de 11ms à la création des utilisateurs. Si cette performance est encourageante et s’avère la meilleure disponible toute implémentation de serveur confondue, il y a tout de même une consommation en mémoire importante et des points de synchronisation bloquants. Pour le test via Mina la solution par défaut, les tests de charge supportaient mal l’augmentation du nombre d’injecteurs qui cause une cascade de Timeout. D’autre part alors que Jetty se limite à 80 Threads, avec son implémentation Mina, Restlet crée rapidement des centaines de Threads et consomme beaucoup de mémoire. Retenez donc que l’implémentation Jetty NIO de Restlet est très performante et répondra à la majorité des besoins en informatique de gestion.

Tomcat 7

Le développement de ces POCs était une bonne occasion pour tester Tomcat 7 et les Servlets 3.0. Nous avons donc implémenté notre service avec des Servlets déclarées par annotation et utilisant l’exécution asynchrone pour maximiser le nombre de connexions concurrentes supportées.
Notez que l’utilisation des Servlets asynchrones a un impact immédiat sur les performances du serveur. La version asynchrone de la solution supportait effectivement plus de connexions avec des temps de réponse à peu près similaires. Le simple fait de passer la Servlet en traitement asynchrone permet donc de gagner en performance. Malheureusement en examinant les ThreadDump, il était clair que Tomcat avait un gros impact sur les temps de réponse (files d’attente et autre point de synchro était omni-présent).
Une dernière remarque, les tests ont été réalisés en utilisant deux connecteurs HTTP différents : le standard et le NIO. La solution NIO était bien plus performante en terme de parallélisme, mais le connecteur souffrait alors d’une fuite de mémoire conséquente. Le connecteur NIO de Tomcat aurait pu être une bonne option, mais nous ne voulions pas partir sur une solution reposant d’emblée sur des API instables comme c’était le cas du connecteur. A priori le bug a été corrigé dans les versions suivantes de Tomcat. Notez aussi que Tomcat a une empreinte mémoire importante et implique l’utilisation quasi permanente du GC car la mémoire utilisée dans la heap augmente régulièrement.

Grizzly

Pour mémoire, Grizzly est l’API réseaux utilisée par GlassFish. La librairie fournie un serveur de Servlet et un framework de traitement réseau NIO, “haute performance”. Tant qu’à faire, c’est à la partie framework NIO que nous nous sommes intéressés. Pour créer le serveur, il suffit d’utiliser HttpServer.createSimpleServer() auquel on ajoute ensuite des HttpRequestProcessor mappés par URL. Bien que très mal documentée, l’API est simple à mettre en place. Le problème vient plutôt du fine tuning car il est facile de changer le port d’écoute et d’activer le monitoring JMX, par contre la gestion des ThreadsPool s’avère bien plus complexe. Il n’existe pas dans Grizzly un moyen simple de spécifier son ExecutorService. Sur le scénario simple d’injection des utilisateurs, Grizzly crée rapidement plusieurs centaines de Threads et souffre de nombreux points de synchronisation. N’ayant pas beaucoup de temps à consommer dans cette phase de POC, nous avons rapidement éliminés Grizzly qui nécessitait d’emblé des optimisations et ne répondait pas aux besoins directement sorti de sa boite. En tests nous avons atteint une moyenne de 1500 req/s pour l’injection de 100k joueur dans une simple HashMap. Notez tout de même que Grizzly supporte différentes stratégies pour gérer les IO (http://grizzly.java.net/nonav/docs/docbkx2.0/html/iostrategies.html), par défaut il applique le WorkerThread qui consiste à déléguer le traitement des IO à un worker séparé. Nous n’avons pas testé d’autres stratégies que celle par défaut; cela aurait nécessité plus de temps que nous ne souhaitions en allouer au POC. En résumé, les tests sur grizzly se sont avérés décevants; avec plus de temps nous aurions sûrement amélioré ces performances.

Deft

Deft est une implémentation Java s’inspirant de Tornado, le serveur HTTP de facebook en python. Son principe est de créer une boucle infinie unique exécutant l’ensemble des évènements remontés par le Selector dans le même thread. Comme dans NodeJS, ce système permet de réaliser les traitements de façon évènementiel sans se soucier de la concurrence. Avec quelques tests sur un seul Thread, nous avons constaté des performances impressionnantes. En temps de réponse, en nombre de requêtes concurrentes, comme en consommation mémoire le serveur dépasse l’ensemble de ses concurrents sans aucune optimisation. Il y avait toutefois quelques bugs rédhibitoires dans l’API causant des erreurs graves à l’exécution de certains tests de charge. Le code étant simple, Séven a réalisé des corrections de bugs et implémenté un mode multi-thread permettant de lancer plusieurs boucles infinies pour tirer au mieux parti des CPU multi-coeurs. Sur le test d’injection de 100k joueurs, nous avons atteint entre 10 et 11k req/s pour une moyenne de 6ms de temps de réponse. Pour Deft, nous avons donc fait exception à la règle du temps consommé, n’étant jamais à l’abri d’une bonne surprise cela pouvait valoir le coups. Au final, l’API étant encore un peu trop instable et manquant de raffinement, comme la gestion des cookies ou le support de la compression gzip, ou encore une solution clé en main de longpolling, nous avons abandonné ce POC pourtant prometteur. Notez que, depuis la fin du challenge, le projet est rentré en incubation chez Apache avec pour but de fournir un serveur Java haute performance non J2EE. L’histoire continue pour Séven qui a rejoint l’équipe des commiters de ce nouveau projet Apache.

Netty

Netty est le framework NIO de JBoss, il permet de créer rapidement un serveur HTTP. Netty utilise deux ThreadPool, l’un pour écouter et accepter les connexions entrantes et l’autre pour traiter les échanges de données sur socket établies (Boss / Worker). Il définit ensuite une liste de Handler permettant de traiter les messages lus et d’écrire une réponse. Les handlers se comportent comme des filtres passant un événement portant un message, ils sont invoqués dans le sens de la lecture puis dans le sens de l’écriture vers le SocketChannel. La création d’un serveur HTTP nécessite donc de configurer les décodeurs dans le bon ordre mais l’exemple fournit dans la documentation permet de mettre le tout en œuvre en quelques minutes. Pour les habitués des documentations Jboss, notez que celle de Netty est riche et lisible : un bel effort pour ces experts du labyrinthe. Netty a l’avantage de supporter nativement le long-polling et de parfaitement gérer les envois de réponse en masse sur les sockets client. Sur le test d’injection des 100k utilisateurs, nous avons atteint les 9k req/s pour des temps de réponses de 6ms en moyenne. Pendant le test, Netty alloue un peu de mémoire supplémentaire mais se stabilise rapidement sans avoir d’activité GC. Comme vous pouvez le constater, cette solution supporte une charge légèrement inférieure mais apporte une stabilité accrue par rapport à Deft et surtout bénéficie d’optimisation pour le long-polling. La seule optimisation dont nous avons eu besoin par rapport au code de démonstration, fût l’utilisation d’un MemoryAwareThreadPool en lieu et place du CachedThreadPool qui causait un goulet d’étranglement. C’est donc la maturité de l’API, la simplicité d’usage et la performance de la solution qui ont fait la différence au final sur notre choix.

Conclusion

Pour conclure, ce tour d’horizon nous a permis finalement de découvrir qu’avec un peu d’optimisation, il est facile de faire des serveurs de hautes performance en reposant sur nos bons vieux serveurs J2EE comme Tomcat et Jetty. Les solutions NIO font surtout la différence sur l’application du long-polling, et les réponses groupées. Ainsi que sur la consommation mémoire et le nombre de Thread utilisés favorisant ainsi l’économie des FullGC et plus globalement du temps de traitement du garbage collector. NIO prend tout son sens pour le messaging et les serveurs de type événementiels. Parmi les API NIO non couvertes par nos tests, notez aussi l’existence de Simple : un serveur web prometteur permettant d’héberger des application Jersey en JAX-RS et intégré dans Restlet.
Vous trouverez les sources des pocs sur le GitHub de Xebia: https://github.com/xebia-france/NIO

Published by et

Publié par Julien Buret

Julien est CTO chez Xebia. Il est également formateur au sein de Xebia Training .

Commentaire

8 réponses pour " Java NIO et Framework Web Haute performance "

  1. Published by , Il y a 11 ans

    Très intéressant merci!
    A noter que Play Framework permet aussi d’utiliser des requêtes non blocantes (le serveur Play est basé sur Netty)

  2. Published by , Il y a 11 ans

    Merci pour ce billet et la couverture de Restlet! Quelques petites précisions pour ceux qui souhaitent aller plus loin sur cette voie:

    1) Dans la version 2.0, Restlet dispose d’un serveur HTTP interne (directement dans « org.restlet.jar » donc) basé sur BIO, donc peu scalable et utile en phase de développement. Deux connecteurs d’extension sont disponibles et recommandés en production (basés sur Jetty et Simple) et deux autres pour des expérimentations (basés sur Grizzly et Netty).

    2) Dans la version 2.1, nous avons réécrit le serveur interne pour utiliser NIO en mode non bloquant, pouvant même jusqu’à n’utiliser qu’un seul thread pour la boucle du sélecteur NIO et le traitement des appels comme Deft (via configuration). Le pool de threads est également entièrement configurable via des paramètres pour en limiter la taille.

    A ce jour en release candidate 1, nous sommes en phase de correction de bogues et de stabilisation, en espérant rivaliser à terme (en v2.2) avec les performances du connecteur Jetty (optimisations mémoire prévues notamment). Nous continuons à maintenir les extensions Jetty et Simple mais avons retiré le support de Netty et Grizzly de Restlet pour nous concentrer sur notre nouveau connecteur interne NIO.

    A noter que nous n’avons pas d’intégration de Restlet avec MINA à ce jour. Notre connecteur NIO a été développé de zéro. Voir les spécifications ici:
    http://wiki.restlet.org/developers/172-restlet/g1/354-restlet.html

    Une fois la version 2.1.0 de Restlet sortie, ça serait super de pouvoir retester les résultats avec votre pic NIO. Un futur billet à prévoir peut-être, couvrant également la scalabilité côté client HTTP?

  3. Published by , Il y a 11 ans

    Bel article, bravo!
    Pouvez-vous nous en dire plus sur la façon dont vous avez mené ce challenge ? combien de temps cela a-t-il prit ? comment avez-vous travaillé (le soir, le WE, etc)? Merci

  4. Published by , Il y a 11 ans

    Merci pour ce superbe retour de POC clair et concis. Il n’y a que les optim de la stack ip du mac qui m’ont laissées sur le carreau. En tout cas, ça m’a bien donné envie de jouer avec tout ça.

  5. Published by , Il y a 11 ans

    Merci pour vos commentaires :).
    @Loic, Play n’était pas vraiment dans notre cible car nous souhaitions tester Netty sans surcouche. Mais implémenter le POC via Play doit-être assez simple.

    @Jerome, merci pour la correction concernant Mina, j’aimerai savoir pourquoi vous avez fait le choix d’implémenter votre propre couche NIO plutôt que de rester sur l’un des frameworks déjà implémenté. Peut-être que Deft serait une bonne option ?

    @Manu, nous avons bien travaillé sur le challenge en dehors de nos heures passées chez le client. Dire combien de temps cela nous a pris, j’en suis incapable, mais nous y avons passé beaucoup de temps je peux le garantir.

    @olamy, je sais que l’argumentation n’est pas bien lourde mais pourquoi pas ? HttpCore est encore instable, pour l’avoir utilisé j’ai constaté des contentions sur la transmission des évènements d’un Thread à un autre. Alors qu’Async Http client repose sur Netty qui est stable et éprouvé. Honnêtement notre but n’était pas de tester les clients HTTP mais plutôt les serveurs. Nous avons donc retenus la première solution qui a répondu à nos besoins. Deft fourni aussi un client asynchrone :).

    Merci encore pour vos retours et n’hésitez pas à jouer avec les sources sur GitHub.

    Séven

  6. Published by , Il y a 11 ans

    Il faut souligner les efforts :

    – celui de maîtriser son sujet
    – celui de partager le retour d’expérience
    – celui de le restituer de façon claire et concise

    A la fin de l’article on a le sentiment d’avoir gagné du temps sur une éventuelle étude sur les serveurs HTTP java. On a aussi appris des choses.

    Merci pour ce travail.

  7. Published by , Il y a 11 ans

    @Seven, l’idée était de disposer d’un connecteur le plus simple possible qui colle directement à HTTP et à l’API Restlet, plutôt que de s’appuyer sur un framework NIO générique multi-protocoles qui introduise une couche d’abstraction supplémentaire.

    Sinon, nous disposons déjà de connecteurs Jetty et Simple qui couvrent bien les besoins actuels (et avions commencé à supporter Netty et Grizzly). Donc un objectif de simplicité et légèreté avec aucune dépendance en plus.

    J’ai l’impression que Deft est sur un chemin similaire, donc je me pose la question inverse: quel intérêt d’utiliser Deft par rapport à Restlet/NIO? ;)

    Une fois notre connecteur stabilisé nous allons ensuite pouvoir introduire de nouvelles fonctionnalités au niveau de l’API Restlet qui permettrons de mieux contrôler ce connecteur. Nous voulons pouvoir partager par exemple le même pool de thread entre plusieurs connecteurs HTTP/SIP écoutant sur des ports différents, côté client et serveur ainsi que le TaskService Restlet gérant les tâches asynchrones des applications.

    Offrir également un service de protection contre les attaques par déni de service dynamique qui permette de bloquer à l’acceptation de la socket est plus facile lorsque l’on contrôle finement ces couches.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.