Code Review: Kiedy lepiej używać struktury niż klasy

Wiele programistów c# zapomina, że struktury również istnieją w .NET. Mam wrażenie, że jest to konstrukcja bardziej popularna w CPP niż w C#.

W praktyce jednak, wybranie struktur zamiast klas, może mieć kolosalne znaczenie jeśli chodzi o wydajność i płynność aplikacji. Nie jednokrotnie porównywałem te dwa typy obiektów na blogu więc podstaw nie będę omawiał tutaj. Przyjmuje, że każdy wie, że struktury znajdują się na stosie a obiekty klas na stercie.

W poście chciałbym skupić się na następującym scenariuszu. Mamy obiekt reprezentujący punkt za pomocą dwóch współrzędnych (X,Y). Jaka jest w praktyce różnica pomiędzy następującymi konstrukcjami?

struct Point
{
    public int X{get;private set;}
    public int Y{get;private set;}
}
class Point
{
    public int X{get;private set;}
    public int Y{get;private set;}
}

Point[] dataSource = new Point[10000000]

Zastanówmy się, co się dzieje gdy mamy kilka milionów punktów w pamięci w sytuacji gdy każdy z nich zdefiniowany jest przez strukturę oraz klasę.

W przypadku, gdy musimy stworzyć kilka milinów instancji, zużycie pamięci ma znaczenie. Struktura jest prosta i zajmie 8 bajtów (każdy int ma 4). Oczywiście zależy to od przyjętego layoutu, ponieważ integer i byte nie będą zajmować 5 bajtów a prawdopodobnie 8, gdyż jest to jest optymalne z punktu widzenia CPU i dostępu do takiej pamięci.

Każda klasa posiada jednak wiele dodatkowych pól. Przede wszystkim ma wskaźnik na tabelę zawierającą wskaźniki do metod. Programiści z CPP znają ją, ponieważ jest to jedyny sposób na wywołanie wirtualnych metod. Załóżmy, że mamy następujący kod:

BaseClass data = new ConcreteImplementationA();
data.Insert();

Ponadto, przyjmujemy, że Insert jest wirtualną metodą i może zostać przeładowana w różnych implementacjach. Skąd wiadomo, jaką implementację wywołać? Gdyby była to niewirtualna metoda, wtedy kompilator sprawdziłby, że zmienna data jest typu BaseClass i należy wywołać Insert znajdujący się w BaseClass. Metoda jest wirtualna więc w tym przypadku chcemy wywołać ConcreteImplementationA::Insert(). Pewnym trickiem, mogłaby być dynamiczna analiza kodu czyli sprawdzenie czym tak naprawdę jest data. W powyższym scenariuszu sprawdziłoby się to ale w praktyce możemy mieć przecież ServiceLocator albo wstrzyknięcie implementacji za pomocą konstruktora. Sprawa może być naprawdę skomplikowana, zależna od wielu warunków. W rzeczywistości problem jest rozwiązany właśnie przez tabelę wskaźników do metod. Gdy chcemy wywołać metodę, przeszukujemy taką tabelę  i za pomocą niej uzyskujemy wskaźnik do konkretnej implementacji. Tworząc ConcreteImplemantionA(), tabela zostanie stworzona z prawidłowymi wskaźnikami. Oczywiście wywołanie takiej metody jest bardziej czasochłonne ponieważ najpierw należy znaleźć odpowiedni wpis w tabeli co w przypadku gdy mamy wiele metod jest jakimś obciążeniem.

Warto zauważyć, że wspomniana tabela nie jest dokładnie tym samym czym jest w CPP. Przede wszystkim posiada ona metody do wszystkich metod, a nie tylko tych wirtualnych. Kolejną różnicą są wpisy dla interfejsów, które w CPP nie istnieją.W rzeczywistości jest tam o wiele więcej informacji przydatnych przy reflection.

