Garbage Collector

Deti

1 Wstęp
2 Skróty
3 Typy w C#
4 Garbage Collector
5 Destruktory
     5.1 Jak stworzyć destruktor?
     5.2 Czym jest finalizer?
     5.3 Kilka uwag nt. stosowania destruktorów
6 Sterowanie GC
     6.4 GC.Collect()
     6.5 GC.SuppressFinalize()
     6.6 GC.ReRegisterForFinalize()
     6.7 GC.WaitForPendingFinalizers()
7 Zasoby zewnętrzne i IDisposable
8 Dispose i Finalize
9 Podsumowanie

Wstęp

W artykule postaram się opisać pewne fundamenty programowania w C#. Każdy znajdzie tu coś dla siebie. Początkujący programiści C# dowiedzą się jak działają obiekty w .NET, co to jest kod zarządzany i jak w tytule - jak działa Garbage Collector. Bardziej doświadczeni również nie powinni się zawieść i może dowiedzą się czegoś nowego. Kimkolwiek byś nie był - zapraszam do lektury.

Skróty

Poniżej kilka skrótów, które będą używane w całym artykule, aby się zbytnio nie rozpisywać:

  • GC - Garbage Collector
  • CLR - Common Language Runtime (wspólne środowisko uruchomieniowe dla platformy .NET)
  • VT - Value typ (typ prosty)
  • RT - Reference type (typ referencyjny)

Typy w C#

W języku tym mamy do czynienia z dwoma rodzajami "bytów":

  • typy proste
  • typy referencyjne

