Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Jak szybki jest .NET Garbage Collector? Część 1.

.NET GC jest bardzo szybki! Mam nadzieje, że ta pocieszająca informacja Ci nie wystarcza i potrzebujesz nieco więcej szczegółów :) Pokaże Ci kilka testów, które dowodzą, że nie kłamię. Na początek jednak przyda się krótkie przypomnienie tego czym jest GC:

Garbage Collector to podstawowy komponent .NET CLR. Odpowiada on za zwalnianie pamięci na stercie zarządzanej tak by programista nie musiał troszczyć się o dealokację. W przeciwieństwie do obiegowej opinii, GC zajmuje się zarówno pamięcią typów referencyjnych jak i wartościowych. Dlaczego? Często przestrzeń dla takich rzeczy jak np. struktury i typy numeryczne jest alokowana na stercie. Dzieje się tak na przykład gdy typ wartościowy jest elementem tablicy lub polem w instancji klasy.

GC w .NET używa algorytmu mark and sweep: przemierza graf obiektów począwszy od tzw. rootów (np. zmiennych statycznych, referencji na stosie lub rejestrach) i oznacza wszystkie elementy do może dotrzeć. Kiedy obieg jest zakończony, GC wie, że może bezpiecznie zwolnić pamięć zajętą przez nieoznaczone obiekty – ponieważ jako nieosiągalne są one bezużyteczne dla aplikacji.

Ze względów wydajnościowych GC wspiera pojęcie generacji. Świeżo utworzony obiekt przynależy do Gen 0 (za wyjątkiem tzw. dużych obiektów). Jeśli obiekt z Gen 0 przetrwa cykl zbiórki jest przenoszony do Gen 1. Jeśli przetrwa kolejny cykl trafia do Gen 2 i w niej zostaje (nie ma Gen 31). Większość obiektów żyje krótko – nie wychodzą poza Gen 0 lub Gen 1 więc .NET najpierw zwalnia pamięć z niższych generacji. Zbieranie nieużytków z Gen 2 (tzw. full collection) odbywa się znacznie rzadziej niż zbieranie w Gen 0. Jeśli obiekt jest duży tj. powyżej 850002 bajtów jest on alokowany na LOH (Large Object Heap) i trafia od razu do Gen 2. Traktowanie dużych obiektów tak samo jak małych miałoby negatywny wpływ na algorytmy defragmentacji3 sterty ponieważ przenoszenie takich obiektów jest czasochłonne.

GC wspiera różne tryby pracy dla stacji roboczych i serwerów, potrafi wykonać cześć pracy w działających w tle wątkach, musi brać pod uwagę specjalne przypadki takie jak istnienie finalizatorów i przypiętych buforów pamięciowych, posiada wyspecjalizowane konfiguracje dla różnych platform (CLR na PC różni się na przykład od tego na Xbox)… Ok, starczy, obiecałem przecież "krótkie przypomnienie"!

Teraz czas na test!

W tym pierwszym poście pokażę Ci jak szybko .NET GC radzi sobie z dużymi tablicami typów wartościowych. Test będzie dotyczył tablicy bajtów zajmującej około dwóch giga. Pomimo swoich wielkich rozmiarów taki obiekt nie obciąża znacznie Garbage Collectora. Jest tak ponieważ jedyną referencją którą musi sprawdzić GC jest ta do tablicy. Jeśli tablica staje się niedostępna cała pamięć zajęta przez jej elementy może bez problemu zostać użyta ponownie. Dodatkowo, tablica taka, jako przynależna do LOH, nie jest kopiowana (w celu uniknięcia fragmentacji sterty) gdy przeżywa ona cykl zbiórki GC. W drugim odcinku tego artykułu pokaże Ci jak GC działa dla bardziej złożonych scenariuszy. Przetestujemy wydajność dla tablicy obiektów, oraz co ważniejsze, dla drzewa obiektów z tysiącami referencji…

Info o środowisku testowym: stacjonarny PC z procesorem Intel i-5 2400 3.1 GHz 4 Core CPU, 12 GB RAM DDR 3/1333 i Windows 7 Ultimate x64. Program testowy to konsolowa aplikacja .NET 4.0 skompilowana w trybie Relase i uruchomiona bez podłączania debuggera

Oto kod testowy:

static void TestSpeedForLargeByteArray()
{
    Stopwatch sw = new Stopwatch();

    Console.Write(string.Format("GC.GetTotalMemory before creating array: {0:N0}. Press any key to create array...", GC.GetTotalMemory(true)));
    Console.ReadKey();
    byte[] array = new byte[2000 * 1000 * 1000]; // About 2 GB
   
    sw.Start();
    for (int i = 0; i < array.Length; i++)
    {
        array[i] = 1; // Touch array elements to fill working set              
    }          
    Console.WriteLine("Setup time: " + sw.Elapsed);

    Console.WriteLine(string.Format("GC.GetTotalMemory after creating array: {0:N0}. Press Enter to set array to null...", GC.GetTotalMemory(true)));
    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting array to null");
        array = null;
    }
               
    sw.Restart();
    GC.Collect();                 
    Console.WriteLine("Collection time: " + sw.Elapsed);
    Console.WriteLine(string.Format("GC.GetTotalMemory after GC.Collect: {0:N0}. Press any key to finish...", GC.GetTotalMemory(true)));

    Console.WriteLine(array); // To avoid compiler optimization...
    Console.ReadKey();
}

Jak widzisz test jest bardzo prosty. Kod używa dwóch metod ze statycznej klasy GC: GC.GetTotalMemory oraz GC.Collect. Pierwsza zwraca ilość zaalokowanej pamięci zarządzanej a druga zmusza Garbage Collector do zwolnienia pamięci. Jedyna rzecz, która może Cię zaskoczyć to pętla odwołująca się do elementów tablicy. Bez niej zaobserwowałbyś “dziwne” zjawisko: po zdefiniowaniu tablicy GC.GetTotalMemory zaraportowałaby około 2 GB ale nie zauważyłbyś zwiększenia zużycia pamięci w Menadżerze zadań Windows! Jest tak ponieważ taksmgr.exe pokazuje dane zestawu roboczego (working set). Możesz użyć bardziej zaawansowanego narzędzia: Monitora zasobów (taskmgr.exe) by zobaczyć co się dzieje:

To screenshot sprzed “dotknięcia” elementów tablicy:

Zużycie pamięci przed dostępem do elementów tablicy. Kliknij aby powiększyć...

a to zrzut zrobiony po wykonaniu pętli:

Zużycie pamięci po dostępie do elementów tablicy. Kliknij aby powiększyć...

Pora na rezultaty testu:

GC.GetTotalMemory before creating array: 41,568. Press any key to create array...
Setup time: 00:00:01.0633903
GC.GetTotalMemory after creating array: 2,000,053,864. Press Enter to set array to null...
Setting array to null
Collection time: 00:00:00.1443391
GC.GetTotalMemory after GC.Collect: 53,800. Press any key to finish...

Możesz zauważyć, że wyczyszczenie około 2 GB pamięci zajęło GC około 150 milisekund. Nieźle, co? Prędkość tą można szczególnie docenić gdy zwróci się uwagę na to, że samo przejście w pętli przez elementy tablicy zabrało ponad sekundę!

Oto ekran z Monitora wydajności (perfmon.exe) z kilkoma licznikami dla pamięci .NET:

Monitor wydajności z licznikami pamięci zarządzanej. Kliknij aby powiększyć...

Nasza tablica jest dużym obiektem (znacznie większym niż próg LOH) – widać więc, że po jej zdefiniowaniu pamięć LOH zwiększa się a pamięć Gen 0 pozostaje bez zmian.

Poniżej znajdują się rezultaty wywołania GC.Collect dla przypadku gdy referencja do tablicy nie zostaje ustawiona na null:

GC.GetTotalMemory before creating array: 41,568. Press any key to create array..
Setup time: 00:00:01.0385779
GC.GetTotalMemory after creating array: 2,000,053,864. Press Enter to set array to null...
Collection time: 00:00:00.0001287
GC.GetTotalMemory after GC.Collect: 2,000,053,824. Press any key to finish...

Ułamek milisekundy. Zupełnie pomijalna wartość! Dlaczego w ogóle wspominam o sytuacji w kórej pamięć nie podlega zebraniu? W następnym poście zobaczysz, że GC ma zazwyczaj więcej pracy gdy elementy na strecie przeżywają cykl zbiórki nieużytków.

Planuje dodać drugą cześć artykułu za tydzień albo dwa. Niczego jednak nie obiecuję – wiesz, życie… ;)

Update 26.07.2014: Zmieniłem ilustrację z monitora wydajności (zwiększona skala Gen 0 i Gen 1)..

Update 24.06.2014: Kilka dni temu napisałem drugą cześć artykułu. Kliknij tutaj.

1. Możesz użyć metody GC.GC.MaxGeneration by to sprawdzić.

2. Próg LOH to detal implementacyjny. Większość źródeł wymienia 85KB jako limit ale nie jest to zawsze prawda - tablica double długości zaledwie 1000 elementów trafia na LOH (na x86)...

3..NET 4.5 wprowadza zmiany w LOH usprawniające zapobieganie fragmentacji poprzez balansowanie i usprawnione użycie listy wolnych bloków. Przyszłe edycje wprowadzą prawdopodobnie również opcje kompaktowania.

Dodaj komentarz

Loading