niedziela, 14 października 2012

Zarządzanie pamięcią w .NET

Artykułów na temat Garbage Collector zostało napisanych już wiele. Dlatego nie będę przedstawiać dokładnie zasady jego działania, a raczej praktyczne wskazówki implementacji finalizerów i wzorca IDisposable.
Garbage Collector podczas swojego cyklu wykonuje następujące czynności
  • sprawdza czy dany obiekt jest przypisany do referencji
  • defragmentuje zarządzaną stertę
  • wolne miejsce scala do jednego bloku pamięci
Finalizery to defensywna metoda dopilnowania, aby pamięć została zwolniona nawet wtedy, kiedy użytkownik zapomni wywołać metody Dispose. Finalizery są wywoływane przez Garbage Collector po usunięciu obiektu z pamięci. Moment wywołania finalizera nie jest deterministyczny.
Finalizery obniżają wydajność aplikacji. Gdy GC otrzyma zadanie usunięcia obiektu, który posiada finalizer nie zrobi tego od razu. Najpierw musi zostać wywołany finalizer. Wywołanie go nie odbywa się w tym samym wątku co działania GC. Obiekt taki trafia do kolejki obiektów, którym należy wywołać finalizera. GC nie przerywa w tym momencie swojej pracy. Przy następnym wywołaniu GC obiekty z wywołanym finalizerem zostają usunięte z pamięci. Wydawałoby się iż obiekty posiadające finalizer trwają w pamięci o jeden cykl więcej niż ich odpowiednicy bez niego. Tak nie jest. GC pracuje z tzw. generacjami. Jest ich 3: zerowa, pierwsza i druga. Każdy obiekt który przetrwał 1 cykl oczyszczania trafia do pierwszej generacji, obiekt który przetrwał 2 cykle lub więcej trafia do drugiej generacji.Tak więc:
  • generacja 0 - przeważnie zmienne lokalne
  • generacja 1 lub 2 - zmienne globalne
 GC optymalizuje swoją pracę poprzez ograniczenie czyszczenia 1 i 2 generacji. W każdym cyklu czyszczona jest generacja 0, jednak tylko raz na 10 cykli jest czyszczona generacja 1, a raz na 100 wszystkie generacje. Obiekt zwierający finalizer może więc przebywać o 9 cykli więcej niż taki, który go nie zawiera.
Rozwiązaniem jest poprawna implementacja wzorca IDisposable. Nic jednak nie pomoże tak GC, jak unikanie niepotrzebnego tworzenia obiektów. Przykładem złego tworzenia obiektów jest napisana poniżej funkcja OnPaint:

Code:
        protected override void OnPaint(PaintEventArgs e)
        {
            using (var font = new Font("Verdana", 10.0f))
            {
                e.Graphics.DrawString("Ala ma kota",
                font, Brushes.Black, new PointF(0, 0));
            }
            base.OnPaint(e);
        }

Metoda OnPaint wywoływana jest wielokrotnie podczas działania programu. Za każdym razem tworzony będzie obiekt czcionki (Font). W takim przypadku warto wyciągnąć tworzenie czcionki na zewnątrz funkcji:

Code:
        Font font = new Font("Verdana", 10.0f);
        protected override void OnPaint(PaintEventArgs e)
        {
            e.Graphics.DrawString("Ala ma kota", font, Brushes.Black, new PointF(0, 0));
            base.OnPaint(e);
        }

Warto zauważyć tutaj także użycie klasy Brushes. Klasa ta może być wykorzystywana w dowolnym miejscu programu. Każdy kolor w tej klasie jest tworzony jako singleton. Nie jest więc tworzony odrębny obiekt pędzla a korzystamy z jednego dostępnego dla całego programu.
Kolejnym przykładem złego tworzenia obiektów jest łączenie długich stringów. Jeżeli łączymy wiele stringów należy stworzyć obiekt typu StringBuilder i za pomocą niego dokonać operacji na łańcuchu.

W przypadku hierarchii klas, klasa bazowa powinna implementować interfejs IDisposable. Dodatkowo implementujemy finalizer na przypadek gdy użytkownik zapomni zwolnić zasoby. Jeżeli klasa potomna także alokuje zasoby, które muszą zostać zwolnione, musi także implementować interfejs IDisposable oraz dodatkowo wywołać implementację z klasy bazowej.
Implementacja IDisposable musi spełnić 4 założenia:
  1. uwolnienie zasobów nie zarządzanych
  2. uwolnienie zasobów zarządzanych (np. uchwyty zdarzeń)
  3. ustawienie flagi mówiącej, że obiekt został zwolniony. W publicznych metodach powinno nastąpić sprawdzenie flagi i w przypadku jej ustawienia rzucenie wyjątku ObjectDisposedException
  4. Zapobiegnięcie finalizacji obiektu - wywołanie funkcji GC.SuppressFinalize(this) 
Dwa pierwsze punkty eliminuje implementuje IDisposable. Mechanizm będzie nadal posiadał luki:
W jaki sposób klasy potomne wyczyszczą swoje zasoby, a zarazem pozwolą klasie bazowej posprzątać po sobie? Jeżeli klasy dziedziczące nie wywołają czyszczenia z klasy bazowej zasoby nigdy nie zostaną zwolnione. Aby zwolnić te zasoby tworzymy wirtualną metodę Dispose, o następującej sygnaturze:

Code:
protected virtual void Dispose(bool isDisposing)

klasy potomne nadpisują tę metodę a na końcu wywołują wersję z klasy bazowej. W zależności od wartości parametru idDisposing:
  • TRUE - czyścimy zarówno zasoby zarządzane jak i nie zarządzane
  • FALSE - tylko zasoby nie zarządzane
Przykład:

Code:
    public class A : IDisposable
    {
        private bool isDisposed = false;

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool isDisposing)
        {
            if (isDisposed)
            {
                return;
            }
            if (isDisposing)
            {
                //usuwamy zasoby zarządzane
            }

            //czyszczenie zasobów nie zarządzanych

            isDisposed = true;
        }

        public void Method()
        {
            if (isDisposed)
            {
                throw new ObjectDisposedException("A");
            }
        }

        ~A()
        {
            Dispose(false);
        }
    }


Code:
    public class B : A
    {
        private bool isDisposed = false;
        protected override void Dispose(bool isDisposing)
        {
            if (isDisposed)
            {
                return;
            }
            if (isDisposing)
            {
                //czyszczenie zasobów zarządzanych
            }
            //czyszczenie zasobów niezarządzanych

            base.Dispose(isDisposing);
            isDisposed = true;
        }
    }

Warto zauważyć, że flaga jest powielona - zarówno klasa bazowa jak i dziedzicząca ją posiada. Zapobiega to niepoprawnemu zwalnianiu zasobów tylko dla jednego typu.
Finalizer do klasy dodajemy tylko wtedy kiedy kod zawiera zasoby niezarządzane. W powyższym przypadku nie musiał zostać zdefiniowany, wręcz brak jego definicji wpłynie bardzo pozytywnie na wydajność.
Ważne jest także, aby operacja zwalniania nie wykonywała żadnych innych operacji niż tych do które jest przewidziana. Implementacja innych operacji niż tych które czyszczą pamięć, może dojść do niechcianego odtworzenia obiektu.

Brak komentarzy:

Prześlij komentarz