Do pierwszej grupy zaliczają się wszelkie typy wbudowane typu System.Int32, System.Boolean, System.Byte, typy wyliczeniowe oraz struktury[#]_ . Typy proste istnieją na zupełnie innej zasadzie niż typy referencyjne. Są one umieszczone bezpośrednio na stosie i GC ich nie dotyczy.

Typy referencyjne z kolei to instancje klas np. System.String[#]_, System.Text.Encoding, System.Timers.Timer itd. Rządzą się one własnymi prawami - zupełnie odmiennymi niż typy proste.

.. [#] W zasadzie to System.Int32 i pozostałe opisane również są strukturami.

.. [#] System.String jest typem referencyjnym - inaczej niż mogło to by się wydawać. String w języku C# jest obiektem niezmienialnym - każda jego rzekoma zmiana jest w rzeczywistości nowym stringiem. Z tego powodu wiele ludzi myli string z typem prostym.

Kiedy mamy do czynienia z typem referencyjnym - w rzeczywistości operujemy na wskaźniku, który wskazuje na odpowiedni obszar w pamięci.

Przykład z objaśnieniem:

 StringBuilder a=new StringBuilder(); //1
 StringBuilder b=new StringBuilder(); //2
 a=b; // 3
 b=null; //4

Linie zostały ponumerowane odpowiednimi komentarzami. Poniżej mały opis krok po kroku:

  1. Tworzymy nowy obiekt - instancję klasy StringBuilder. W rzeczywistości tworzone są 2 byty: właściwy obiekt na stercie oraz wskaźnik na stosie, który wskazuje na ten obiekt.

gcrys1.jpg

  1. Tworzymy nowy obiekt typu StringBuilder. Tworzony jest nowy obiekt na stercie oraz nowy wskaźnik[#]_

.. [#] Zarządzaniem stertą zajmuje się CLR - programista nie musi nic robić aby powstał obiekt (np. przydzielić pamięć).

gcrys2.jpg

  1. Przypisujemy wskaźnikowi 'a' referencję, która wskazuje na 'b'. Choć w rzeczywistości może się wydawać, że jest zupełnie inaczej, ten zapis w C# oznacza właśnie zmianę referencji. Mówiąc "wskaźnik" mam na myśli zmienną typu jakiego jest nasz obiekt. Wszystko powinien wyjaśnić rysunek:

gcrys3.jpg

  1. Przypisujemy zmiennej 'b' wartość null. To przypisanie jest niczym innym jak usunięciem referencji. Po wykonaniu tej linii, zmienna 'b' (mimo, że jest typu StringBuilder) nie wskazuje na żaden obiekt na stercie. "null" jest zatem pustą referencją.

gcrys4.jpg

Cóż zatem się stało? Na początku istniały 2 obiekty z referencjami do nich, a w ostateczności zmieniliśmy referencję dla obiektu 'a', a usunęliśmy dla 'b'. Cóż zatem z rzeczywistym obiektem 'a', który dalej jest na stercie? A więc: nie mamy żadnej sposobności aby odwołać się do obiektu 'a' (wszystkie referencje do niego zostały usunięte). O takim obiekcie mówi się, że jest martwy. Zajmuje tylko niepotrzebną pamięć sterty, która jest marnowana.

W takim wypadku wchodzi właśnie mechanizm GC, który wspaniałomyślnie usunie obiekt ze sterty.

Garbage Collector

Usunięciem obiektu "a" ze stery zajmuje się GC. Jest to mechanizm, który tłumaczy się na polski język jako "odśmiecacz". Jego zadaniem jest szukanie martwych obiektów oraz usunięcie ich - w ten sposób - utrzymując porządek na stercie oraz nie dopuszczając do przepełnień ani wycieków pamięci.

Pojawia się jednak szereg pytań - jak to robi? Kiedy odśmieca? Czy można nim sterować? .. Na te pytania postaram się odpowiedzieć poniżej.

Pierwszą rzecz, jaką powinien wiedzieć każdy programista C# to to, że GC działa automatycznie. Nie ma on żadnego włącznika, który powoduje, że zaczyna działać. Piszac nawet wielkie aplikacje, czy to WinForms, WebForms czy też Web Service- nie musimy praktycznie nic robić. Działanie GC można porównać do działania osobnego wątku. Wątek ten non stop działa podczas życia aplikacji i w pewnych momentach uruchamia się[#]_.

Gdy GC zdecyduje, ze czas "na porządki", wywłaszcza on wszystkie wątki w danym procesie i skanuje w poszukiwaniu obiektów na stercie, do którego nie ma referencji. Mowa oczywiście o danym kontekście w aplikacji. Obiekt, który jest obecnie bez referencji, mógł mieć jakiś wskaźnik, który na niego wskazuje jeszcze kilka milisekund wcześniej. GC zatem skanuje na podstawie aktualnego kontekstu aplikacji[#]_.

.. [#] Jest wiele momentów, który notyfikują GC do działania (jednym z nich jest na przykład wyjście z metody).

.. [#] Mówiąc o kontekście mam na myśli dosłownie miejscie w kodzie. Na przykład: gdy wychodzimy z metody, wszelkie zmienne, które były stworzone wewnątrz tej metody są już niedostępne - zatem można je wyczyścić ze sterty.

Oczywiście powyższy opis jest jedynie wierzchołkiem góry lodowej - nie będziemy się bowiem zagłębiać w szczegóły wewnętrznej implementacji GC. Podam jedynie te informacje, które faktycznie mogą przydać się podczas tworzenia aplikacji.

Destruktory

To słowo spędzało ze snu wielu programistów C++ czy Delphi. Ponieważ w C# za usuwanie obiektów odpowiada GC - nie musimy jawnie deklarować destruktorów. GC w jakiś magiczny sposób usuwa wszelką pamięć, którą wcześniej wywłaszczał obiekt. Jednak destruktory w C# są możliwe do zrealizowania - jednak ich działanie różni się w sposób istotny od destruktorów znanych np. z Delphi.

Przede wszystkim w języku C# destruktor wywoływany jest niejawnie. Co więcej - może on być wywołany jedynie przez GC. Nie mamy możliwości aby w naszym kodzie zniszczyć jakiś obiekt w konkretnym miejscu.

Nie można jawnie wywołać destruktora obiektu. Może to zrobić jedynie GC, gdy stwierdzi, że obiekt ten nie jest już potrzebny. Nie można również stwierdzić kiedy to się stanie.

Ponieważ GC działa asynchronicznie do wątku aplikacji, nie ma gwarancji, że przy wyjściu z metody wszelkie obiekty żyjące wewnątrz tej metody będą zwolnione natychmiast. W wielu przypadkach - być może tak się stanie, jednak programiści nie powinni robić takich założeń.

Jak stworzyć destruktor?

Jest to bardzo proste. Spójrzmy na przykład:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace GC {

    class TestClass {
        int a=5;

        ~TestClass() {
            // to jest destruktor!
            Console.WriteLine("Destruktor");
        }
    }

    class Program {
        static void Main(string[] args) {
            TestFunc();
        }

        static void TestFunc() {
            TestClass a=new TestClass();
        }


    }
}

Jest to bardzo prosta aplikacja konsolowa. W funkcji Main wywołujemy funkcję TestFunc, gdzie tworzony jest testowany obiekt typu TestClass. Własnie klasa TestClass ma zadeklarowany destruktor.

Aby zadeklarować destruktor, zastosuj nazwę klasy poprzedzoną znakiem tyldy (~).
Wywołując aplikację zobaczymy, że rzeczywiście destruktor się wykonał - i wypisał swoją rolę do okienka konsoli. Miało to miejsce dokładnie przed tym, jak GC usunął obiekt "a" ze sterty.

Tylda jest sposobem na stworzenie destruktora, jednak w rzeczywistości funkcja ta (destruktor) został przez kompilator przepisany na:

  protected override void Finalize() {
       try {
           // to jest destruktor!
            Console.WriteLine("Destruktor");
       }
       finally {
           base.Finalize();
       }
   }

Czym jest finalizer?

Jak widać tylda jest tylko zabiegiem, aby wygenerować (niejawnie) powyższy kod. Kod ten gwarantuje, że w przypadku wystąpienia wyjątków w destruktorze - nastąpi wywołanie destruktora klasy bazowej[#]_.

Istotne jest tu wystąpienie słowa override. Oznacza to, że metoda Finalize() jest wirtualna i zadeklarnowa w klasie bazowej. Nasza klasa nie miała klasy bazwowej - a zatem niejawnie dziedziczy z klasy System.Object[#]_.

A zatem destruktor jest w każdej klasie od samego początku - wymuszony przez klasę bazową System.Object. To właśnie nadpisanie Finalize() przez klasę pochodną informuje GC, że w danej klasie jest destruktor, który należy wywołać przed fizycznym wyczyszczeniem obiektu. GC robi to w sposób bardziej skomplikowany niż by się wydawało - tworzy sobie wewnętrzną listę obiektów, których destruktory należy wywołać. Lista ta jest budowana na podstawie tego, czy dany obiekt ma destruktor czy nie.

.. [#] Mówiąc zamiennie destruktor / finalizer mam na myśli ten sam fragment kodu. Destruktor to nazwa zwyczajowa - jako metoda, która wykonuje się przed zniszczeniem obiektu. Finalize() to nazwa tej metody w języku C#.

.. [#] System.Object jest klasą bazową dla wszystkich klas, jakkolwiek byśmy jej nie zadeklarowali.

Kilka uwag nt. stosowania destruktorów

W więcej niż 99% przypadków swoich klas - nie będzie potrzeby deklarowania destruktora. Prawdę mówiąc - przyda się on tylko, gdy klasa będzie używała pamięci niezarządzanej[#]_.

Jeśli już przyjdzie ci pracować z destruktorem, oto kilka uwag i przemyśleń, które mogą okazać się przydatne:

  • Finalizer powinien być zawsze oznaczony jako protected, a nigdy public. Ma to istotne znaczenie, ponieważ nie chcemy aby ktoś ręcznie wywoływać destruktor obiektu. Najlepiej stosować jednak tyldę, która zapewnia nie tylko bezpieczeństwo, ale i czystość w kodzie.
  • Finalizer powinien służyć jedynie do czyszczenia pamięci niezarządzanej. Nawet z poziomu finalizera nie masz dostępu do destruktorów innych obiektów.
  • Czas wykonania destruktora nie jest znany. Nie powinieneś polegać na tym, że jakiś destruktor się wykona w danym momencie. Co więcej - nie mogę sobie wyobrazić sytuacji, aby takie założenie było niezbędne.
  • Kolejność wykonywanie destruktorów obiektów nie jest znana. Innymi słowy- nie powinieneś się odwoływać do innych obiektów z poziomu destruktora - z prostego poziomu - mogą one już nie istnieć.
  • Nie stosuj finalizera na typach prostych - GC i tak go nie wykona.
  • Nie stosuj pustych destruktorów. Jeśli nie masz nic konkretnego do wykonania w destruktorze - nie deklaruj go. Zwolni to tylko działanie GC, który dodatkowo będzie musiał odpalić twój destruktor.

.. [#] Jest to swojego rodzaju konflikt, ale tylko pozornie. GC czyści jedynie pamięć zarządzaną - stertę, która działa w ramach CLR i nic ponad to. Pewne klasy mogą chcieć operować na czymś więcej niż tylko pamięci zarządzanej. W tej sytuacji destruktor okaże się ostatnią deską ratunku, aby wyczyścić tą pamięć. A dokładniej - wywoła metodę, która to zrobi. Ale o tym powiem więcej w seksji, gdzie poznamy interfejs IDisposable.

Sterowanie GC

W poprzednich sekcjach można było dowiedzieć się trochę o automatycznym działaniu GC. Jednak sam GC nie jest aż tak wielką tajemnicą, żeby programista nie miał do niego dostępu. GC udostępnia szereg metod, które mogą pomóc podczas projektowania aplikacji - zwłaszcza jeśli chodzi o optymalizację.

GC jest dostępny jako statyczna klasa System.GC. Poniżej kilka metod, których działanie warto poznać:

GC.Collect()

Wywołanie tej metody zmusza GC do odśmiecania. Jak widać GC notyfikuje się nie tylko automatycznie, ale również manualnie - właśnie poprzez metodę Collect(). Jednak samo działanie również nie jest tak oczywiste jakby się wydawało. Metoda ta działa asynchronicznie. Nie ma pewności, że GC rzeczywiście wykona całą pracę po wykonaniu tej metody. Jest to tylko asynchroniczna notyfikacja typu "spróbuj czyścić teraz". Ludzie często zadają pytania: kiedy (jeśli w ogóle) wywoływać GC.Collect(). Przede wszystkim - nie ma obowiązku tego wywołania. Jeśli nie jesteś doświadczonym programistą - jego błędne użycie spowoduje jedynie spadek wydajności aplikacji niż jego wzrost. Jednym ze scenariuszy jakie można by przytoczyć jest użycie dość dużej liczby obiektów, które już są niepotrzebne. Wywołanie Collect zmusi GC do usunięcia ich ze sterty.

Metoda Collect() jest przeciążona, jednak użycie przeciążenia o parametry wymaga większej wiedzy o działaniu GC (tzw. generacji) i wykracza poza ramy tego artykułu.

GC.SuppressFinalize()

Metoda ta przyjmuje jako parametr obiekt. Wywołanie tej metody spowoduje, że dla danego obiektu nie będzie wywoływany destruktor podczas usuwania go. Praktycznie zastosowanie poznacie podczas omawiania IDisposable.

GC.ReRegisterForFinalize()

Metoda, która dodaje finalizer, który ma być wykonywany. Może się przydać po wcześniejszym wykonaniu SuppressFinalize().

GC.WaitForPendingFinalizers()

Jest to metoda, która może się szczególnie przydać po wywołaniu Collect. Mianowicie - wstrzymuje one bieżący wątek i czeka, aż GC wykona wszystkie destruktory jakie ma na swojej liście. Może to dać pewne możliwości w kontrolowaniu destruktorów - jednak ma jedną wadę - wstrzymuje wątek.

Zasoby zewnętrzne i IDisposable

Było powiedziane, że GC służy do czyszczenia pamięci zarządzanej oraz, że czas, w którym to czyszczenie następuje jest nieznany. Gdybyśmy mieli do czynienia z klasą, która celowo działa na zewnętrznych zasobach (np. połączenie z bazą danych, połączenie z internetem, czytanie pliku), chcielibyśmy aby zasoby te były używane jak najkrótszy okres czasu - minimalny dla prawidłowego działania. Zdefiniowanie destruktora nam tego nie zapewni - jako, że obiekt może istnieć dłużej niż oczekiwaliśmy.

.NET zdefiniował ten problem w dość sprytny sposób. Przyjęło się konwencje, że każda klasa, która działa na zewnętrznych zasobach oraz musi te zasoby jawnie zwalniać - powinna dziedziczyć po interfejsie IDisposable[#]_.

.. [#] Konwencja ta nie jest obowiązkiem. Równie dobrze klasa może działać bez IDisposable i nie będzie błędu podczas Design Time. Jednak każdy powinien użyć IDisposable jeśli klasa ma jawnie zwalniać pewne zasoby.

public interface IDisposable
{
    void Dispose();
}

Jak widać interfejs jest bardzo prosty - jedna, bezparametrowa metoda o nazwie Dispose. Interfejs ten został nieco wbudowany w język C#, mianowicie pozwala zastosować klauzulę using(). Przykład:

using System;

namespace GC {

    class TestClass: IDisposable {
        int a=5;

        public void Dispose() {
            Console.WriteLine("Disposing");
        }

    }

    class Program {
        static void Main(string[] args) {
            using(TestClass a =new TestClass()) {
                // do some work
            }

        }

    }
}

Jak widać klasa TestClass dziedziczy po IDisposable i w ramach Dispose nie robi nic innego jak wypisanie tekstu do konsoli. W rzeczywistym przypadku zawartość Dispose odpowiadała by za zwolnienie zewnętrznych zasobów.

Bardziej interesujący fragment kodu znajduje się w metodzie Main klasy Program. Użyto tu instrukcji using, właśnie dzięki IDisposable. Instrukcja using gwarantuje nam, kod w Dispose() obiektu wykona się zawsze - nawet w przypadku wyjątku wewnątrz instrukcji using. Dzieję się tak ponieważ kompilator zamienia using na stosowny kod try/finally .. - jednak to nadaje się na osobny artykuł.

A zatem IDisposable jest interfejsem, który unifikuje zwalnianie zewnętrznych zasobów - dzięku użyciu instrukcji using. Moment wykonania Dispose jest znany, nie jak w przypadku Finalize. Metoda Dispose służy jedynie do czyszczenia zewnętrznych zasobów, jakie zostały wcześniej otworzone - nie niszczy ona obiektu.

Dispose i Finalize

Te dwie metody mogą świetnie ze sobą współpracować - jeśli się wie jak ich użyć. Na przestrzeni lat programiści (w tym programiści samego .NET) wypracowali pewną regułę, którą można wykorzystywać w swoich aplikacjach zapewniając tym samym spójny system zwalniania zasobów.

Było powiedziane, że Dispose służy do zwalniania zewnętrznych zasobów. Ale patrząc w kod widać wyraźnie, że programista używając klasy, która implementuje IDisposable wcale nie musi używać tego interfejsu. Może jawnie wykonać Dispose() nie używając do tego instrukcji using. Co więcej - może nawet wcale nie zwolnić zasobów. Warto by było jednak te zasoby 'jakoś' zwolnić, gdy Dispose() nie zostało jawnie wywołane.
Tutaj z pomocą przychodzi destruktor. Gdy zapominalski programista otworzył zasoby w obiekcie, ale ich nie zwolnił (nie wywołał Dispose), te są dalej używane przez obiekt. W najczarniejszym scenariuszu programista może stracić wszystkie referencje do tego obiektu i już nie móc wykonać Dispose. W takim przypadku finalizer jest jedyną metodą, która może coś zdziałać - mianowicie - zwolnić zasoby podczas wykonywania destruktora[#]_.

.. [#] Jak już wspomniałem, jest to ostatnie miejsce, gdzie można zasoby te zwolnić. Programiści zawsze powinni używać instrukcji using gdzie tylko działa IDisposable (ewentualnie być pewni wykonania Dispose przez zestaw try / finally). Destruktor jest tylko kołem ratunkowym, gdyby programista zawiódł.

Najprostszym rozwiązaniem tego problemu jest po prostu wywołanie metody Dispose() z destruktora.

class TestClass: IDisposable {
        int a=5;

        public void Dispose() {
            Console.WriteLine("Disposing");
        }

        ~TestClass() {
            Dispose();
        }

    }

Na pierwszy rzut oka powyższy kod wydaje się mieć sens. Stosując instrukcję using - zwolnimy zasoby. Gdy coś pójdzie nie tak i Dispose się nie wykona, zrobi to za nas GC poprzez destruktor. Jednak kod ten należy bardziej rozbudować. Przede wszystkim - stosując using() metoda Dispose wykona się dwukrotnie (pierwszy raz przez samo using, drugi raz przez destruktor). Należało by zabezpieczyć jakoś klasę, aby nie wykonywać dwukrotnie tego samego.

Poniżej przedstawiam prawidłowy i zalecany sposób na zaimplementowanie IDisposable.

class TestClass: IDisposable {
        private bool _disposed;

        public TestClass() {
            // do some work
            _disposed = false;
        }

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

        protected virtual void Dispose(bool disposing) {
            if(!_disposed) {
                if(disposing) {
                    Console.WriteLine("Object disposed.");
                }
                _disposed = true;
            }
        }

        ~TestClass() {
            Dispose(false);
        }

    }

Na początku wydać się to może skomplikowane - po co tak utrudniać ? Spróbuje wytłumaczyć w punktach:

  • Pole _disposed jednoznacznie określa, czy zasoby zostały już zwolnione. Jeśli tak - "dwukrotne zwolnienie" nie będzie mieć miejsca.
  • Dodatkowa metoda Dispose z parametrem disposing ujednolica nam zwalnianie zasobów. Jeśli disposing ma wartość True - wywołuje się IDisposable, jeśli False - wywołuje się destruktor.
  • Klasy pochodne mogą nadpisać Dispose aby dołączyć tam swoją logikę.
  • Wywołanie metody SuppressFinalize powoduje, że jeśli użyty został interfejs IDisposable w prawidłowy sposób - nie trzeba już uruchamiać destruktora aby zwolnić zasoby.

Może to wyglądać naiwnie ale powyższy sposób należy uznać za "jedyny słuszny".

Podsumowanie

Zachęcam do komentowania - nie jestem niestety dobry w pisaniu artów, ale mam nadzieję, że wyjaśniłem trochę podstaw działania GC .NET.

7 komentarzy

"Jest to mechanizm, który tłumaczy się na polski język jako "odśmiecacz". " ?
Bardziej bym powiedział, że Garbage Collector, tłumaczy się na Kolekcjoner/Zbieracz Śmieci ;)

Za C# wziąłem się stosunkowo niedawno, ale artykuł wiele wyjaśnia :)

Czy jest możliwość (zgoda autora) aby podpiąć ten artykuł pod link http://4programmers.net/C_sharp/Garbage_Collector z Kompendium Wiedzy C#?

Odp: dla Heko (on pewnie już wie, ale komuś innemu może się przyda)
private void Dispose(bool disposing)

{

// upewnij się, czy nie było już sprzątane

if (!this.disposed)

{

// jeżeli disposing = true posprzątaj wszystkie zarządzane zasoby

if (disposing)

{

// sprzataj zasoby zarządzane

}

// posprzastaj nie zarządzane zasoby

}

disposed = true;

}

Metoda Finalize() nie powinna usuwać żadnych obiektów zarządzanych, natomiast metoda Dispose() przeciwnie.

A w którym miejscu protected virtual void Dispose(bool disposing) zwalnia się zasoby, bo jeśli w tym miejscu: if(disposing) { Console.WriteLine("Object disposed."); }, to na moje oko w przypadku destruktora zasoby nie zostaną zwolnione.

Bardzo dobry artykuł! :)

Świetny artykuł, od razu wiele rzeczy się wyjaśniło :)