Fragmentation du tas d'objets volumineux

97

L'application C # / .NET sur laquelle je travaille souffre d'une lente fuite de mémoire. J'ai utilisé CDB avec SOS pour essayer de déterminer ce qui se passe mais les données ne semblent pas avoir de sens, donc j'espérais que l'un de vous en a déjà fait l'expérience.

L'application s'exécute sur le framework 64 bits. Il calcule et sérialise en permanence des données vers un hôte distant et atteint un peu le tas d'objets volumineux (LOH). Cependant, la plupart des objets LOH que je pense être transitoires: une fois le calcul terminé et envoyé à l'hôte distant, la mémoire doit être libérée. Ce que je vois, cependant, est un grand nombre de tableaux d'objets (vivants) entrelacés avec des blocs de mémoire libres, par exemple en prenant un segment aléatoire de la LOH:

0:000> !DumpHeap 000000005b5b1000  000000006351da10
         Address               MT     Size
...
000000005d4f92e0 0000064280c7c970 16147872
000000005e45f880 00000000001661d0  1901752 Free
000000005e62fd38 00000642788d8ba8     1056       <--
000000005e630158 00000000001661d0  5988848 Free
000000005ebe6348 00000642788d8ba8     1056
000000005ebe6768 00000000001661d0  6481336 Free
000000005f214d20 00000642788d8ba8     1056
000000005f215140 00000000001661d0  7346016 Free
000000005f9168a0 00000642788d8ba8     1056
000000005f916cc0 00000000001661d0  7611648 Free
00000000600591c0 00000642788d8ba8     1056
00000000600595e0 00000000001661d0   264808 Free
...

Évidemment, je m'attendrais à ce que ce soit le cas si mon application créait des objets volumineux et durables pendant chaque calcul. (Il fait cela et j'accepte qu'il y aura un degré de fragmentation LOH mais ce n'est pas le problème ici.) Le problème est les très petits tableaux d'objets (1056 octets) que vous pouvez voir dans le vidage ci-dessus que je ne peux pas voir dans le code en cours de création et qui restent enracinés d'une manière ou d'une autre.

Notez également que CDB ne signale pas le type lorsque le segment de tas est vidé: je ne sais pas si cela est lié ou non. Si je vide l'objet marqué (<-), CDB / SOS le signale bien:

0:015> !DumpObj 000000005e62fd38
Name: System.Object[]
MethodTable: 00000642788d8ba8
EEClass: 00000642789d7660
Size: 1056(0x420) bytes
Array: Rank 1, Number of elements 128, Type CLASS
Element Type: System.Object
Fields:
None

Les éléments du tableau d'objets sont tous des chaînes et les chaînes sont reconnaissables à partir de notre code d'application.

