Miłosz Orzeł

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

Nowe posty

Niestety nie mam czasu przygotować polskich wersji nowych postów :(

Teksty po angielsku (Google Translate całkiem nieźle przetłumaczy):

  • Vanilla div performance  >   en.morzel.net
  • Material UI 5 Box performance  >   en.morzel.net
  • Accessing state in Redux Thunks  >   en.morzel.net
  • Controlling CSS module class names in Vite  >   en.morzel.net
  • Notes from migrating React app from CRA to Vite  >   en.morzel.net
  • vim.morzel.net - Questions with Explanations  >   en.morzel.net
  • Filtering DOM dump in React Testing Library  >   en.morzel.net
  • Suppressing legacy lifecycle method warnings in tests for chosen React components  >   en.morzel.net
  • WebGPU/WGSL Hello Triangle!   >   en.morzel.net
  • Safer ag-Grid (React) Column Definitions with TypeScript   >   CP, en.morzel.net
  • ag-Grid (React) Cell Renderers Performance   >   CP, en.morzel.net
  • Resizing All ag-Grid (React) Columns   >   CP, en.morzel.net
  • ag-Grid API Access with Hooks   >   CP, en.morzel.net
  • CSS Grid - Site for Rapid Experimentation   >   CP, en.morzel.net
  • Legacy Apps - Dealing with IFRAME Mess (Window.postMessage)   >   CP, en.morzel.net
  • Detecting a Drone - OpenCV in .NET for Beginners (Emgu CV 3.2, VS 2017). Part 3   >   CP, en.morzel.net
  • Detecting a Drone - OpenCV in .NET for Beginners (Emgu CV 3.2, VS 2017). Part 2   >   CP, en.morzel.net
  • Detecting a Drone - OpenCV in .NET for Beginners (Emgu CV 3.2, VS 2017). Part 1   >   CP, en.morzel.net
  • Save yourself some troubles with TortoiseGit pre-commit hook   >   CP, en.morzel.net
  • Easy way to fix outdated links (URL rewrite rule in Web.config)   >   CP, en.morzel.net
  • Differences in Java and C#: protected   >   CP, en.morzel.net
  • Setting version of assemblies in ASP.NET MVC application with TeamCity build...  >   CPen.morzel.net
  • [OoB] Controlling gun turret with Spring Boot REST service (Java/Arduino)   >  CP, en.morzel.net
  • [OoB] Moving webcam with joystick and servos (Arduino/SharpDX/WinForms)   >   CPen.morzel.net
  • [OoB] Shooting paintball marker with relay, Arduino and .NET WinForms   >   CPen.morzel.net
  • [OoB] Sonar with Arduino, C#, JavaScript and HTML5 (Part 2)   >   CPen.morzel.net
  • [OoB] Sonar with Arduino, C#, JavaScript and HTML5   >   CPen.morzel.net

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.

jQuery UI Autocomplete w MVC 5 - wybór encji zagnieżdżonej

Wyobraź sobie, że chcesz stworzyć widok do edycji encji Company, która posiada dwie właściwości: Name (typu string) Boss (typu Person). Chcesz by obie właściwości były edytowalne. Dla Company.Name proste pole tekstowe zupełnie wystarczy ale dla Company.Boss potrzebny będzie widget jQuery UI Autocomplete*. Kontrolka ta musi realizować poniższe wymagania:

  • sugestie powinny pojawić się gdy użytkownik zacznie wpisywać nazwisko osoby lub naciśnie klawisz strzałki w dół;
  • identyfikator osoby wybranej jako szef powinien zostać przesłany do serwera;
  • elementy listy powinny prezentować dodatkowe informacje (imię i datę urodzenia);
  • użytkownik musi wybrać element z listy (dowolny tekst nie jest akceptowalny);
  • właściwość określająca szefa musi być sprawdzana (komunikat walidacji i styl muszą być ustawione dla odpowiedniego pola).

Powyższe wymagania pojawiają się często w aplikacjach webowych. Widziałem wiele przekombinowanych sposobów na ich implementacje. Chcę pokazać Ci jak można je zrealizować w szybki i czysty sposób. Zakładam, że posiadasz podstawową wiedzę na temat jQuery UI Autocomplete oraz ASP.NET MVC. W tym poście pokaże jedynie ten kod, który jest bezpośrednio związany z autocomplete ale możesz pobrać pełen projekt demo tutaj. To aplikacja ASP.NET MVC 5/Entity Framework 6/jQuery UI 1.10.4 stworzona w Visual Studio 2013 Express for Web i przetestowana w Chrome 34, FF 28 oraz IE 11 (w trybach 11 i 8). 

Oto nasze klasy domenowe:

public class Company
{
    public int Id { get; set; } 

    [Required]
    public string Name { get; set; }

    [Required]
    public Person Boss { get; set; }
}
public class Person
{
    public int Id { get; set; }

    [Required]
    [DisplayName("First Name")]
    public string FirstName { get; set; }
    
    [Required]
    [DisplayName("Last Name")]
    public string LastName { get; set; }

    [Required]
    [DisplayName("Date of Birth")]
    public DateTime DateOfBirth { get; set; }

    public override string ToString()
    {
        return string.Format("{0}, {1} ({2})", LastName, FirstName, DateOfBirth.ToShortDateString());
    }
}

Nic nadzwyczajnego, kilka właściwości ze standardowymi atrybutami dla wymagalności pól i dobrej prezentacji. Klasa Person posiada override metody ToString - tekst z tej metody będzie wykorzystany do budowy listy sugestii kontrolki autocomplete.

Edycja encji Company bazuje na tym modelu widoku:

public class CompanyEditViewModel
{    
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }

    [Required]
    public int BossId { get; set; }

    [Required(ErrorMessage="Please select the boss")]
    [DisplayName("Boss")]
    public string BossLastName { get; set; }
}

