Frammentazione dell'heap di oggetti di grandi dimensioni


97

L'applicazione C # /. NET su cui sto lavorando soffre di una lenta perdita di memoria. Ho usato CDB con SOS per cercare di determinare cosa sta succedendo ma i dati non sembrano avere alcun senso, quindi speravo che uno di voi potesse averlo sperimentato prima.

L'applicazione è in esecuzione sul framework a 64 bit. Calcola e serializza continuamente i dati su un host remoto e sta raggiungendo il Large Object Heap (LOH) un bel po '. Tuttavia, la maggior parte degli oggetti LOH che mi aspetto siano transitori: una volta che il calcolo è completo ed è stato inviato all'host remoto, la memoria dovrebbe essere liberata. Quello che sto vedendo, tuttavia, è un gran numero di array di oggetti (live) interleaved con blocchi di memoria liberi, ad esempio, prendendo un segmento casuale dal 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
...

Ovviamente mi aspetterei che questo fosse il caso se la mia applicazione creasse oggetti grandi e di lunga durata durante ogni calcolo. (Lo fa e accetto che ci sarà un certo grado di frammentazione LOH ma non è questo il problema qui.) Il problema sono gli array di oggetti molto piccoli (1056 byte) che puoi vedere nel dump sopra che non posso vedere nel codice in fase di creazione e che rimangono in qualche modo radicati.

Si noti inoltre che CDB non riporta il tipo quando il segmento di heap viene scaricato: non sono sicuro che sia correlato o meno. Se eseguo il dump dell'oggetto contrassegnato (<-), CDB / SOS lo segnala bene:

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

Gli elementi dell'array di oggetti sono tutte stringhe e le stringhe sono riconoscibili dal codice della nostra applicazione.

Inoltre, non riesco a trovare le loro radici GC poiché il comando! GCRoot si blocca e non torna più (ho persino provato a lasciarlo durante la notte).

Quindi, apprezzerei molto se qualcuno potesse far luce sul motivo per cui questi piccoli array di oggetti (<85k) stanno finendo sul LOH: in quali situazioni .NET inserirà un piccolo array di oggetti lì? Inoltre, qualcuno conosce un modo alternativo per accertare le radici di questi oggetti?


Aggiorna 1

Un'altra teoria che mi è venuta in mente ieri è che questi array di oggetti sono iniziati in grande ma sono stati ridotti lasciando i blocchi di memoria libera che sono evidenti nei dump della memoria. Ciò che mi rende sospettoso è che gli array di oggetti sembrano sempre essere lunghi 1056 byte (128 elementi), 128 * 8 per i riferimenti e 32 byte di overhead.

L'idea è che forse un codice non sicuro in una libreria o in CLR sta danneggiando il campo del numero di elementi nell'intestazione dell'array. Un po 'lungo lo so ...


Aggiorna 2

Grazie a Brian Rasmussen (vedi risposta accettata) il problema è stato identificato come la frammentazione del LOH causata dalla tabella interna delle stringhe! Ho scritto una rapida applicazione di prova per confermare questo:

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'applicazione prima crea e dereferenzia stringhe univoche in un ciclo. Questo è solo per dimostrare che la memoria non perde in questo scenario. Ovviamente non dovrebbe e non lo fa.

Nel secondo ciclo vengono create e internate stringhe univoche. Questa azione li radica nella tabella intern. Quello di cui non mi ero reso conto è come è rappresentato il tavolo stagista. Sembra che consista di un insieme di pagine - array di oggetti di 128 elementi stringa - che vengono creati nel LOH. Questo è più evidente in 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)

Fare un dump del segmento LOH rivela lo schema che ho visto nell'applicazione che perde:

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

Si noti che la dimensione dell'array di oggetti è 528 (anziché 1056) perché la mia workstation è a 32 bit e il server delle applicazioni è a 64 bit. Gli array di oggetti sono ancora lunghi 128 elementi.

Quindi la morale di questa storia è stare molto attenti allo stage. Se la stringa che stai internando non è nota per essere un membro di un insieme finito, la tua applicazione perde a causa della frammentazione del LOH, almeno nella versione 2 del CLR.

Nel caso della nostra applicazione, c'è un codice generale nel percorso del codice di deserializzazione che inserisce identificatori di entità durante l'unmarshalling: ora sospetto fortemente che questo sia il colpevole. Tuttavia, le intenzioni dello sviluppatore erano ovviamente buone in quanto volevano assicurarsi che se la stessa entità viene deserializzata più volte, verrà mantenuta in memoria solo un'istanza della stringa di identificazione.


