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.
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 (Erlang, Go).
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 ?
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.