Qu'est-ce qui gonfle la mémoire dans Ruby?

Chez Phusion, nous avons un simple proxy HTTP multi-thread en Ruby (distribue les packages DEB et RPM). J'ai vu dessus une consommation de mémoire de 1,3 Go. Mais c'est fou pour un processus apatride ...


Question: qu'est-ce que c'est? Réponse: Ruby utilise la mémoire au fil du temps!

Il s'avère que je ne suis pas seul dans ce problème. Les applications Ruby peuvent utiliser beaucoup de mémoire. Mais pourquoi? Selon Heroku et Nate Burkopek , les ballonnements sont principalement dus à la fragmentation de la mémoire et à une distribution excessive des tas .

Berkopek a conclu qu'il existe deux solutions:

  1. Utilisez un allocateur de mémoire complètement différent de celui de la glibc - généralement jemalloc , ou:
  2. Définissez la variable d'environnement magique MALLOC_ARENA_MAX=2 .

Je m'inquiète de la description du problème et des solutions proposées. Il y a quelque chose qui ne va pas ici ... Je ne suis pas sûr que le problème soit entièrement décrit correctement ou que ce soient les seules solutions disponibles. Cela m'énerve aussi que beaucoup se réfèrent à jemalloc comme un bassin d'argent magique.

La magie n'est qu'une science que nous ne comprenons pas encore . J'ai donc fait un voyage de recherche pour découvrir toute la vérité. Cet article couvrira les sujets suivants:

  1. Comment fonctionne l'allocation de mémoire.
  2. Quelle est cette «fragmentation» et «distribution excessive» de la mémoire dont tout le monde parle?
  3. Qu'est-ce qui cause une grande consommation de mémoire? La situation est-elle cohérente avec ce que les gens disent ou y a-t-il autre chose? (spoiler: oui, il y a autre chose).
  4. Existe-t-il des solutions alternatives? (spoiler: j'en ai trouvé un).

Remarque: cet article ne concerne que Linux et uniquement les applications Ruby multithread.

Table des matières



Allocation de mémoire Ruby: une introduction


Ruby alloue de la mémoire à trois niveaux, de haut en bas:

  1. Interprète Ruby qui gère les objets Ruby.
  2. La bibliothèque d'allocateurs de mémoire du système d'exploitation.
  3. Le noyau.

Passons en revue chaque niveau.

Rubis


De son côté, Ruby organise les objets dans des zones de mémoire appelées pages de tas Ruby . Une telle page de segment est divisée en emplacements de même taille, où un objet occupe un emplacement. Que ce soit une chaîne, une table de hachage, un tableau, une classe ou autre chose, il occupe un emplacement.



Les emplacements sur la page de tas peuvent être occupés ou libres. Lorsque Ruby sélectionne un nouvel objet, il essaie immédiatement d'occuper un emplacement libre. S'il n'y a pas d'emplacements libres, une nouvelle page de tas sera mise en évidence.

L'emplacement est petit, environ 40 octets. De toute évidence, certains objets ne rentreront pas dedans, par exemple, des lignes de 1 Mo. Ruby stocke ensuite les informations ailleurs en dehors de la page de segment de mémoire et place un pointeur sur cette zone de mémoire externe dans l'emplacement.


Les données qui ne tiennent pas dans l'emplacement sont stockées en dehors de la page de segment de mémoire. Ruby place un pointeur sur ces données externes dans le slot

Les pages de segment Ruby et toutes les zones de mémoire externe sont allouées à l'aide de l'allocateur de mémoire système.

Allocateur de mémoire système


L'allocateur de mémoire du système d'exploitation fait partie de la glibc (runtime C). Il est utilisé par presque toutes les applications, pas seulement Ruby. Il a une API simple:

  • La mĂ©moire est allouĂ©e en appelant malloc(size) . Vous lui donnez le nombre d'octets que vous souhaitez allouer et il renvoie soit l'adresse d'allocation, soit une erreur.
  • La mĂ©moire allouĂ©e est libĂ©rĂ©e en appelant free(address) .

Contrairement à Ruby, où des emplacements de même taille sont alloués, l'allocateur de mémoire traite les demandes d'allocation de mémoire de n'importe quelle taille. Comme vous l'apprendrez plus tard, ce fait entraîne certaines complications.

À son tour, l'allocateur de mémoire accède à l'API du noyau. Cela prend des morceaux de mémoire beaucoup plus importants au noyau que ne le demandent ses propres abonnés, car l'appel du noyau est cher et l'API du noyau a une limitation: elle ne peut allouer de la mémoire que par multiples de 4 Ko.


L'allocateur de mémoire alloue de gros morceaux - on les appelle des tas de système - et partage leur contenu pour satisfaire les demandes des applications

La zone de mémoire que l'allocateur de mémoire alloue à partir du noyau s'appelle le tas. Notez que cela n'a rien à voir avec les pages du tas Ruby, donc pour plus de clarté, nous utiliserons le terme tas système .

L'allocateur de mémoire affecte ensuite des parties des tas du système à ses appelants jusqu'à ce qu'il y ait de l'espace libre. Dans ce cas, l'allocateur de mémoire alloue un nouveau segment système à partir du noyau. Ceci est similaire à la façon dont Ruby sélectionne les objets dans les pages d'un tas Ruby.


Ruby alloue de la mémoire à partir de l'allocateur de mémoire, qui à son tour alloue de la mémoire à partir du noyau

Le noyau


Le noyau ne peut allouer de la mémoire qu'en unités de 4 Ko. Un tel bloc 4K est appelé une page. Pour éviter toute confusion avec les pages de tas Ruby, pour plus de clarté, nous utiliserons le terme page système (page OS).

La raison est difficile Ă  expliquer, mais c'est ainsi que fonctionnent tous les noyaux modernes.

L'allocation de mémoire via le noyau a un impact significatif sur les performances, c'est pourquoi les allocateurs de mémoire tentent de minimiser le nombre d'appels du noyau.

Définition de l'utilisation de la mémoire


Ainsi, la mémoire est allouée à plusieurs niveaux, et chaque niveau alloue plus de mémoire qu'il n'en a réellement besoin. Les pages de tas Ruby peuvent avoir des emplacements libres, ainsi que des tas de système. Par conséquent, la réponse à la question "Quelle quantité de mémoire est utilisée?" dépend complètement du niveau que vous demandez!

Des outils comme top ou ps montrent l'utilisation de la mémoire du point de vue du noyau . Cela signifie que les niveaux supérieurs doivent fonctionner de concert pour libérer la mémoire du point de vue du noyau. Comme vous l'apprendrez plus tard, c'est plus difficile qu'il n'y paraît.

Qu'est-ce que la fragmentation?


La fragmentation de la mémoire signifie que les allocations de mémoire sont dispersées de manière aléatoire. Cela peut provoquer des problèmes intéressants.

Fragmentation au niveau du rubis


Considérez la collecte des ordures Ruby. Le ramasse-miettes d'un objet signifie marquer l'emplacement de page de tas Ruby comme libre, ce qui permet de le réutiliser. Si la page entière du tas Ruby se compose uniquement d'emplacements libres, sa page entière peut être libérée dans l'allocateur de mémoire (et, éventuellement, dans le noyau).



Mais que se passe-t-il si tous les emplacements ne sont pas gratuits? Que se passe-t-il si nous avons plusieurs pages du tas Ruby et que le garbage collector libère des objets à différents endroits, de sorte qu'à la fin il y a beaucoup de slots libres, mais sur des pages différentes? Dans cette situation, Ruby a des emplacements libres pour placer des objets, mais l'allocateur de mémoire et le noyau continueront d'allouer de la mémoire!

Fragmentation d'allocation de mémoire


L'allocateur de mémoire a un problème similaire mais complètement différent. Il n'a pas besoin d'effacer immédiatement tous les tas du système. Théoriquement, il peut libérer n'importe quelle page système unique. Mais comme l'allocateur de mémoire traite des allocations de mémoire de taille arbitraire, il peut y avoir plusieurs allocations sur la page système. Il ne peut pas libérer la page système tant que toutes les sélections ne sont pas libérées.



Pensez à ce qui se passe si nous avons une allocation de 3 Ko, ainsi qu'une allocation de 2 Ko, divisée en deux pages système. Si vous libérez les 3 premiers Ko, les deux pages système resteront partiellement occupées et ne pourront pas être libérées.



Par conséquent, si les circonstances échouent, il y aura beaucoup d'espace libre sur les pages système, mais elles ne seront pas entièrement libérées.

Pire encore: que se passe-t-il s'il y a beaucoup de places libres, mais qu'aucune n'est assez grande pour satisfaire une nouvelle demande d'allocation? L'allocateur de mémoire devra allouer un tout nouveau tas système.

La fragmentation de la page du tas Ruby provoque-t-elle une surcharge de la mémoire?


Il est probable que la fragmentation entraîne une surutilisation de la mémoire dans Ruby. Si oui, laquelle des deux fragmentations est la plus nuisible? C'est ...

  1. Fragmentation de la page du tas de rubis? Ou
  2. Fragmentation de l'allocateur de mémoire?

La première option est assez simple à vérifier. Ruby fournit deux API: ObjectSpace.memsize_of_all et GC.stat . Grâce à ces informations, vous pouvez calculer toute la mémoire reçue par Ruby de l'allocateur.



ObjectSpace.memsize_of_all renvoie la mémoire occupée par tous les objets Ruby actifs. Autrement dit, tout l'espace dans leurs emplacements et toutes les données externes. Dans le diagramme ci-dessus, il s'agit de la taille de tous les objets bleus et oranges.

GC.stat permet de connaître la taille de tous les emplacements libres, c'est-à-dire toute la zone grise dans l'illustration ci-dessus. Voici l'algorithme:

 GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE] 

