Miłosz Orzeł

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

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

Przeczytaj poprzednią cześć tego artykułu jeśli jeszcze tego nie zrobiłeś. Część 1 zawiera krótkie podsumowanie tego czym jest GC i w jaki sposób działa. Mieści się tam również test wydajności w obsłudze dużej tablicy bajtów i dokładny opis mojego środowiska testowego…

Ten post skupia się na scenariuszach, które zmuszają GC do większego wysiłku i występują częściej w normalnych (nietestowych) aplikacjach. Zobaczysz, że nawet drzewo składające się z ponad 100 milionów obiektów jest obsługiwane szybko. Na początek sprawdźmy jednak jak GC radzi sobie z dużą tablicą elementów typu object:

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

    Console.Write(string.Format("GC.GetTotalMemory before creating array: {0:N0}. Press key any to create array...", GC.GetTotalMemory(true)));
    Console.Read();
    object[] array = new object[100 * 1000 * 1000]; // About 800 MB (3.2 GB when filled with object instances)
             
    sw.Start();
    for (int i = 0; i < array.Length; i++)
    {
        array[i] = new object();
    }
    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();
}

Powyższy test tworzy tablicę 100 milionów elementów. Początkowo tablica zajmuje 800 megabajtów pamięci (na platformie x64). Ta część jest alokowana na LOH. Gdy instancje obiektów zostaną utworzone, zużycie pamięci wzrasta do 3,2 GB. Elementy tablicy są niewielkie więc są częścią Small Object Heap i początkowo należą do Gen 0.

Oto rezultaty testu dla przypadku gdy referencja do tablicy jest ustawiona na null:

GC.GetTotalMemory before creating array: 41,736. Press key any to create array...
Press any key to fill array...
Setup time: 00:00:07.7910574
GC.GetTotalMemory after creating array: 3,200,057,616. Press Enter to set array to null...
Setting array to null
Collection time: 00:00:00.7481998
GC.GetTotalMemory after GC.Collect: 57,624. Press any key to finish...

Odzyskanie ponad 3 GB pamięci zajęło jednie 740 ms!

Spójrz na wykres z Monitora wydajności:

Liczniki pamięci zarządzanej dla dużej tablicy object[]; ustawienie tablicy na null. Kliknij aby powiększyć...

Możesz zauważyć, że gdy tablica była wypełniana, Gen 0 i Gen 1 zmieniały rozmiar (zwróć jednak uwagę, że ich skala jest 100x większa niż skala pozostałych liczników). Oznacza to, że cykle zbiórki GC były wywoływane podczas tworzenia elementów tablicy - jest to oczekiwane zachowanie. Zauważ jak rozmiar Gen 2 i LOH pokrywa się z całkowitą ilością pamięci na stercie zarządzanej.

A co gdyby zamiast ustawienia tablicy na null ustawić jej elementy na null?

Wykres:

Liczniki pamięci zarządzanej dla dużej tablicy object[]; ustawienie elementów tablicy na null. Kliknij aby powiększyć...

Zauważ, że po GC.Collect 800 MB nadal pozostaje w użyciu - to pamięć LOH zajęta przez samą tablicę.

Rezultaty testu:

GC.GetTotalMemory before creating array: 41,752. Press key any to create array...
Press any key to fill array...
Setup time: 00:00:07.7707024
GC.GetTotalMemory after creating array: 3,200,057,632. Press Enter to set array elements to null...
Setting array elements to null
Collection time: 00:00:01.0926220
GC.GetTotalMemory after GC.Collect: 800,057,672. Press any key to finish...

Ok, koniec z tablicami. Można się domyślać, że jako ciągłe bloki pamięci są one łatwiejsze w obsłudze niż złożone struktury obiektów występujące powszechnie w rzeczywistych aplikacjach.

Stwórzmy więc bardzo duże drzewo małych elementów referencyjnych:

static int _itemsCount = 0;

class Item
{
    public Item ChildA { get; set; }
    public Item ChildB { get; set; }
    
    public Item()
    {
        _itemsCount++;
    }           
}