Zauważ, że istnieją dwie właściwości stworzone dla potrzeb danych Boss.

Poniżej znajduje się fragment widoku edycji odpowiedzialny za wyświetlanie pola tekstowego z kontrolką  jQuery UI Autocomple dla property Boss:

<div class="form-group">
    @Html.LabelFor(model => model.BossLastName, new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.TextBoxFor(Model => Model.BossLastName, new { @class = "autocomplete-with-hidden", data_url = Url.Action("GetListForAutocomplete", "Person") })
        @Html.HiddenFor(Model => Model.BossId)
        @Html.ValidationMessageFor(model => model.BossLastName)
    </div>
</div>

form-group i col-md-10 to klasy należące do frameworka Bootstrap, który jest użyty w szablonach projektów MVC 5 - nie przejmuj się nimi. Właściwość BossLastName jest użyta do wyświetlania etykiety, pola tekstowego i komunikatu walidacji. Widok posiada ukryte pole do przechowania identyfikatora osoby wybranej dla właściwości Boss (encja Person). Helper @Html.TextBoxFor odpowiedzialny za prezentacje widocznego pola tekstowego definiuje klasę i atrybut data. Klasa autocomplete-with-hidden służy do oznaczania inputów, które powinny otrzymać funkcjonalność autocomplete. Atrybut data-url istnieje po to by dostarczyć informacji o adresie action methody, która zwraca dane dla listy sugestii autocomplete. Użycie Url.Action to lepszy pomysł niż wstawianie adresu na sztywno do kodu JavaScript ponieważ helper bierze pod uwagę reguły routingu, które mogą ulec zmianie.

Oto HTML, który został wyprodukowany przez pokazany wyżej kod Razor:

<div class="form-group">
    <label class="control-label col-md-2" for="BossLastName">Boss</label>
    <div class="col-md-10">
        <span class="ui-helper-hidden-accessible" role="status" aria-live="polite"></span>
        <input name="BossLastName" class="autocomplete-with-hidden ui-autocomplete-input" id="BossLastName" type="text" value="Kowalski" 
         data-val-required="Please select the boss" data-val="true" data-url="/Person/GetListForAutocomplete" autocomplete="off">
        <input name="BossId" id="BossId" type="hidden" value="4" data-val-required="The BossId field is required." data-val-number="The field BossId must be a number." data-val="true">
        <span class="field-validation-valid" data-valmsg-replace="true" data-valmsg-for="BossLastName"></span>
    </div>
</div>

To kod JavaScript odpowiedzialny za instalację kontrolki jQuery UI Autocomplete:

$(function () {
    $('.autocomplete-with-hidden').autocomplete({
        minLength: 0,
        source: function (request, response) {
            var url = $(this.element).data('url');
   
            $.getJSON(url, { term: request.term }, function (data) {
                response(data);
            })
        },
        select: function (event, ui) {
            $(event.target).next('input[type=hidden]').val(ui.item.id);
        },
        change: function(event, ui) {
            if (!ui.item) {
                $(event.target).val('').next('input[type=hidden]').val('');
            }
        }
    });
})

Opcja source jest ustawiona na funkcję. Funkcja ta ściąga dane z serwera za pomocą wywołania $.getJSON. URL jest pobierany z atrybutu data-url. Jeśli chcesz kontrolować caching albo dodać obsługę błędów możesz przełączyć się na funkcje $.ajax. Zdarzenie change jest obsługiwane po to by zapewnić, że wartości BossId oraz BossLastName są ustawione jedynie wówczas gdy użytkownik wybierze element z listy sugestii.

To jest metoda kontrolera dostarczająca dane dla autocomplete:

public JsonResult GetListForAutocomplete(string term)
{               
    Person[] matching = string.IsNullOrWhiteSpace(term) ?
        db.Persons.ToArray() :
        db.Persons.Where(p => p.LastName.ToUpper().StartsWith(term.ToUpper())).ToArray();

    return Json(matching.Select(m => new { id = m.Id, value = m.LastName, label = m.ToString() }), JsonRequestBehavior.AllowGet);
}

value i label to standardowe właściwości których oczekuje widget. label określa tekst prezentowany na liście sugestii, value wyznacza jakie dane są prezentowane w polu na którym został zainstalowany widget. id to niestandardowa właściwość wskazująca która encja Person została wybrana. Informacja ta jest użyta przy obsłudze zdarzenia select (zauważ odwołanie do ui.item.id). Wybrane ui.item.id jest ustawiane jako wartość ukrytego pola - dzięki temu jest ono przesłane w żądaniu HTTP gdy użytkownik decyduje się na zapis danych firmy.

Na zakończenie metoda zapisująca dane encji Company:

public ActionResult Edit([Bind(Include="Id,Name,BossId,BossLastName")] CompanyEditViewModel companyEdit)
{
    if (ModelState.IsValid)
    {
        Company company = db.Companies.Find(companyEdit.Id);
        if (company == null)
        {
            return HttpNotFound();
        }

        company.Name = companyEdit.Name;

        Person boss = db.Persons.Find(companyEdit.BossId);
        company.Boss = boss;
        
        db.Entry(company).State = EntityState.Modified;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(companyEdit);
}

Standardowy kod. Jeśli kiedykolwiek używałeś Entity Framework powyższe instrukcje powinienny być dla Ciebie jasne. Jeśli tak nie jest, nie martw się. Dla celów tego posta istotne jest zauważenie, że możemy użyć companyEdit.BossId ponieważ właściwość ta została wypełniona przez model binder dzięki istnieniu naszego ukrytego pola.

I to tyle, wszystkie wymagania spełnione! Prawda, że proste? :)

* Możesz się zastanawiać dlaczego w ogóle chcę użyć kontrolki jQuery UI w projekcie Visual Studio 2013, który domyślnie korzysta z Twitter Bootstrap. To prawda, że Bootstrap posiada pewne widgety i wtyczki ale po chwili eksperymentowania doszedłem do wniosku, że dla pewnych bardziej skomplikowanych scenariuszy jQ UI działa lepiej. Ten zestaw kontrolek jest po prostu bardziej dojrzały...

Html Agility Pack - masowe wyciąganie danych ze stron WWW

Niedawno potrzebowałem pozyskać zawartość pewnej bazy danych. Niestety była ona upubliczniona jedynie w formie serwisu WWW prezentującego 50 rekordów na pojedynczej stronie. Cała baza miała ponad 150 tysięcy rekordów. Co zrobić w takiej sytuacji? Przeklikiwać się przez 3000 stron, ręcznie gromadząc dane w pliku tekstowym? Tydzień i po sprawie! ;) Lepiej jednak napisać automat (tzw. scraper), który zrobi to za Ciebie. Automat musi zrobić trzy rzeczy:

  • wygenerować listę adresów stron, z których pobierane będą dane;
  • odwiedzać kolejno strony i wyciągać odpowiednie informacje z ich kodu HTML;
  • zrzucać dane do lokalnej bazy i logować postęp prac.

