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…

Dodaj komentarz

Loading