static void AddChildren(Item parent, int depth) 
{
    if (depth == 0)
    {
        return;
    }
    else
    {
        parent.ChildA = new Item();
        parent.ChildB = new Item();

        AddChildren(parent.ChildA, depth - 1);
        AddChildren(parent.ChildB, depth - 1);                
    }
}

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

    Console.Write(string.Format("GC.GetTotalMemory before building object tree: {0:N0}. Press any key to build tree...", GC.GetTotalMemory(true)));
    Console.ReadKey();

    sw.Start();
    _itemsCount = 0;       
    Item root = new Item();            
    AddChildren(root, 26);
    Console.WriteLine("Setup time: " + sw.Elapsed);
    Console.WriteLine("Number of items: " + _itemsCount.ToString("N0"));

    Console.WriteLine(string.Format("GC.GetTotalMemory after building object tree: {0:N0}. Press Enter to set root to null...", GC.GetTotalMemory(true)));

    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting tree root to null");
        root = 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(root); // To avoid compiler optimization...            
    Console.ReadKey();
}

Powyższy test tworzy trzewo mające ponad 130 milionów węzłów, które zajmują niemal 4,3 GB pamięci.

Oto co stanie się gdy root ustawimy na null:

GC.GetTotalMemory before building object tree: 41,616. Press any key to build tree...
Setup time: 00:00:14.3355583
Number of items: 134,217,727
GC.GetTotalMemory after building object tree: 4,295,021,160. Press Enter to set root to null...
Setting tree root to null
Collection time: 00:00:01.1069927
GC.GetTotalMemory after GC.Collect: 53,856. Press any key to finish...

Liczniki pamięci zarządzanej dla dużego drzewa małych obiektów; ustawienie root na null. Kliknij aby powiększyć...

Zebranie wszystkich śmieci zajęło jedynie 1,1 sekundy! Gdy referencja do root została ustawiona na null wszystkie elementy drzewa znajdujące się pod nią stały się zbędne zgodnie z regułami algorytmu mark and sweep… Zauważ, że tym razem LOH nie jest używana ponieważ żaden z obiektów nie przekracza progu 85 KB.

Teraz sprawdźmy co się stanie gdy root nie zostanie ustawiony na null i wszystkie obiekty przetrwają cykl GC:

GC.GetTotalMemory before building object tree: 41,680. Press any key to build tree...
Setup time: 00:00:14.3915412
Number of items: 134,217,727
GC.GetTotalMemory after building object tree: 4,295,021,224. Press Enter to set root to null...
Collection time: 00:00:03.7172580
GC.GetTotalMemory after GC.Collect: 4,295,021,184. Press any key to finish...

Tym razem wykonanie GC.Collect zajęło około 3,7 sek (mniej niż 28 nanosekund na referencję) – pamiętaj, że obsługa żyjących obiektów jest kosztowniejsza od obsługi tych, których pamięć można zwolnić!

Jest jeszcze jeden scenariusz wart sprawdzenia. Zamiast root = null ustawmy root.ChildA = null. W ten sposób tylko połowa drzewa stanie się nieosiągalna. GC będzie mógł zwolnić pamięć i scalić ją by uniknąć fragmentacji. Oto wyniki:

GC.GetTotalMemory before building object tree: 41,696. Press any key to build tree...
Setup time: 00:00:15.1326459
Number of items: 134,217,727
GC.GetTotalMemory after creating array: 4,295,021,240. Press Enter to set root.ChildA to null...
Setting tree root.ChildA to null
Collection time: 00:00:02.5062596
GC.GetTotalMemory after GC.Collect: 2,147,537,584. Press any key to finish...

Czas na ostatni test. Zbudujmy drzewo ponad 2 milionów węzłów o złożonej strukturze. Elementy będą zawierać referencje do obiektów, małą tablicę i unikalny string. Dodatkowo niektóre instancje MixedItem będą posiadać tablicę bajtów na tyle dużą, że zostanie ona umieszczona na Large Object Heap.

static int _itemsCount = 0;

class MixedItem
{
    byte[] _smallArray;
    byte[] _bigArray;
    string _uniqueString;

    public MixedItem ChildA { get; set; }
    public MixedItem ChildB { get; set; }

    public MixedItem()
    {
        _itemsCount++;

        _smallArray = new byte[1000];
        if (_itemsCount % 1000 == 0)
        {
            _bigArray = new byte[1000 * 1000];
        }

        _uniqueString = DateTime.Now.Ticks.ToString();
    }
}