Generowanie adresów powinno być proste. W większości serwisów stronicowanie jest zrobione za pomocą zwykłych lików, w których numer strony jest łatwo widoczny w głównej części URL (http://example.com/somedb/page/1) lub w query stringu (http://example.com/somedb?page=1). Jeśli paginacja jest ajaxowa sprawa nieco się kompiluje, nie martwmy się tym jednak w tym poście... Gdy poznasz wzorzec przekazywania parametru z numerem strony, do stworzenia listy adresów wystarczy zwykła pętla z czymś w stylu:

string url = string.Format("http://example.com/somedb?page={0}", pageNumber)

Teraz pora na coś ciekawszego. W jaki sposób wyciągnąć dane ze strony WWW? Można użyć klas WebRequest/WebResponse lub WebClient z przestrzeni nazw System.Net do pobrania zawartości strony, po czym pozyskiwać informacje za pomocą wyrażeń regularnych. Można też spróbować potraktować pobraną treść jako XML i badać ją za pomocą XPath lub LINQ to XML. Nie są to jednak dobre podejścia. Przy skomplikowanej strukturze strony napisanie prawidłowego wyrażenia może być trudne, trzeba też pamiętać, że w większości przypadków strony internetowe nie są prawidłowymi dokumentami XML. Na szczęście powstała biblioteka Html Agility Pack, która umożliwia łatwe parsowanie stron HTML. Nawet tych, których kod nie przeszedłby walidacji bo np. nie zawiera poprawnych znaczników zamykających. HAP procesuje treść strony i buduje obiektowy model dokumentu, który można przetwarzać za pomocą LINQ to Objects lub XPath.

Aby zacząć pracę z HAP użyj NuGet by zainstalować pakiet HtmlAgilityPack (ja używałem wersji 1.4.6) po czym zaimportuj namespace o tej samej nazwie. Jeśli nie chcesz korzystać z NuGet (dlaczego?) pobierz plik zip ze strony projektu i dodaj referencje do pliku HtmlAgilityPack.dll stosownego dla Twojej platformy (zip zawiera np. wersje dla .NET 4.5 i Silverlight 5). Pomocna będzie też dokumentacja w pliku .chm. Uwaga! Gdy otworzyłem pobrany plik (w Windows 7), dokumentacja wyglądała na pustą. Pomogło użycie opcji „Odblokuj” z menu właściwości pliku.

Pobranie treści strony za pomocą HAP jest bardzo proste. Należy utworzyć obiekt klasy HtmlWeb po czym użyć metody Load podając adres strony:

HtmlWeb htmlWeb = new HtmlWeb();
HtmlDocument htmlDocument = htmlWeb.Load("http://en.wikipedia.org/wiki/Paintball");

W odpowiedzi otrzymamy obiekt klasy HtmlDocument, która stanowi centrum biblioteki HAP.

HtmlWeb zawiera właściwości, które mają wpływ na sposób pobierania dokumentu z sieci. Można np. wskazać czy ma być użyty mechanizm ciasteczek (UseCookies) oraz ustalić nagłówek User Agent dołączany do żądania HTTP (UserAgent). Mnie szczególnie przydały się właściwości AutoDetectEncoding i OverrideEncoding, dzięki którym mogłem poprawnie odczytać dokument z polskimi znakami:

HtmlWeb htmlWeb = new HtmlWeb() { AutoDetectEncoding = false, OverrideEncoding = Encoding.GetEncoding("iso-8859-2") };

Inna bardzo przydatną właściwością HttpWeb jest StatusCode (typu System.Net.HttpStatusCode). Informuje ona o rezultacie obsługi ostatniego żądania.

Mając obiekt HtmlDocument możemy przystąpić do wyciągania danych. Oto przykład jak wyciągnąć adresy i teksty linków z pobranej wcześniej strony (dodaj using System.Linq):

IEnumerable<HtmlNode> links = htmlDocument.DocumentNode.Descendants("a").Where(x => x.Attributes.Contains("href");
foreach (var link in links)
{
    Console.WriteLine(string.Format("Link href={0}, link text={1}", link.Attributes["href"].Value, link.InnerText));       
}

Właściwość DocumentNode typu HtmlNode wskazuje na root strony. Metodą Descendants pobieramy wszystkie linki (tag a) zawierające atrybut href, po czym wypisujemy adres linku i jego tekst na konsole. Prawda, że proste? Kilka innych przykładów:

Pobranie całego kodu HTML strony:

string html = htmlDocument.DocumentNode.OuterHtml;

Pobranie elementu o id footer”:

HtmlNode footer = htmlDocument.DocumentNode.Descendants().SingleOrDefault(x => x.Id == "footer");

Pobranie dzieci div o id „toc” i wyświetlenie nazw tych, których typ jest różny od Text:

IEnumerable<HtmlNode> tocChildren = htmlDocument.DocumentNode.Descendants().Single(x => x.Id == "toc").ChildNodes;
foreach (HtmlNode child in tocChildren)
{
    if (child.NodeType != HtmlNodeType.Text)
    {
        Console.WriteLine(child.Name);
    }
}

Pobranie elementów listy (tag li) mających klasę toclevel-1:

IEnumerable<HtmlNode> tocLiLevel1 = htmlDocument.DocumentNode.Descendants()
    .Where(x => x.Name == "li" && x.Attributes.Contains("class")
    && x.Attributes["class"].Value.Split().Contains("toclevel-1"));

Zwróć uwagę, że filtr Where jest dość złożony. Prosty warunek:

Where(x => x.Name == "li" && x.Attributes["class"].Value == "toclevel-1")

nie jest poprawny! Po pierwsze nie ma gwarancji, że każdy tag li ma atrybut class, więc mógłby się pojawić NullReferenceException. Po drugie sprawdzenie istnienia klasy toclevel-1 jest wadliwe. Element HTML może mieć wiele klas, dlatego zamiast == warto użyć Contains(). Samo Value.Contains to jednak za mało. Co jeśli szukamy klasy o nazwie „sec”, a element ma klasę „secret”? Taki element też zostanie dopasowany! Zamiast Value.Contains należy użyć Value.Split().Contains. Wówczas przeszukujemy nie string, a tablicę stringów (przy użyciu operatora równości).

Pobranie tekstów wszystkich elementów li, które zagnieżdżone są w co najmniej jedynym elemencie li:

var h1Texts = from node in htmlDocument.DocumentNode.Descendants()
              where node.Name == "li" && node.Ancestors("li").Count() > 0
              select node.InnerText;

Oprócz LINQ to Objects do wyciągania informacji można użyć również zapytań XPath. Przykładowo:

Pobranie tagów a, których wartość atrybutu href zaczyna się od # i jest dłuższa niż 15 znaków:

IEnumerable<HtmlNode> links = htmlDocument.DocumentNode.SelectNodes("//a[starts-with(@href, '#') and string-length(@href) > 15]"));

Znalezienie elementów li w div o idtoc”, które są trzecim dzieckiem w swoim elemencie zawierającym:

IEnumerable<HtmlNode> listItems = htmlDocument.DocumentNode.SelectNodes("//div[@id='toc']//li[3]");

XPath to skomplikowane narzędzie o ogromnych możliwościach, ja poprzestanę na tych dwóch przykładach...

HAP pozwala nie tylko na badanie struktury i treści strony, umożliwia też jej modyfikację i zapis. Istnieją metody pomocnicze m. in. do wykrycia kodowania (DetectEncoding) lub usunięcia encji HTML (DeEntitize). Można również pozyskać informacje o tym czy oryginalny dokument posiadał odpowiednio zamknięte tagi itp. Te tematy wykraczają jednak poza zakres postu.

W miarę pobierania informacji z kolejnych stron zrzucaj je do bazy, z której będziesz mógł wygodnie skorzystać (może wystarczy Ci plik .csv, może potrzebna będzie baza SQL - mnie wystarczył zwykły plik tekstowy).

Ostatnią rzeczą, która należy zrobić jest zadbanie o to by scraper logował informacje o postępie prac (chciałbyś przecież wiedzieć jak daleko zaszedł Twój automat i czy coś nie poszło źle). Do logowania najlepiej jest użyć wyspecjalizowanej biblioteki takiej jak np. log4net. W necie jest mnóstwo instrukcji o tym jak z niej korzystać, nie będę wiec o tym pisał. Dodam za to przykładową konfiguracje, której możesz użyć w aplikacji konsolowej:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <configSections>
        <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>          
    </configSections>
    <log4net>        
        <root>
            <level value="DEBUG"/>            
            <appender-ref ref="ConsoleAppender" />
            <appender-ref ref="RollingFileAppender"/>
        </root>
        <appender name="ConsoleAppender" type="log4net.Appender.ColoredConsoleAppender">
            <layout type="log4net.Layout.PatternLayout">
                <conversionPattern value="%date{ISO8601} %level [%thread] %logger - %message%newline" />
            </layout>
            <mapping>
                <level value="ERROR" />
                <foreColor value="White" />
                <backColor value="Red" />
            </mapping>
            <filter type="log4net.Filter.LevelRangeFilter">
                <levelMin value="INFO" />                
            </filter>
        </appender>         
        <appender name="RollingFileAppender" type="log4net.Appender.RollingFileAppender">
            <file value="Log.txt" />
            <appendToFile value="true" />
            <rollingStyle value="Size" />
            <maxSizeRollBackups value="10" />
            <maximumFileSize value="50MB" />
            <staticLogFileName value="true" />
            <layout type="log4net.Layout.PatternLayout">
                <conversionPattern value="%date{ISO8601} %level [%thread] %logger - %message%newline%exception" />
            </layout>
        </appender>
    </log4net>    
</configuration>

Posiada ona dwa appendery: ConsoleAppender i RollingFileAppender. Pierwszy z nich loguje tekst do okna konsoli, dbając o to by błędy wyraźnie wyróżniały się kolorem. By ograniczyć ilość informacji ustawiony jest LevelRangeFilter dzięki czemu prezentowane są tylko wpisy o poziomie INFO lub wyższym.

Drugi appender loguje do pliku tekstowego (trafiają tam nawet wpisy o poziomie DEBUG). Maksymalny rozmiar pojedynczego pliku ustalony jest na 50MB, a poduszczana ilość plików archiwalnych to 10. Bieżący log znajduje się zawsze w pliku Log.txt...

I to wszystko, scraper jest gotowy! Odpal go i niech tyra za Ciebie. Wielogodzinną, monotonną robotę zostaw tym, którzy nie potrafią programować! :)

Możesz jeszcze wykonać małe ćwiczenie: zamiast tworzyć listę wszystkich stron do odwiedzenie, ustal jedynie pierwszą stronę po czym spróbuj znaleźć link do następnej strony w kodzie bieżącej.

P.S. Pamiętaj o tym, że HAP działa na kodzie HTML, który został nadesłany przez serwer i to na bazie tego kodu buduje model dokumentu. DOM, który obserwujesz w narzędziach deweloperskich swojej przeglądarki często jest wynikiem działania skryptów i może znacznie różnić się od tego, który wynikałby z odpowiedzi HTTP.

Aktualizacja 08.12.2013: Zgodnie z prośbą stworzyłem proste demo (w Visual Studio 2010) użycia Html Agility Pack i log4net. Aplikacja wyciąga linki ze strony wiki i zapisuje je w pliku tekstowym. Strona wiki zrzucona jest do pliku htm by uniknąć zależności od strony w sieci, która może się zmienić. Pobierz.