Pour les résumer, c'est toute la mémoire que Ruby connaît, et cela implique de fragmenter les pages du tas Ruby. Si, du point de vue du noyau, l'utilisation de la mémoire est plus élevée, alors la mémoire restante va quelque part en dehors du contrôle de Ruby, par exemple, vers des bibliothèques tierces ou la fragmentation.

J'ai écrit un programme de test simple qui crée un tas de threads, chacun sélectionnant des lignes dans une boucle. Voici le résultat après un certain temps:



c'est ... juste ... fou!

Le résultat montre que Ruby a un effet si faible sur la quantité totale de mémoire utilisée, peu importe si les pages du tas Ruby sont fragmentées ou non.

Faut chercher le coupable ailleurs. Au moins maintenant, nous savons que Ruby n'est pas à blâmer.

Étude de fragmentation d'allocation de mémoire


Un autre suspect probable est un allocateur de mémoire. À la fin, Nate Berkopek et Heroku ont remarqué que s'occuper de l'allocateur de mémoire (soit un remplacement Jemalloc complet ou la définition de la variable d'environnement magique MALLOC_ARENA_MAX=2 ) réduit considérablement l'utilisation de la mémoire.

Voyons d'abord ce que fait MALLOC_ARENA_MAX=2 et pourquoi cela aide. Ensuite, nous examinons la fragmentation au niveau du distributeur.

Allocation de mémoire excessive et glibc


La raison MALLOC_ARENA_MAX=2 laquelle MALLOC_ARENA_MAX=2 aide est à MALLOC_ARENA_MAX=2 multithreading. Lorsque plusieurs threads tentent simultanément d'allouer de la mémoire à partir du même tas système, ils se battent pour l'accès. Un seul thread à la fois peut recevoir de la mémoire, ce qui réduit les performances de l'allocation de mémoire multi-thread.


Un seul thread à la fois peut fonctionner avec le tas système. Dans les tâches multithreads, un conflit survient et, par conséquent, les performances diminuent

Dans l'allocateur de mémoire pour un tel cas, il y a optimisation. Il essaie de créer plusieurs tas de système et de les affecter à différents threads. La plupart du temps, un thread ne fonctionne qu'avec son propre segment de mémoire, évitant les conflits avec d'autres threads.

En fait, le nombre maximum de segments système alloués de cette manière est par défaut égal au nombre de processeurs virtuels multiplié par 8. Autrement dit, dans un système dual-core avec deux hyper-threads, chacun produit 2 * 2 * 8 = 32 segments système! C'est ce que j'appelle une distribution excessive .

Pourquoi le multiplicateur par défaut est-il si grand? Parce que le principal développeur de l'allocateur de mémoire est Red Hat. Leurs clients sont de grandes entreprises avec des serveurs puissants et une tonne de RAM. L'optimisation ci-dessus vous permet d'augmenter les performances moyennes de multithreading de 10% en raison d'une augmentation significative de l'utilisation de la mémoire. Pour les clients de Red Hat, c'est un bon compromis. Pour la plupart des autres - à peine.

Nate dans son blog et son article Heroku affirment que l'augmentation du nombre de tas de système augmente la fragmentation et citent la documentation officielle. La variable MALLOC_ARENA_MAX réduit le nombre maximal de MALLOC_ARENA_MAX système alloués pour le multithreading. Par cette logique, il réduit la fragmentation.

Visualisation des tas de système


La déclaration de Nate et Heroku est-elle vraie que l'augmentation du nombre de tas de système augmente la fragmentation? En fait, y a-t-il un problème de fragmentation au niveau de l'allocateur de mémoire? Je ne voulais prendre aucune de ces hypothèses pour acquise, alors j'ai commencé l'étude.

Malheureusement, il n'y a pas d'outils pour visualiser les tas de système, j'ai donc écrit moi-même un tel visualiseur .

Tout d'abord, vous devez en quelque sorte conserver le schéma de distribution des tas de système. J'ai étudié la source de l'allocateur de mémoire et regardé comment il représente en interne la mémoire. Il a ensuite écrit une bibliothèque qui itère sur ces structures de données et écrit le schéma dans un fichier. Enfin, il a écrit un outil qui prend un tel fichier en entrée et compile la visualisation en images HTML et PNG ( code source ).



Voici un exemple de visualisation d'un segment de système spécifique (il y en a beaucoup plus). Les petits blocs de cette visualisation représentent des pages système.

  • Les zones rouges sont des cellules de mĂ©moire utilisĂ©es.
  • Les gris sont des zones libres qui ne sont pas restituĂ©es au cĹ“ur.
  • Les zones blanches sont libĂ©rĂ©es pour le noyau.

Les conclusions suivantes peuvent être tirées de la visualisation:

  1. Il y a une certaine fragmentation. Les taches rouges sont dispersées dans la mémoire et certaines pages système ne sont qu'à moitié rouges.
  2. À ma grande surprise, la plupart des tas de système contiennent une quantité importante de pages système entièrement gratuites (grises)!

Et puis il m'est apparu:

Bien que la fragmentation reste un problème, ce n'est pas la question!

Au contraire, le problème est beaucoup de gris: cet allocateur de mémoire ne renvoie pas de mémoire au noyau !

Après avoir réexaminé le code source de l'allocateur de mémoire, il s'est avéré que par défaut, il n'envoie que les pages système au noyau à la fin du tas système, et même rarement . Probablement, un tel algorithme est implémenté pour des raisons de performances.

Tour de magie: circoncision


Heureusement, j'ai trouvé une astuce. Il y a une interface de programmation qui forcera l'allocateur de mémoire à libérer pour le noyau non seulement la dernière, mais toutes les pages système pertinentes. Il s'appelle malloc_trim .

Je connaissais cette fonction, mais je ne pensais pas qu'elle était utile, car le manuel dit ce qui suit:

La fonction malloc_trim () essaie de libérer de la mémoire libre en haut du tas.

Le manuel est faux! L'analyse du code source indique que le programme libère toutes les pages système pertinentes, pas seulement le haut.

Que se passe-t-il si cette fonction est appelée pendant la récupération de place? J'ai modifié le code source de Ruby 2.6 pour appeler malloc_trim() dans la fonction gc_start de gc.c, par exemple:

 gc_prof_timer_start(objspace); { gc_marks(objspace, do_full_mark); // BEGIN MODIFICATION if (do_full_mark) { malloc_trim(0); } // END MODIFICATION } gc_prof_timer_stop(objspace); 

Et voici les résultats des tests:



Quelle grande différence! Un simple patch a réduit la consommation de mémoire à presque MALLOC_ARENA_MAX=2 .

Voici Ă  quoi cela ressemble dans la visualisation:



Nous voyons de nombreuses zones blanches qui correspondent aux pages système libérées dans le noyau.

Conclusion


Il s'est avéré que la fragmentation n'avait rien à voir avec cela. La défragmentation est toujours utile, mais le principal problème est que l'allocateur de mémoire n'aime pas libérer de la mémoire dans le noyau.

Heureusement, la solution s'est avérée très simple. L'essentiel était de trouver la cause profonde.

Code source du visualiseur


Code source

Et la performance?


La performance est restée l'une des principales préoccupations. L'appel de malloc_trim() ne peut pas être malloc_trim() gratuitement, mais selon le code, l'algorithme fonctionne en temps linéaire. Je me suis donc tourné vers Noah Gibbs , qui a lancé la référence Rails Ruby Bench. À ma grande surprise, le patch a provoqué une légère augmentation des performances.





Cela m'a époustouflé. L'effet est incompréhensible, mais les nouvelles sont bonnes.

Besoin de plus de tests.


Dans le cadre de cette étude, seul un nombre limité de cas a été vérifié. On ne sait pas quel est l'impact sur les autres charges de travail. Si vous souhaitez aider avec les tests, veuillez me contacter .

Source: https://habr.com/ru/post/fr444482/


All Articles