static void AddChildren(MixedItem parent, int depth)
{
    if (depth == 0)
    {
        return;
    }
    else
    {
        parent.ChildA = new MixedItem();
        parent.ChildB = new MixedItem();

        AddChildren(parent.ChildA, depth - 1);
        AddChildren(parent.ChildB, depth - 1);
    }
}

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

    Console.Write(string.Format("GC.GetTotalMemory before building object tree: {0:N0}. Press any key to build tree...", GC.GetTotalMemory(true)));
    Console.ReadKey();

    sw.Start();
    _itemsCount = 0;
    MixedItem root = new MixedItem();
    AddChildren(root, 20);
    Console.WriteLine("Setup time: " + sw.Elapsed);
    Console.WriteLine("Number of items: " + _itemsCount.ToString("N0"));

    Console.WriteLine(string.Format("GC.GetTotalMemory after building object tree: {0:N0}. Press Enter to set root to null...", GC.GetTotalMemory(true)));

    if (Console.ReadKey().Key == ConsoleKey.Enter)
    {
        Console.WriteLine("Setting tree root to null");
        root = 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(root); // To avoid compiler optimization...
    Console.ReadKey();
}

Jak GC poradzi sobie z niemal 4,5 GB pamięci o tak nieregularnej budowie? Wyniki testu dla ustawienia root na null:

GC.GetTotalMemory before building object tree: 41,680. Press any key to build tree...
Setup time: 00:00:11.5479202
Number of items: 2,097,151
GC.GetTotalMemory after building object tree: 4,496,245,632. Press Enter to set root to null...
Setting tree root to null
Collection time: 00:00:00.5055634
GC.GetTotalMemory after GC.Collect: 54,520. Press any key to finish...

Liczniki pamięci zarządzanej dla dużego drzewa mieszanych obiektów; ustawienie root na null. Kliknij aby powiększyć...

I na wypadek gdybyś się zastanawiał, oto wyniki dla przypadku gdy root nie zostaje ustawiony na null:

GC.GetTotalMemory before building object tree: 41,680. Press any key to build tree...
Setup time: 00:00:11.6676969
Number of items: 2,097,151
GC.GetTotalMemory after building object tree: 4,496,245,632. Press Enter to set root to null...
Collection time: 00:00:00.5617486
GC.GetTotalMemory after GC.Collect: 4,496,245,592. Press any key to finish...

Co z tego wszystkiego wynika? Wniosek jest taki, że o ile nie piszesz aplikacji wymagających ekstremalnej wydajności bądź absolutnej gwarancji nieprzerwanego wykonania, powinieneś być zadowolony, że .NET używa automatycznego zarządzania pamięcią. GC to kawał świetnego softu, który uwalnia Cię od konieczności manualnego zwalniania pamięci (czynność nudna i błędogenna). Dzięki GC możesz skupić się na tym co naprawdę istotne: dostarczaniu funkcjonalności dla użytkowników aplikacji. Zawodowo programuje w .NET od 8 lat (rzeczy „enterprise”, głównie aplikacje webowe i usługi systemowe) i jeszcze nie spotkałem1 się z sytuacja gdzie prędkość GC byłaby istotnym czynnikiem. Zazwyczaj problemy z wydajnością tkwią w takich sprawach jak: zła konfiguracja bazy danych, nieefektywne zapytania SQL/ORM, wolne usługi zdalne, złe wykorzystanie sieci, brak współbieżności, złe cache’owanie, powolne renderowanie w przeglądarce itp. Jeśli unikasz podstawowych błędów takich jak tworzenie zbyt wielu stringów pewnie nawet nie zauważysz, że Garbage Collector istnieje :)

Aktualizacja: 31.08.2014: Właśnie uruchomiłem najcięższy test (duże drzewo małych elementów referencyjnych, które nie zostają zwolnione przez GC) na nowym laptopie. Wynik to 3,3s w porównaniu do wyniku 3,7 zaprezentowanego w poście. Program testowy: aplikacja konsolowa .NET 4.5 w trybie Release uruchomiona bez debuggera. Sprzęt: i7-4700HQ 2.4-3.4GHz 4 Core CPU, 8GB DDR3/1600MHz RAM. System: Windows 7 Home Premium x64.

1. Zdarzały się z wyjątki braku pamięci powiązanyne z fragmentacją LOH. Na szczęście jednak algorytmy obsługi LOH są usprawniane a platforma x86, która jest szczególnie podatna na tego typu błędy, przechodzi do historii…

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.