Netty (3/6) : Recyclage de la mémoire

357 views
Skip to first unread message

Laurent Caillette

unread,
Dec 21, 2016, 2:18:27 AM12/21/16
to tec...@googlegroups.com
Dans l'épisode précédent nous avons vu que Netty fournissait un modèle
de programmation cohérent pour empiler les traitements avec le
``ChannelPipeline`` qui empile des ``ChannelHandler``. Nous avons vu
également que le ``ChannelPipeline`` respectait des règles de
non-concurrence d'accès pour toutes les primitives asynchrones
associées (``ScheduledExecutorService`` et notification de
l'achèvement d'une ``Future``). Cette non-concurrence d'accès s'appuie
sur une boucle d'événements qui réutilise un petit nombre de fils
d'exécution qui ne se retrouvent jamais bloqués, ce qui permet de
maximiser les performances en diminuant les temps de commutation entre
fils d'exécution.

Un autre aspect de la quête des performances en Java, c'est
l'utilisation de la mémoire. Aussi puissant que soit le ramasse-miette
("garbage collector") de Java, il y a toujours moyen de le saturer. La
notion de saturation est assez compliquée puisque la JVM utilise un
ramasse-miette générationnel, qui classe les objets en fonction de
leur ancienneté, partant du principe que la plupart des objets
non-référencés ont été créés il y a peu de temps. //A contrario// plus
des objets sont anciens, plus ils peuvent être référencés en un grand
nombre d'endroits, augmentant la complexité du parcours de graphe.
Plus le parcours de graphe est compliqué, plus le ramasse-miettes
consomme de ressources système au détriment du reste. Par ailleurs,
certains parcours de graphe peuvent nécessiter un gel complet de la
machine virtuelle, et c'est catastrophique pour les temps de réponse.
Mais il y a également des cas où la saturation du ramasse-miettes se
traduit par un épuisement de la mémoire disponible, tout simplement
parce que le ramasse-miettes n'a pas eu le temps de faire son boulot.

Ce n'est pas tout ! Un autre cas de dégradation des performances du
ramasse-miette, c'est quand le tas mémoire ("heap") est trop grand
(plusieurs Go sur une JVM 64 bits), ce qui implique des temps de
balayage élevés, indépendemment de la complexité du graphe d'objets.
Comment peut-on en arriver là ? En allouant des tampons mémoire de
grande taille dans le tas. Mais peut-on les allouer ailleurs ? Oui,
Java fournit un mécanisme pour allouer des tampons en mémoire directe.
Leur libération se fait à l'intiative du développeur et le
ramasse-miette ne les voit pas.

Faut-il s'interdire d'allouer des objets dans le tas ? Ceux qui en
arrivent là constatent que le code Java devient aussi lisible que du
`C++` optimisé. Tout dépend des objectifs en termes de performance. Il
y a des gens qui désactivent (au moins partiellement) le
ramasse-miette parce qu'ils maîtrisent la quantité de données en
entrée et savent que ça passera pour un cas d'utilisation donné. Une
fois mis de côté les cas pathologiques on peut se demander : comment
Netty peut-il nous aider ?

Déjà le code de Netty est conçu pour ne pas aggraver le problème. Il
évite toute allocation mémoire superflue, et déclare sous forme de
constante tout ce qui est possible. On pense évidemment aux constantes
HTTP, mais il y a également des écouteurs dénués d'états, et même
certaines exceptions (oui des ``java.lang.Exception``) pour un cas
dont je laisse au lecteur la joie de la découverte.

Après il y a des astuces un peu plus poussées. Par exemple chaque fois
qu'il est possible d'utiliser une ``CharSequence`` (qui est une
surclasse de ``java.lang.String``), Netty déclare une ``AsciiString``
qui représente une chaîne de caractères avec un tableau d'octets au
lieu d'un tableau de caractères. Et hop ! `50 %` de gagnés sur la
représentation interne, sans compter le temps d'encodage.

Là où Netty brille particulièrement, c'est dans la réutilisation des
tampons mémoire. Comme dit plus haut, il est possible d'échapper
complètement à l'attention du ramasse-miette avec des tampons en
mémoire directe. Et là on se retrouve avec le problème inverse :
comment (ou quand) désallouer ? On ne peut pas se contenter
d'allouer-désallouer ponctuellement un tampon en mémoire directe dans
un bloc ``try ... finally`` car l'allocation-désallocation de ce type
de mémoire est beaucoup plus coûteuse qu'avec la mémoire du tas (c'est
peut-être pour ça qu'on a inventé le tas, d'ailleurs). Donc il faut
recycler.

Comment se passe le recyclage ? Les objets supportant le recyclage
implantent ``io.netty.util.ReferenceCounted``. Lorsqu'ils sont créés
dans un ``ChannelHandler``, leur compteur est immédiatement incrémenté
de 1. Lorsqu'un ``ChannelHandler`` n'en a plus besoin (par exemple
après avoir encodé un objet ``FullHttpRequest``) leur compteur est
décrémenté de 1. Quand il atteint 0 le recyclage a lieu. Le
``ChannelPipeline`` fournit un allocateur, qui est paramétré pour
créer ces objets dans le tas mémoire, ou en mémoire directe. Dans le
cas minimal on n'a pas besoin d'intervenir directement sur le compteur
de références.

Mais si on doit y toucher, comment être sûr qu'on ne s'est pas planté
? Accéder à un objet dont le compteur de référence est à zéro provoque
une erreur, donc pas de doute possible. Mais si on a oublié de
décrémenter ? Alors on risque de causer une surconsommation mémoire
qui ne sera peut-être décelable qu'au bout de plusieurs jours, par
épuisement de la mémoire. Pour détecter rapidement un tel cas, Netty
fournit un mécanisme de vérification activable par une propriété
système. Si un objet est collecté alors que son compteur de références
est supérieur à 0, alors Netty journalise une exception en indiquant
l'état de la pile au moment de l'allocation. Le code applicatif peut
également "marquer" les instances de ``ReferenceCounted`` avec un
message de façon à tracer leur cycle de vie. Les mecs ont bien bossé.
Reply all
Reply to author
Forward
0 new messages