2
Ottima domanda: ho notato la stessa cosa nella mia domanda. Piccoli oggetti lasciati nel LOH dopo che i blocchi grandi sono stati puliti e ciò causa problemi di frammentazione.
Reed Copsey

2
Sono d'accordo, ottima domanda. Cercherò risposte.
Charlie Flowers,

2
Molto interessante. Sembra che fosse un vero problema eseguire il debug!
Matt Jordan

Risposte:


46

Il CLR utilizza LOH per preallocare alcuni oggetti (come l'array utilizzato per le stringhe interne ). Alcuni di questi sono inferiori a 85000 byte e quindi normalmente non sarebbero allocati sul LOH.

È un dettaglio di implementazione, ma presumo che il motivo sia per evitare inutili operazioni di garbage collection di istanze che dovrebbero sopravvivere fintanto che il processo stesso.

Anche a causa di un'ottimizzazione un po 'esoterica, uno qualsiasi double[]di 1000 o più elementi viene allocato anche sul LOH.


Gli oggetti problematici sono oggetti [] contenenti riferimenti a stringhe che so essere create dal codice dell'app. Ciò implica che l'app sta creando gli oggetti [] (non ne vedo la prova) o che una parte del CLR (come la serializzazione) li sta utilizzando per lavorare sugli oggetti dell'applicazione.
Paul Ruane

1
Potrebbe essere la struttura interna utilizzata per le stringhe interne. Si prega di controllare la mia risposta a questa domanda per maggiori dettagli: stackoverflow.com/questions/372547/…
Brian Rasmussen,

Ah, questo è un indizio molto interessante, grazie. Ho completamente dimenticato il tavolo degli stagisti. So che uno dei nostri sviluppatori è un appassionato interner, quindi questo è sicuramente qualcosa su cui indagherò.
Paul Ruane

1
85000 byte o 84 * 1024 = 87040 byte?
Peter Mortensen

5
85000 byte. Puoi verificarlo creando un array di byte di 85000-12 (dimensione della lunghezza, MT, blocco di sincronizzazione) e chiamando GC.GetGenerationl'istanza. Ciò restituirà Gen2: l'API non distingue tra Gen2 e LOH. Rendi l'array più piccolo di un byte e l'API restituirà Gen0.
Brian Rasmussen

13

.NET Framework 4.5.1 ha la capacità di compattare in modo esplicito l'heap di oggetti grandi (LOH) durante la garbage collection.

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

Ulteriori informazioni in GCSettings.LargeObjectHeapCompactionMode


2

Quando si leggono le descrizioni di come funziona GC, e la parte su come gli oggetti di lunga durata finiscono nella generazione 2, e la raccolta di oggetti LOH avviene solo alla raccolta completa - così come la raccolta della generazione 2, l'idea che viene in mente è. .. perché non tenere la generazione 2 e gli oggetti di grandi dimensioni nello stesso mucchio, poiché verranno raccolti insieme?

Se questo è ciò che accade effettivamente, spiegherebbe come i piccoli oggetti finiscono nello stesso posto del LOH, se sono vissuti abbastanza a lungo da finire nella generazione 2.

E quindi il tuo problema sembrerebbe essere una buona confutazione all'idea che mi viene in mente: risulterebbe nella frammentazione del LOH.

Riepilogo: il tuo problema potrebbe essere spiegato dal LOH e dalla generazione 2 che condividono la stessa regione di heap, sebbene ciò non sia affatto una prova che questa sia la spiegazione.

Aggiornamento: l'uscita di !dumpheap -statpraticamente fa saltare questa teoria fuori dall'acqua! La generazione 2 e LOH hanno le proprie regioni.


Usa! Eeheap per mostrare i segmenti che compongono ogni heap. Gen 0 e gen 1 vivono in un segmento (lo stesso segmento), gen 2 e LOH possono entrambi allocare più segmenti ma quei segmenti per ogni heap rimangono separati.
Paul Ruane

Sì, l'ho visto, grazie. Volevo solo menzionare il comando! Eeheaps poiché mostra questo comportamento in modo molto più chiaro.
Paul Ruane

L'efficienza del GC principale deriva in gran parte dal fatto che può riposizionare gli oggetti, quindi ci sarà solo un piccolo numero di regioni libere di memoria sull'heap principale. Se un oggetto sull'heap principale viene bloccato durante una raccolta, lo spazio sopra e sotto l'oggetto appuntato potrebbe dover essere tracciato separatamente, ma poiché il numero di oggetti appuntati è normalmente molto piccolo, lo sarà anche il numero di aree separate che il GC deve traccia. La combinazione di oggetti rilocabili e non rilocabili (grandi) nello stesso heap comprometterebbe le prestazioni.
supercat

Una domanda più interessante è perché .NET inserisce doublearray più grandi di 1000 elementi nel LOH, invece di modificare il GC in modo da garantire che siano allineati su limiti di 8 byte. In realtà, anche su un sistema a 32 bit mi aspetterei che, a causa del comportamento della cache, imporre l'allineamento a 8 byte su tutti gli oggetti la cui dimensione allocata è un multiplo di 8 byte sarebbe probabilmente una vittoria in termini di prestazioni. Altrimenti, mentre le prestazioni di un uso pesante double[]che è allineato alla cache sarebbero migliori di quelle che non lo sono, non so perché le dimensioni sarebbero correlate con l'utilizzo.
supercat

@supercat Inoltre, i due heap si comportano in modo molto diverso anche nell'allocazione. L'heap principale è (in questo momento) fondamentalmente uno stack in schemi di allocazione - alloca sempre in alto, ignorando lo spazio libero - quando arriva la compattazione, gli spazi liberi vengono spremuti. Questo rende l'allocazione quasi un no-op e aiuta la localizzazione dei dati. D'altra parte, l'allocazione sul LOH è simile a come funziona malloc: troverà il primo posto libero che può contenere ciò che stai allocando e allocare lì. Poiché è per oggetti di grandi dimensioni, la località dei dati è un dato di fatto e la penalità per l'allocazione non è poi così grave.
Luaan

1

Se il formato è riconoscibile come la tua applicazione, perché non hai identificato il codice che genera questo formato stringa? Se ci sono diverse possibilità, prova ad aggiungere dati univoci per capire quale percorso del codice è il colpevole.

Il fatto che gli array siano intercalati con grandi elementi liberati mi porta a supporre che fossero originariamente accoppiati o almeno correlati. Prova a identificare gli oggetti liberati per capire cosa li stava generando e le stringhe associate.

Dopo aver identificato cosa sta generando queste stringhe, prova a capire cosa impedirebbe loro di essere GCed. Forse vengono inseriti in un elenco dimenticato o inutilizzato per scopi di registrazione o qualcosa di simile.


EDIT: ignora la regione di memoria e la dimensione specifica dell'array per il momento: cerca solo di capire cosa viene fatto con queste stringhe per causare una perdita. Prova! GCRoot quando il tuo programma ha creato o manipolato queste stringhe solo una o due volte, quando ci sono meno oggetti da tracciare.


Le stringhe sono un mix di guide (che usiamo) e chiavi di stringa che sono facilmente identificabili. Posso vedere dove vengono generati, ma non vengono mai (direttamente) aggiunti agli array di oggetti e non creiamo esplicitamente 128 array di elementi. Tuttavia, per cominciare, questi piccoli array non dovrebbero essere nella LOH.
Paul Ruane

1

Ottima domanda, ho imparato leggendo le domande.

Penso che anche altre parti del percorso del codice di deserializzazione utilizzino l'heap di oggetti di grandi dimensioni, da cui la frammentazione. Se tutte le stringhe fossero state internate nello STESSO momento, penso che staresti bene.

Dato quanto è buono il garbage collector .net, è probabile che sia abbastanza buono lasciare che il percorso del codice di deserializzazione crei un normale oggetto stringa. Non fare nulla di più complesso fino a quando non viene dimostrata la necessità.

Al massimo cercherei di tenere una tabella hash delle ultime stringhe che hai visto e riutilizzarle. Limitando la dimensione della tabella hash e inserendola quando si crea la tabella è possibile interrompere la maggior parte della frammentazione. È quindi necessario un modo per rimuovere le stringhe che non hai visto di recente dalla tabella hash per limitarne le dimensioni. Ma se le stringhe create dal percorso del codice di deserializzazione sono di breve durata comunque, non guadagnerai molto se non nulla.


1

Ecco un paio di modi per identificare l'esatto stack di chiamate di LOH dell'allocazione .

E per evitare la frammentazione LOH Pre-alloca una vasta gamma di oggetti e bloccali. Riutilizza questi oggetti quando necessario. Ecco un post su LOH Fragmentation. Qualcosa di simile potrebbe aiutare a evitare la frammentazione LOH.


Non riesco a capire perché il blocco qui dovrebbe aiutare? Gli oggetti di grandi dimensioni BTW su LOH non vengono comunque spostati dal GC. È però un dettaglio di implementazione.
user492238

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.