De plus, je suis incapable de trouver leurs racines GC car la commande! GCRoot se bloque et ne revient jamais (j'ai même essayé de la laisser du jour au lendemain).

Donc, j'apprécierais beaucoup si quelqu'un pouvait expliquer pourquoi ces petits tableaux d'objets (<85k) se retrouvent sur le LOH: dans quelles situations .NET mettra-t-il un petit tableau d'objets? De plus, est-ce que quelqu'un connaît une autre façon de déterminer les racines de ces objets?


Mise à jour 1

Une autre théorie que j'ai proposée tard hier est que ces tableaux d'objets ont commencé en grand mais ont été réduits en laissant les blocs de mémoire libre qui sont évidents dans les vidages de mémoire. Ce qui me rend suspect, c'est que les tableaux d'objets semblent toujours avoir une longueur de 1056 octets (128 éléments), 128 * 8 pour les références et 32 ​​octets de surcharge.

L'idée est que peut-être un code non sécurisé dans une bibliothèque ou dans le CLR endommage le champ du nombre d'éléments dans l'en-tête du tableau. Un peu long, je sais ...


Mise à jour 2

Grâce à Brian Rasmussen (voir la réponse acceptée), le problème a été identifié comme une fragmentation de la LOH causée par la table des chaînes internes! J'ai écrit une application de test rapide pour confirmer ceci:

static void Main()
{
    const int ITERATIONS = 100000;

    for (int index = 0; index < ITERATIONS; ++index)
    {
        string str = "NonInterned" + index;
        Console.Out.WriteLine(str);
    }

    Console.Out.WriteLine("Continue.");
    Console.In.ReadLine();

    for (int index = 0; index < ITERATIONS; ++index)
    {
        string str = string.Intern("Interned" + index);
        Console.Out.WriteLine(str);
    }

    Console.Out.WriteLine("Continue?");
    Console.In.ReadLine();
}

L'application crée et déréférence d'abord des chaînes uniques dans une boucle. C'est juste pour prouver que la mémoire ne fuit pas dans ce scénario. De toute évidence, il ne devrait pas et ce ne l'est pas.

Dans la deuxième boucle, des chaînes uniques sont créées et internées. Cette action les enracine dans la table interne. Ce que je ne savais pas, c'est comment la table interne est représentée. Il semble qu'il se compose d'un ensemble de pages - des tableaux d'objets de 128 éléments de chaîne - qui sont créés dans le LOH. Ceci est plus évident dans CDB / SOS:

0:000> .loadby sos mscorwks
0:000> !EEHeap -gc
Number of GC Heaps: 1
generation 0 starts at 0x00f7a9b0
generation 1 starts at 0x00e79c3c
generation 2 starts at 0x00b21000
ephemeral segment allocation context: none
 segment    begin allocated     size
00b20000 00b21000  010029bc 0x004e19bc(5118396)
Large object heap starts at 0x01b21000
 segment    begin allocated     size
01b20000 01b21000  01b8ade0 0x00069de0(433632)
Total Size  0x54b79c(5552028)
------------------------------
GC Heap Size  0x54b79c(5552028)

Faire un vidage du segment LOH révèle le modèle que j'ai vu dans l'application qui fuit:

0:000> !DumpHeap 01b21000 01b8ade0
...
01b8a120 793040bc      528
01b8a330 00175e88       16 Free
01b8a340 793040bc      528
01b8a550 00175e88       16 Free
01b8a560 793040bc      528
01b8a770 00175e88       16 Free
01b8a780 793040bc      528
01b8a990 00175e88       16 Free
01b8a9a0 793040bc      528
01b8abb0 00175e88       16 Free
01b8abc0 793040bc      528
01b8add0 00175e88       16 Free    total 1568 objects
Statistics:
      MT    Count    TotalSize Class Name
00175e88      784        12544      Free
793040bc      784       421088 System.Object[]
Total 1568 objects

Notez que la taille du tableau d'objets est de 528 (au lieu de 1056) car mon poste de travail est de 32 bits et le serveur d'applications est de 64 bits. Les tableaux d'objets comportent toujours 128 éléments.

Donc, la morale de cette histoire est d'être un stage très prudent. Si la chaîne que vous effectuez n'est pas connue pour être membre d'un ensemble fini, votre application fuira en raison de la fragmentation du LOH, au moins dans la version 2 du CLR.

Dans le cas de notre application, il y a du code général dans le chemin du code de désérialisation qui intègre les identifiants d'entité lors de la démarshalling: je soupçonne maintenant fortement que c'est le coupable. Cependant, les intentions du développeur étaient évidemment bonnes car ils voulaient s'assurer que si la même entité est désérialisée plusieurs fois, une seule instance de la chaîne d'identifiant sera conservée en mémoire.

Paul Ruane
la source
2
Excellente question - j'ai remarqué la même chose dans mon application. Petits objets laissés dans le LOH après le nettoyage des gros blocs, ce qui cause des problèmes de fragmentation.
Reed Copsey
2
Je suis d'accord, excellente question. Je surveillerai les réponses.
Charlie Flowers
2
Très intéressant. On dirait que c'était tout un problème à déboguer!
Matt Jordan

Réponses:

46

Le CLR utilise le LOH pour préallouer quelques objets (comme le tableau utilisé pour les chaînes internées ). Certains d'entre eux font moins de 85 000 octets et ne seraient donc normalement pas alloués sur le LOH.

C'est un détail d'implémentation, mais je suppose que la raison en est d'éviter le garbage collection inutile d'instances censées survivre aussi longtemps que le processus lui-même.

En raison également d'une optimisation quelque peu ésotérique, l'un double[]des 1000 éléments ou plus est également alloué sur la LOH.

Brian Rasmussen
la source
Les objets problématiques sont des objets [] contenant des références à des chaînes dont je sais qu'elles sont créées par le code de l'application. Cela implique que l'application crée les objets [] (je ne vois pas de preuve de cela) ou qu'une partie du CLR (telle que la sérialisation) les utilise pour travailler sur les objets de l'application.
Paul Ruane
1
Cela pourrait être la structure interne utilisée pour les chaînes internées. Veuillez vérifier ma réponse à cette question pour plus de détails: stackoverflow.com/questions/372547/…
Brian Rasmussen
Ah, c'est une piste très intéressante, merci. Complètement oublié la table interne. Je sais que l'un de nos développeurs est un passionné, c'est donc quelque chose que je vais certainement étudier.
Paul Ruane
1
85000 octets ou 84 * 1024 = 87040 octets?
Peter Mortensen
5
85000 octets. Vous pouvez le vérifier en créant un tableau d'octets de 85000-12 (taille de longueur, MT, bloc de synchronisation) et en appelant GC.GetGenerationl'instance. Cela renverra Gen2 - l'API ne fait pas la distinction entre Gen2 et LOH. Réduisez le tableau d'un octet et l'API renverra Gen0.
Brian Rasmussen
13

Le .NET Framework 4.5.1 a la capacité de compacter explicitement le tas d'objets volumineux (LOH) pendant le garbage collection.

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Voir plus d'informations dans GCSettings.LargeObjectHeapCompactionMode

André Abrantes
la source
2

Lors de la lecture des descriptions du fonctionnement de GC et de la partie sur la durée de vie des objets dans la génération 2, et la collection d'objets LOH se produit uniquement à la collection complète - tout comme la collection de la génération 2, l'idée qui vient à l'esprit est. .. pourquoi ne pas simplement garder la génération 2 et les gros objets dans le même tas, car ils vont être rassemblés ensemble?

Si c'est ce qui se passe réellement, cela expliquerait comment les petits objets se retrouvent au même endroit que le LOH - s'ils vivent suffisamment longtemps pour finir dans la génération 2.

Et donc votre problème semble être une assez bonne réfutation à l'idée qui me vient à l'esprit - cela entraînerait la fragmentation de la LOH.

Résumé: votre problème pourrait être expliqué par le partage de la LOH et de la génération 2 dans la même région de tas, bien que ce ne soit en aucun cas la preuve que ce soit l'explication.

Mise à jour: la sortie de à !dumpheap -statpeu près souffle cette théorie hors de l'eau! La génération 2 et LOH ont leurs propres régions.

Daniel Earwicker
la source
Utilisez! Eeheap pour afficher les segments qui composent chaque tas. Les générations 0 et 1 vivent dans un segment (le même segment), les générations 2 et LOH peuvent allouer plusieurs segments, mais ces segments pour chaque tas restent séparés.
Paul Ruane
Oui, j'ai vu ça, merci. Je voulais juste mentionner la commande! Eeheaps car elle montre ce comportement d'une manière beaucoup plus claire.
Paul Ruane
L'efficacité du GC principal provient en grande partie du fait qu'il peut déplacer des objets de sorte qu'il n'y aura qu'un petit nombre de régions libres de mémoire sur le tas principal. Si un objet sur le tas principal est épinglé pendant une collection, l'espace au-dessus et au-dessous de l'objet épinglé peut devoir être suivi séparément, mais comme le nombre d'objets épinglés est normalement très petit, le nombre de zones séparées le sera également. Piste. Le mélange d'objets déplaçables et non déplaçables (grands) dans le même tas altérerait les performances.
supercat
Une question plus intéressante est de savoir pourquoi .NET place des doubletableaux de plus de 1000 éléments sur la LOH, plutôt que de peaufiner le GC afin de s'assurer qu'ils sont alignés sur des limites de 8 octets. En fait, même sur un système 32 bits, je m'attendrais à ce qu'en raison du comportement du cache, imposer un alignement de 8 octets sur tous les objets dont la taille allouée est un multiple de 8 octets serait probablement une amélioration des performances. Sinon, alors que les performances d'un appareil très utilisé double[]et aligné sur le cache seraient meilleures que celles d'un autre qui ne l'est pas, je ne sais pas pourquoi la taille serait en corrélation avec l'utilisation.
supercat
@supercat De plus, les deux tas se comportent également très différemment dans l'allocation. Le tas principal est (pour le moment) essentiellement une pile de modèles d'allocation - il alloue toujours en haut, ignorant tout espace libre - lorsque le compactage arrive, les espaces libres sont évincés. Cela rend l'allocation presque sans opération et facilite la localisation des données. D'autre part, l'allocation sur la LOH est similaire au fonctionnement de malloc - il trouvera le premier emplacement libre qui peut contenir ce que vous allouez et y allouera. Puisqu'il s'agit d'objets volumineux, la localité des données est une donnée et la pénalité pour l'allocation n'est pas trop mauvaise.
Luaan
1

Si le format est reconnaissable comme votre application, pourquoi n'avez-vous pas identifié le code qui génère ce format de chaîne? S'il y a plusieurs possibilités, essayez d'ajouter des données uniques pour déterminer quel chemin de code est le coupable.

Le fait que les tableaux soient entrelacés avec de grands éléments libérés me conduit à deviner qu'ils étaient à l'origine appariés ou du moins liés. Essayez d'identifier les objets libérés pour comprendre ce qui les a générés et les chaînes associées.

Une fois que vous avez identifié ce qui génère ces chaînes, essayez de comprendre ce qui les empêcherait d'être GC. Peut-être qu'ils sont stockés dans une liste oubliée ou inutilisée à des fins de journalisation ou quelque chose de similaire.


EDIT: ignorez la région mémoire et la taille du tableau spécifique pour le moment: déterminez simplement ce qui est fait avec ces chaînes pour provoquer une fuite. Essayez! GCRoot lorsque votre programme a créé ou manipulé ces chaînes une ou deux fois seulement, lorsqu'il y a moins d'objets à tracer.

HUAGHAGUAH
la source
Les chaînes sont un mélange de Guids (que nous utilisons) et de clés de chaîne qui sont facilement identifiables. Je peux voir où ils sont générés, mais ils ne sont jamais (directement) ajoutés aux tableaux d'objets et nous ne créons pas explicitement des tableaux de 128 éléments. Ces petits tableaux ne devraient pas être dans la LOH pour commencer.
Paul Ruane
1

Excellente question, j'ai appris en lisant les questions.

Je pense que d'autres parties du chemin du code de désérialisation utilisent également le grand tas d'objets, d'où la fragmentation. Si toutes les chaînes étaient internées au même moment, je pense que tout irait bien.

Étant donné la qualité du ramasse-miettes .net, le simple fait de laisser le chemin du code de désérialisation créer un objet de chaîne normal sera probablement suffisant. Ne faites rien de plus complexe tant que le besoin n'est pas prouvé.

Je voudrais tout au plus garder une table de hachage des dernières chaînes que vous avez vues et les réutiliser. En limitant la taille de la table de hachage et en transmettant la taille lorsque vous créez la table, vous pouvez arrêter la plupart de la fragmentation. Vous avez ensuite besoin d'un moyen de supprimer les chaînes que vous n'avez pas vues récemment de la table de hachage pour limiter sa taille. Mais si les chaînes créées par le chemin du code de désérialisation sont de toute façon de courte durée, vous ne gagnerez pas grand-chose, voire rien.

Ian Ringrose
la source
1

Voici quelques façons d'identifier la pile d' appels exacte de l' allocation LOH .

Et pour éviter la fragmentation LOH, pré-allouez un large éventail d'objets et épinglez-les. Réutilisez ces objets si nécessaire. Voici un article sur la fragmentation LOH. Quelque chose comme cela pourrait aider à éviter la fragmentation LOH.

Naveen
la source
Je ne vois pas pourquoi l'épinglage ici devrait aider? Les gros objets BTW sur LOH ne sont pas déplacés par le GC de toute façon. C'est un détail de mise en œuvre cependant.
user492238