W nagłówku klasy znajduje się również pole na tzw. Sync Block. Służy one do synchronizacji w środowisku wielowątkowym. Gdy chcemy skorzystać z lock, musimy przekazać jakiś obiekt, który będzie służył za identyfikator sekcji krytycznej – tylko jeden obiekt może do takowej wejść. Zakładając blokadę na obiekcie A, ustawiany w nim jest SyncBlock, który stanowi wskaźnik na specjalny obiekt w tablicy SyncBlocks. Każdy SyncBlock nie jest tworzony od nowa. W momencie, gdy zakładamy blokadę, wskaźnik jest po prostu ustawiany na odpowiedni SyncBlock. Dzięki temu jest to bardzo wydajne ponieważ nie ma niepotrzebnej alokacji obiektu – mamy do dyspozycji pulę SyncBlock. Gdy zabraknie odpowiedniej ilości SyncBlock, wtedy dopiero zostaną one utworzone. Każdy SyncBlock zawiera informacje o blokadzie takie jak wątek, który aktualnie wykonuje sekcję krytyczną, liczbę wątków oczekujących oraz recursion count czyli liczbę wykorzystywaną gdy ten sam wątek ponownie chce wejść do takiej samej sekcji krytycznej.

Wniosek jest taki, że alokacja klas jest dużo bardziej czasochłonna. Należy stworzyć dodatkowe obiekty oraz końcowy rozmiar obiektu jest dużo większy. Dla jednej instancji nie ma to większego znaczenia, ale jak mamy do czynienia z kilku-milionową tablicą wtedy jest już sytuacja warta przemyślenia.

Kolejny problem to zwolnienie pamięci. W przypadku struktury, w momencie gdy np. metoda kończy wywołanie to wszystkie obiekty zostaną automatycznie zwolnione (wyzerowane) wraz z całym stosem. Zwolnienie pamięci na stercie jest z kolei koszmarnie wolnym i czasochłonnym procesem. Należy przede wszystkim przeszukać graf obiektów, składający się z kilku milionów obiektów co już jest skomplikowanym procesem. A co jeśli, któryś z obiektów zostanie wypromowany do kolejnych generacji? Sprawa się jeszcze bardziej skomplikuje i spowolni działanie aplikacji. Pamiętajmy (pomijając specjalne tryby), że GC musi zatrzymać wszystkie wątki jeśli chce dokonać kolekcji co wiąże się z zatrzymaniem aplikacji.

Biorąc pod uwagę powyższe rozważania, używajmy zawsze struktur gdy tworzymy wiele małych obiektów (wyłącznie kilka pól). Gdy dany obiekt zajmuje kilka bajtów, wtedy klasa jest po prostu zbyt ciężkim obiektem i dodaje niepotrzebne pola.

5 thoughts on “Code Review: Kiedy lepiej używać struktury niż klasy”

  1. @Michal:
    W swoich projektach zawsze uzywam struktur dla opisanych wyzej warunkow.
    W sumie w nastpenych poscie moge zrobic maly eksperyment pokazujac dostepna pamiec itp. Dzieki za pomysl.

  2. Moja przygoda z programowaniem zaczęła się od mikroprocesorów, które nie wybaczają błędów wydajnościowych.
    Niestety z przykrością patrzę na kolegów, którzy w ogóle nie zdają sobie sprawy z tego co się dzieje z kodem. Często ich konstrukcje działają tylko przy małym obciążeniu aplikacji. Fajnie, że poruszasz zagadnienia optymalizacji aplikacji.

  3. @Robert:
    Bo to jest tak, ze mozna nauczyc sie podstaw java czy c# bez znajomosci podstaw architektury komputera. W przypadku ASM czy CPP sprawa wyglada inaczej(zwlaszcza ASM) i zwykle programisci sa zmuszeni aby myslec o pamieci itp.

  4. Dzięki Piotrek, kolejny fajny wpis. W kwestiach wydajności zawsze prosimy o więcej 🙂

    Pozdrawiam

Leave a Reply

Your email address will not be published.