La mort prochaine du ramasse-miettes ?

le 11/02/2014 par Philippe Prados
Tags: Software Engineering

Les technologies de l’information dépendent de plusieurs ressources : la puissance des CPU, la mémoire, l’espace disque et la bande passante du réseau. Nous sommes capables d’augmenter les volumes de nos ressources, mais plus vraiment leurs performances. Lorsque la puissance n’est plus capable de gérer nos volumes: « Houston, nous avons un problème ».

Avec un titre aussi provocant, il est important d’avoir un regard lucide sur la situation actuelle et comment elle peut évoluer. Chacun se forgera son opinion.

Les quatre composants de ressources (vitesse de calcul, vitesse d'accès aux données, volume de stockage, algorithmes de gestion mémoire) se sont trouvés ces 20 dernières années dans une configuration très favorable aux ramasse-miettes. Aujourd'hui, la configuration a suffisamment évolué pour mettre le ramasse-miettes sous tension. Les évolutions incrémentales, qui sont réelles, suffisent de moins en moins à répondre aux demandes. Les GC évoluent, mais pas assez vite par rapport à leur environnement.

De nombreux signaux faibles montrent la fin probable des ramasse-miettes (GC) :

  • Les JVM 64 bits sont plus lentes que les 32 bits
  • Des problèmes de performances au-delà de 8/10Go
  • Un nouveau paramètre pour les JVM : -XX:MaxDirectMemorySize=_x_G
  • Twitter optimise les attributs dans les objets Java pour éviter les latences du GC

Chaque catégorie de ressource bénéficie régulièrement d’évolutions technologiques, permettant des traitements de plus en plus performants. Mais pas dans les mêmes proportions. Les volumes augmentent plus vites que les puissances de traitements.

La fréquence des processeurs ne peut plus vraiment progresser sans un échauffement intolérable. La taille de la gravure des processeurs approche dangereusement des effets quantiques. Des fils trop fins entraînent que les signaux deviennent probabilistes et non déterministes.  Alors, on multiplie les cœurs. Mais, la loi de Moore (doublement des capacités tous les 18 mois) présente des limites qui seront prochainement atteintes. Intel prédit l'atterrissage pour 2018. Il ne sera plus possible d’améliorer la vitesse d’exécution des programmes gratuitement, rien qu’en changeant de matériel.

Dans le même temps, les limites d’accès aux disques entraînent de nouvelles approches pour la gestion des bases de données (Big Data, all in-memory, NoSQL, etc.). Suivant les scénarios, ces dernières sont maintenant réparties sur plusieurs nœuds, avec les données intégralement en mémoire. C’est ce dernier mot qui pose problème. Une base de données en mémoire peut-elle être gérée par un GC ?

En théorie, un processeur 64 bits peut adresser 256 tébioctets (240) ou plus suivant les architectures. L’adressage permet maintenant de monter l’intégralité des bases de données des entreprises en mémoire, pour effectuer des calculs sans accès disques.

La mémoire des serveurs est multipliée par 100 tous les dix ans. Il ne semble pas y avoir de raison objective que cela cesse. Il s’agit d’une augmentation du volume, sans augmentation de la puissance pour la traiter.

L’aire du Big-memory approche dangereusement.

Le début de la fin

C’est le début de la fin des ramasse-miettes. Augmenter la taille mémoire commence à nuire gravement à l’approche de la gestion automatique de la mémoire. Il n’est pas rare d’avoir plusieurs secondes de pause au-delà de 8 ou 10Go de mémoire. Avec des volumes si importants, il va devenir de plus en plus difficile de laisser les langages gérer la mémoire. Avec les JVM 64 bits, le temps d’analyse de la mémoire a considérablement augmenté. Les périodes d’indisponibilité également.

Des paramètres spécifiques des JVM permettent de retarder l’échéance, en optimisant les situations généralement rencontrée actuellement. Pour libérer dix objets, l’effort est proportionnel à la mémoire globale. Pratiquement tous les algorithmes de GC sont victime d’une malédiction :

« Plus on augmente la mémoire, plus on dégrade les performances. »

Comme la puissance des processeurs est limitée, cette loi va entraîner inévitablement un changement d’approche.

Bien sûr, si on a les moyens, on peut dédier quelques cœurs à s’occuper uniquement de la mémoire. Il ne faut pas le dire trop fort. Un DSI n’appréciera pas de savoir qu’un quart de sa puissance machine est dédié à cela. Mais cela ne règle toujours pas le problème de l’arrêt du monde.

Une solution pour contrer cette terrible loi consiste à réduire le volume de donnée à traiter par le ramasse-miettes. Si un groupe d’objet peut être isolé des autres, il est possible d’y faire le ménage rapidement. Les programmes doivent être découpés en tranches (ou agent), avec des espaces mémoire réduits. Le ramasse-miettes travaille en parallèle sur des sous-ensembles d’objets. Cette approche est pertinente si l’algorithme lui-même n’a pas besoin de plus de mémoire que la tranche qui lui est allouée.

L'approche type Akka épaulée par des allocateurs mémoires par threads, peuvent bénéficier de cela. Tant que les objets sont alloués dans la zone des objets temporaires, et que cette zone reste à une taille raisonnable, nous pourrons encaisser l’augmentation de volume.

Une première approche consiste à isoler les groupes d’objets créés par le même thread. Chaque thread possède son allocateur. Des ramasse-miettes peuvent alors s’occuper de ces données à la mort du thread ou périodiquement. Cela limite les données à traiter, tant qu’elles ne sont pas partagées par plusieurs threads.

Il faut détecter dynamiquement les objets utilisés par plusieurs threads pour les exclure du groupe associé au thread. Un groupe “processus” peut se charger des objets partagés. Mais cela ne fait que retarder l’échéance. Dalvik, la machine virtuelle d'Android utilise une approche similaire.

Une autre approche consiste à utiliser des verrous hardwares lors de la lecture d’une zone mémoire, pour ajuster alors les pointeurs vers les nouveaux emplacements mémoires des objets ou pour les marquer sans interrompre le processus.

L’algorithme C4 de sans arrêt du monde, en capturant les accès mémoires vers les objets, via des fautes de pages et un module noyau (Voir l’article du blog d’Octo). Cela ne fonctionne que sur Linux. Est-il nécessaire de faire évoluer les systèmes d’exploitation pour pouvoir gérer correctement les ramasse-miettes ? Pourquoi pas.

Quelques projets de bases de données en mémoires proposent déjà des approches mixtes, combinant une JVM et un allocateur mémoire externe (Terracotta ou Apache Direct Memory). Utiliser une mémoire hors heap permet en effet de régler pas mal de problèmes. Les développeurs sont ainsi libérés de la gestion mémoire lors des traitements, mais doivent gérer le cycle de vie des objets en dehors du ramasse-miettes. Le problème majeur de ces approches est qu'il est nécessaire de dupliquer les buffers entre la mémoire hors-heap et la mémoire in-heap. Déjà que Java se charge d'initialiser le buffer avec des zéros, cela fait beaucoup de cycles pour pas grand-chose.

L'existence même de ces composants démontre que le ramasse-miettes est déjà un problème qu’il faut gérer. Comme pour les autres technologies, les langages devront évoluer pour intégrer ces nouvelles contraintes.

Bien sûr, il reste la scalabilité horizontale. Mais la justifier uniquement par l’incapacité du ramasse-miettes à gérer convenablement la mémoire ne me semble pas une approche économiquement pertinente. Ajouter une machine n’est pas une solution.

Même si la mode du moment est d’avoir systématiquement un ramasse-miettes pour chaque nouveau langage, il va falloir trouver d’autres approches pour une gestion prédictible et déterministe de la mémoire. Fini le temps où la mémoire est traitée différemment que les fichiers ouverts, les sockets, les sessions utilisateurs, etc. Il va falloir rentrer dans le rang et gérer toutes les ressources dans les développements.

Bien entendu, suivant les contextes, les impacts du ramasse-miettes seront différents. Apple a choisi de le bannir, afin d’optimiser au maximum les performances. Google a choisi de le garder dans Android, afin de privilégier la portabilité du code sur différents processeurs, et éviter les fuites mémoires. Par contre, les contextes de type Big Data + Dababase in memory risquent de souffrir. Par exemple, Twitter organise les classes Java pour économiser la mémoire, et cherche à gérer tous les objets dans la zone Eden de la JVM. Nous avons rencontré les mêmes limitations sur un projet de recherche à haute fréquence.

Autres pistes ?

Bien entendu, il est préférable d’éviter de gérer les couples malloc()/free(). Il existe des solutions sémantiques à cela, pas forcément très compliquées, et permettant une gestion efficace de la mémoire. L’intégralité de la puissance CPU est alors dédiée au programme. Il ne faut pas consacrer une fraction de la puissance à faire le ménage en tâche de fond.

Il est envisageable d’enrichir les langages de nouveaux types de pointeurs, pour associer automatiquement le cycle de vie des objets à la vie du pointeur. L’objectif est d’enrichir la sémantique des pointeurs pour identifier, lors de la compilation, le moment exact où une donnée peut être effacée.

Un pointeur peut être une relation, une agrégation, une agrégation partagée, référencer un objet immuable, etc. Suivant les cas, la gestion de la mémoire peut être déterministe. L’essentiel des problèmes de gestions de la mémoire est dû à l’absence de sens sur chaque pointeur. La version 11 de C++ propose quelques approches comme les rvalue references, les unique_ptr (pour gérer les agrégations) à la place des auto_ptr, les shared_ptr et weak_ptr pour les compteurs de références.

Ces approches permettent une gestion de la mémoire déterministe, explicite, avec une sémantique forte, sans utiliser de ramasse-miettes.

Même pour des environnements plus légers, le ramasse-miettes est remis en cause. Apple propose la technologie Automatic Reference Counting (ARC) pour déduire de l’analyse des sources, le cycle de vie des objets. Cette technologie s’appuie sur des compteurs de références. Lorsque le compteur tombe à zéro, l’objet peut être supprimé. La gestion des cycles dans les objets est alors problématique. Les compteurs ne sont plus à zéro, alors que l'îlot d’objets n’est plus accessible par un autre pointeur. Des pointeurs faibles permettent de gérer ces situations “à la main”.

D’autres approches proposent un ramasse-miettes à base de compteur de référence et des algorithmes pour détecter les cycles. D’autres approches encore, proposent d’isoler les traitements dans des espaces mémoire réduits. Chaque processus ou traitement travail dans un espace confiné. La communication entre les traitements s’effectue à base de messages (ErlangGo).

Dans une certaine mesure, JavaScript ou Dart propose cette démarche pour la gestion du multitâche. Cette approche présente l’avantage de permettre la distribution des traitements sur plusieurs serveurs. Un passage par valeur des données, entre différents espaces d’exécutions, peut être la solution. Elle présente néanmoins un coût en performances dû aux sérialisation/désérialisation des messages.

Une autre approche consiste à utiliser systématiquement des objets immuables (approche fonctionnelle). Un objet ancien ne peut pas pointer vers un objet plus récent. La direction du graphe permet alors d’optimiser les algorithmes. L'analyse se limite quasiment (en pratique) à une analyse locale dans l'espace mémoire du thread. L’arrêt du monde est limité aux variables globales, très peu nombreuses en général sur les sites Web. Les langages fonctionnels comme Haskell proposent ce type d’approche.

Des extensions C++ à Hadoop permettent déjà d’améliorer notablement les performances des algorithmes Map-Reduce. Avec des résultats significatifs. BugSense, un gestionnaire de logs mobiles, préfère utiliser ERLang, C, C++ et Lisp, pour améliorer les performances, l’empreinte mémoire et finalement, réduire le nombre de machine dans le Cloud, donc le coût.

Les machines les plus puissantes d’Amazon EC2 proposent 15Go de mémoire. Dans quelques années, nous aurons 1To. Quel ramasse-miettes sera capable de gérer cela ? Les derniers chiffres que nous avons trouvés date de septembre 2008, avec 3,5 Téraoctets. L’arrêt du monde dure 30 secondes. Les algorithmes ont été optimisés depuis, mais en quelle proportion ?

L’avenir

Nous pouvons dès à présent choisir les critères permettant de sélectionner une technologie pour les années futures. Comme toutes les ressources arrivent définitivement à saturation, nous devons sélectionner un langage de développement et une architecture qui soit capable de répondre à plusieurs critères.

Dans l’idéal, les solutions sélectionnées doivent pouvoir encaisser l’augmentation des volumes des ressources, sans exiger de puissance supplémentaire. En situation de Big-memory, il faut probablement une gestion “à la main” ou une gestion aidée par un enrichissement sémantique des pointeurs (compteurs de références, sémantiques d’agrégation, etc.). La libération de la mémoire doit être déterministe.

Pour gérer la puissance CPU, les traitements doivent pouvoir être répartis entre plusieurs nœuds. Les langages doivent supporter ce paradigme, en facilitant la communication entre les traitements, par l’envoi de données par valeur.

Pour la mémoire de masse, les données doivent pouvoir être traitées intégralement en mémoire. La persistance s’effectue, soit avec une mémoire statique, soit avec une réplication entre plusieurs nœuds, soit en enregistrant des logs lors de chaque modification. L’enregistrement étant au fils de l’eau, il n’est pas nécessaire de repositionner la tête de lecture lors de chaque écriture.

Ces critères de choix permettent de sélectionner aujourd’hui les technologies à investir.

Il y a quinze ans, nous étions peu nombreux à penser que les bases de données relationnelles seraient contestées, pour utiliser d’autres approches comme des données intégralement en mémoire ou d’autres NoSQL.

Parions que les mémoires statiques et volatiles vont se rejoindre. Qu’ainsi, la notion de mémoire de masse disparaisse. Toutes les données seront présentes en mémoire et persistantes.

Les ramasse-miettes ne seront alors plus la solution, mais le problème. Dans quelques années, une révolution sera nécessaire dans la gestion mémoire des langages. Les ramasse-miettes ne sont pas encore morts, ils ont de beaux jours devant eux, mais des approches complémentaires sont nécessaires.

Est-ce qu’un algorithme magique va émerger, alors que cela fait vingt ans que l’on cherche ? Peut-être.

Philippe PRADOS