Garbage Collector, cz. IV (wycieki pamięci)  

Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2113-06-20

Wprowadzenie

Poprzednie artykuły dotyczyły zasady działania metody Garbage Collector. W czwartej części omówione zostaną najczęściej popełniane błędy, które skutkują wyciekiem pamięci. Postaram się pokazać, że regularne profilowanie kodu jest niezbędne, ponieważ o wyciek pamięci naprawdę nie jest trudno.

Usuwanie zdarzeń

Najczęściej popełnianym błędem związanym z zarządzaniem pamięcią jest niedopilnowanie usunięcia zdarzeń. Oczywiście, nie zawsze należy to zrobić, ale trzeba umieć rozpoznawać przypadki, w których ta czynność jest niezbędna. Rozważmy następujący kod:

class Person
{
    public event EventHandler FirstNameChanged;
}
class ViewModel
{
    private Person _person;
    public void CreatePerson()
    {            
        _person=new Person();
        _person.FirstNameChanged += FirstNameChanged;
    }
    public void RemovePerson()
    {
        _person = null;
    }
    private void FirstNameChanged(object sender, EventArgs e)
    {
        // ...
    }
}

Powyższy kod powstał bardzo sztucznie, ale reprezentuje często popełniany błąd. Implementując jakieś zdarzenie, wskaźnik przypisywany jest tak naprawdę do obiektu, w którym znajduje się dana metoda. Zdarzenie FirstNameChanged wskazuje na metodę FirstNameChanged, która z kolei zlokalizowana jest w obiekcie ViewModel. Innymi słowy, Person zawiera niejawną referencję do ViewModel. Zatem, przed usunięciem referencji należy usunąć również wskaźnik na zdarzenie:

_person.FirstNameChanged -= FirstNameChanged;
_person = null;

Czynności tej nie można wykonać później, ponieważ będzie brakowało referencji na Person. Jednak, nie zawsze potrzebny jest taki zabieg. Rozważmy:

class Person
{
    public event EventHandler FirstNameChanged;
    public void DoSomething()
    {
        FirstNameChanged += new EventHandler(Person_FirstNameChanged);
    }
    void Person_FirstNameChanged(object sender, EventArgs e)
    {            
    }
}
class ViewModel
{        
    public ViewModel()
    {
        Person person=new Person();
        person.DoSomething();
        person = null;
    }
}

W takim przypadku nie ma potrzeby usuwania zdarzenia, ponieważ FirstNameChanged wskazuje na Person. W momencie usunięcia Person z pamięci, zdarzenie nie będzie wskazywało na zewnętrzne obiekty.

Powyższe rozwiązanie nie zawsze można zastosować. Czasami nie wiadomo lub nie ma się nad tym kontroli, kiedy obiekt jest zerowany. W takim przypadku można skorzystać z tzw. Weak Event Pattern. Tematyka tego wzorca wykracza poza ten artykuł, aczkolwiek chciałbym chociaż naszkicować rozwiązanie.

W celu zaimplementowania wzorca Weak Event Pattern można skorzystać z klasy WeakReference, dostępnej w .NET Framework. Służy ona do utworzenia tzw. słabych referencji. Z kolei, silne referencje to standardowy sposób przechowywania obiektów, np.:

SampleClass class = new SampleClass();

GC zaznacza obiekt jako niepotrzebny, gdy wszystkie silne referencje zostaną usunięte. Nie bierze pod uwagę słabych referencji. Z tego względu możliwe jest posiadanie weak reference do obiektu, który jest uznawany przez GC jako niepotrzebny i może zostać w każdej chwili usunięty.

Przykład definiowania słabej referencji do obiektu SampleClass:

WeakReference reference=new WeakReference(new SampleClass("text"));            
var sampleClass = (SampleClass) reference.Target;
if(sampleClass==null)
 Console.WriteLine("Obiekt został usuniety przez GC");
else
 Console.WriteLine("Obiekt wciąż istnieje:{0}",sampleClass.Text);

WeakReference pozwala zatem posiadać referencje do obiektu, który w każdej chwili może zostać zwolniony przez GC ponieważ jest to słaba referencja a tylko silna zapobiega kolekcji. Jak może to pomóc w przypadku zdarzeń?

Zamiast wiązać zdarzenia w sposób silny, można je przechowywać w sposób „słaby”. W takim przypadku, gdy wszystkie silne referencje zostaną usunięte, to po jakimś czasie zdarzenie i tak zostanie usunięte z pamięci, i nie trzeba usuwać go manualnie. Sztuka polega na tym, aby w handlerze odwoływać się do „target”, czyli klasy, która zawiera metodę obsługującą zdarzenie, w sposób „słaby”. Oczywiście, pierwszym rozwiązaniem powinno być usunięcie zasobów, w momencie gdy są już niepotrzebne – to jest najbardziej elegancka praktyka.

W przypadku WPF, dostarczono gotową implementację Weak Event Pattern w postaci klasy WeakEventManager. Więcej informacji na MSDN.

Wyrażenia lambda

Wyrażenia lambda są łatwe w użyciu, ale jak to bywa z takimi ułatwieniami również nieświadomie można spowodować wyciek pamięci. Przykład:

class SampleClass
{
}
class Factory
{
    private Type _type = typeof (SampleClass);

    public Func<SampleClass> Create()
    {
        return () => (SampleClass)Activator.CreateInstance(_type);
    }
}

internal class Program
{
   private static void Main(string[] args)
   {
       Task task=Task.Factory.StartNew(Run);
       task.Wait();
   }
   private static Func<SampleClass> Create()
   {
       Factory factory=new Factory();
       return factory.Create();
   }
   private static void Run()
   {
       Func<SampleClass> factory = Create();
       while (true)
       {
           // some logic
       }
   }
}

Powyższy kod spowoduje wyciek z pamięci. Dlaczego? Wyrażenie lambda potrzebuje dostępu do pola klasy Factory, co skutkuje przetrzymywaniem całego obiektu Factory w pamięci. Może być to trochę mylące, bo referencja Factory, po wyjściu z metody Program:Create, znajduje się poza zakresem (scope).

Aby w pełni to zrozumieć, warto przeanalizować z dekompilatorem Reflector zasadę działania lambda oraz metod anonimowych. Spróbujmy odczytać w nim poniższy kod:

class Factory
{
    private Type _type = typeof (SampleClass);

    public Func<SampleClass> Create()
    {
        return () => (SampleClass)Activator.CreateInstance(_type);
    }
}

CLR musi jakoś przedłużyć obiektowi _type czas życia, który zostałby zwolniony, gdyby nie miał referencji do Factory. Zaglądając do wygenerowanego kodu, można dowiedzieć się, że została stworzona nowa metoda w klasie Factory. Podstawowa implementacja Create wygląda następująco:

.method public hidebysig instance class [mscorlib]System.Func`1<class SampleClass> Create() cil managed
{
    .maxstack 3
    .locals init (
        [0] class [mscorlib]System.Func`1<class SampleClass> CS$1$0000)
    L_0000: nop
    L_0001: ldarg.0
    L_0002: ldftn instance class SampleClass Factory::<Create>b__0()
    L_0008: newobj instance void [mscorlib]System.Func`1<class SampleClass>::.ctor(object, native int)
    L_000d: stloc.0
    L_000e: br.s L_0010
    L_0010: ldloc.0
    L_0011: ret
}

Jak widać, zamiast lambdy zwracana jest nowo wygenerowana metoda o nazwie <Create>b__0():

.method private hidebysig instance class SampleClass <Create>b__0() cil managed
{
    .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
    .maxstack 1
    .locals init (
        [0] class SampleClass CS$1$0000)
    L_0000: ldarg.0
    L_0001: ldfld class [mscorlib]System.Type Factory::_type
    L_0006: call object [mscorlib]System.Activator::CreateInstance(class [mscorlib]System.Type)
    L_000b: castclass SampleClass
    L_0010: stloc.0
    L_0011: br.s L_0013
    L_0013: ldloc.0
    L_0014: ret
}

Powyższe przykłady wyjaśniają wyciek pamięci, przedstawiony na początku sekcji. W praktyce tworzona jest nowa metoda, po czym zwracany jest do niej wskaźnik. Dzieje się tak, ponieważ lambda potrzebuje dostępu do prywatnego pola. Oczywiście, nowo utworzona metoda będzie taki dostęp posiadała.

Zmienne lokalne – przykład Timer’a

Kolejny przykład pokaże ponownie jak łatwo popełnić błąd w kodzie, nie znając dokładnie zasad działania GC. Kod:

internal class Program
{
   public static void Main()
   {
       var timer = new Timer(TimerCallback, null, 0, 1000);
       Console.ReadLine();
   }
   private static void TimerCallback(Object o)
   {
       Console.WriteLine("Callback: " + DateTime.Now);
   }
}

Powyższy konstruktor uruchamia timer i można byłoby spodziewać się, że na ekranie będą wyświetlane kolejne callbacki. W praktyce dokonywana jest jednak pewna optymalizacja, która ma fatalne efekty. Można zauważyć, że zmienna timer nie jest nigdzie tak naprawdę wykorzystywana. Z tego względu, (po optymalizacji w release) powyższy kod można zapisać również jako:

internal class Program
{
   public static void Main()
   {
       new Timer(TimerCallback, null, 0, 1000);
       Console.ReadLine();
   }
   private static void TimerCallback(Object o)
   {
       Console.WriteLine("Callback: " + DateTime.Now);
   }
}

Jakie to przyniesie skutki? GC może zwolnić pamięć przeznaczoną na timera, ponieważ nie ma do niej referencji. W wyniku czego, TimerCallback nie zostanie wywołany. Można to zaobserwować, uruchamiając poniższy kod w release:

internal class Program
{
   public static void Main()
   {
       var timer = new Timer(TimerCallback, null, 0, 1000);
       Console.ReadLine();
   }
   private static void TimerCallback(Object o)
   {
       Console.WriteLine("Callback: " + DateTime.Now);
       GC.Collect();
   }
}

W trybie debug co sekundę będzie wyświetlany komunikat. W trybie release, ze względu na opisane wyżej optymalizacje, TimerCallback zostanie wyświetlony tylko raz. W jaki sposób napisać powyższy kod, który działa dobrze nawet w trybie release?

Można oczywiście zadeklarować zmienną jako pole klasy:

internal class Program
{
   private static Timer _timer;

   public static void Main()
   {
       _timer = new Timer(TimerCallback, null, 0, 1000);
       Console.ReadLine();
   }

   private static void TimerCallback(Object o)
   {
       Console.WriteLine("Callback: " + DateTime.Now);
       GC.Collect();
   }
}

Dużo lepiej w tym przypadku jest jednak wywołać Dispose po ReadLine:

internal class Program
{
   public static void Main()
   {
       var timer = new Timer(TimerCallback, null, 0, 1000);
       Console.ReadLine();
       timer.Dispose();
   }
   private static void TimerCallback(Object o)
   {
       Console.WriteLine("Callback: " + DateTime.Now);
       GC.Collect();
   }
}

Optymalizacja nie zostanie przeprowadzona w tym przypadku, ponieważ zmienna jest wykorzystywana. Analogiczne rozwiązanie to:

internal class Program
{
   public static void Main()
   {
       using(new Timer(TimerCallback, null, 0, 1000))
       {
           Console.ReadLine();
       }
   }
   private static void TimerCallback(Object o)
   {
       Console.WriteLine("Callback: " + DateTime.Now);
       GC.Collect();
   }
}

GC może zwolnić obiekt, nawet jeśli wykonuje jego metodę. Trzeba zdać sobie sprawę, że obiekt może zostać “zebrany” w momencie, gdy jest niepotrzebny. GC i JIT współpracują ze sobą w trakcie wykonywania metody, w dowolnym momencie może zostać zmieniony stos, usuwając jakąś nieużywaną referencję. Na przykład:

class SampleClass
{
    ~SampleClass()
   {
       Console.WriteLine("Obiekt jest usuwany.");
   }
   public void Print()
   {
       GC.Collect();
       GC.WaitForFullGCComplete();
       GC.WaitForPendingFinalizers();
       Console.WriteLine("Test");
   }
}
internal class Program
{
   public static void Main()
   {
       SampleClass sampleClass=new SampleClass();
       sampleClass.Print();
   }
}

Większość osób spodziewa się, że na wyjściu najpierw pojawi się “Test”, a dopiero potem “Obiekt jest usuwany”. Proszę jednak zauważyć, że wskaźnik this w metodzie Print nie jest nigdzie używany. SampleClass może zostać zwolniony już przed linią Console.WriteLine(“test”). Wywołanie metody Print jest ostatnim momentem, w którym wymagany jest dostęp do SampleClass. Jednakże, gdyby Print używał this w Console.WriteLine, wówczas na wyjściu pokaże się najpierw “Test”, a potem dopiero “Obiekt jest usuwany”:

class SampleClass
{
    ~SampleClass()
   {
       Console.WriteLine("Obiekt jest usuwany.");
   }
   public void Print()
   {
       GC.Collect();
       GC.WaitForFullGCComplete();
       GC.WaitForPendingFinalizers();
       Console.WriteLine("Test {0}",GetType());
   }
}
internal class Program
{
   public static void Main()
   {
       SampleClass sampleClass=new SampleClass();
       sampleClass.Print();
   }
}

Zaskakujące! Może wydawać się to dziwne, ale jeszcze raz powtórzę: stos może być zmieniany w każdym momencie dzięki dokonywanym optymalizacjom. Jeśli tylko dany obiekt jest nieużywany, może on zostać usunięty, nawet wówczas, gdy w danym momencie wywoływana jest na nim metoda!

Praca z obiektami COM

Co prawda, obiekty niezarządzane niewiele mają wspólnego z GC, ale w tym cyklu artykułów ta tematyka była już niejednokrotnie poruszana.

Praca z obiektami COM może być trudna i czasami frustrująca. Przykład:

Worksheet sheet = excelApp.Worksheets.Open(...);
// Jakaś logika. Odczytywanie lub modyfikacja arkusza itp.
Marshal.ReleaseComObject(sheet);
Marshal.ReleaseComObject(excelApp);

O obiektach COM należy pamiętać również po zakończeniu pracy z nimi – należy zwolnić wszelkie zasoby. Nie zawsze jest to proste i oczywiste. Powyższy kod spowoduje wyciek, ponieważ Worksheets również musi zostać zwolniony. Metoda Open wywoływana jest na obiekcie Worksheets, który został zapomniany w powyższym fragmencie kodu.

Zasada pracy z obiektami COM jest prosta i mówi, aby unikać podwójnych kropek, czyli odwoływania się do dwóch różnych obiektów. Po refaktoryzacji kod powinien wyglądać następująco:

Worksheets sheets = excelApp.Worksheets;
Worksheet sheet = sheets.Open(...);
...
Marshal.ReleaseComObject(sheet);
Marshal.ReleaseComObject(sheets);
Marshal.ReleaseComObject(excelApp);

Jak widać, nie ma w żadnej linii podwójnych odwołań. Dzięki temu łatwo zauważyć, które obiekty powinny zostać zwolnione. Warto przestrzegać tej zasady, ponieważ późniejsza analiza problemu może być bardzo trudna.

Prawidłowe zwolnienie DispatcherTimer

Rozważmy następujący kod:

class TimePresenterViewModel:BaseViewModel
{
   private readonly DispatcherTimer _timer;
   const int RefreshTime=6*1000;

   public TimePresenterViewModel()
   {
       _timer=new DispatcherTimer();
       _timer.Interval = TimeSpan.FromMilliseconds(RefreshTime);
       _timer.Tick += TimerTick;
       _timer.Start();
   }
   void TimerTick(object sender, EventArgs e)
   {
       // jakas logika np.:
       OnPropertyChanged("CurrentTime");
   }
   public string CurrentTime
   {
       get { return DateTime.Now.ToString(); }
   }
}

Następnie, gdy np. użytkownik naciska przycisk, obiekt ViewModel jest usuwany:

public partial class MainWindow : Window
{
   private TimePresenterViewModel _timePresenterViewModel;
   public MainWindow()
   {
       InitializeComponent();
       _timePresenterViewModel=new TimePresenterViewModel();
   }
   private void button_Click(object sender, RoutedEventArgs e)
   {
       _timePresenterViewModel = null;
   }
}

Wszystko wydaje się w porządku. Skoro instancja TimerPresenterViewModel ustawiana jest na NULL, to GC powinien zebrać wszystkie obiekty – nie ma nigdzie referencji do timera.

Niestety, w praktyce DispatcherTimer spowoduje wyciek pamięci. Zajrzyjmy do profilera:

Nie wystarczy usunąć wszystkie referencje. Łatwo w kodzie o taki błąd, ponieważ żadna z referencji nie wskazuje na timera, a mimo wszystko ma miejsce wyciek pamięci. Spowodowane jest to wewnętrzną budową timera (być może bug).

Rozwiązaniem jest ręczne zatrzymywanie timera przed wyzerowaniem wszystkich referencji:

class TimePresenterViewModel
{
   private DispatcherTimer _timer;
   const int RefreshTime=6*1000;

   public void Init()
   {
       if(_timer!=null)
           return;

       _timer = new DispatcherTimer();
       _timer.Interval = TimeSpan.FromMilliseconds(RefreshTime);
       _timer.Tick += TimerTick;
       _timer.Start();
   }
   void TimerTick(object sender, EventArgs e)
   {
         // jakas logika np.:
       OnPropertyChanged("CurrentTime")
   }
   public void Release()
   {
       if(_timer!=null)
       {
           _timer.Stop();
           _timer-=TimerTick;
           _timer=null;
       }
   }
   public string CurrentTime
   {
       get { return DateTime.Now.ToString(); }
   }
}

Sposób użycia:

public partial class MainWindow : Window
{
   private TimePresenterViewModel _timePresenterViewModel;
   public MainWindow()
   {
       InitializeComponent();
       _timePresenterViewModel=new TimePresenterViewModel();
       _timePresenterViewModel.Init();
   }

   private void button_Click(object sender, RoutedEventArgs e)
   {
       _timePresenterViewModel.Release();
       _timePresenterViewModel = null;
   }
}

Zatem, należy pilnować DispatcherTimera, aby nie okazało się, że referencja jest wyzerowana bez wywołania metody Stop – wtedy nie będzie możliwości prawidłowego zwolnienia zasobów.

Zakończenie

Powyższe przykłady pokazują jeszcze jeden istotny fakt. Nawet, jeśli pisany kod jest idealny i wolny od wycieków pamięci, to bardzo prawdopodobne jest, że biblioteki, z których korzystamy, mają błędy. Osobiście pracuję sporo z WPF. Podczas profilowania okazuje się, że pojawia się wiele problemów, np. z DataBinding czy usuwaniem kontekstu danych. Współczesne systemy są na tyle skomplikowane, że profilowanie jest niezbędne i nie ma innego sposobu na opanowanie wszystkich możliwych zależności.

 


          

Piotr Zieliński

Absolwent informatyki o specjalizacji inżynieria oprogramowania Uniwersytetu Zielonogórskiego. Posiada szereg certyfikatów z technologii Microsoft (MCP, MCTS, MCPD). W 2011 roku wyróżniony nagrodą MVP w kategorii Visual C#. Aktualnie pracuje w General Electric pisząc oprogramowanie wykorzystywane w monitorowaniu transformatorów . Platformę .NET zna od wersji 1.1 – wcześniej wykorzystywał głównie MFC oraz C++ Builder. Interesuje się wieloma technologiami m.in. ASP.NET MVC, WPF, PRISM, WCF, WCF Data Services, WWF, Azure, Silverlight, WCF RIA Services, XNA, Entity Framework, nHibernate. Oprócz czystych technologii zajmuje się również wzorcami projektowymi, bezpieczeństwem aplikacji webowych i testowaniem oprogramowania od strony programisty. W wolnych chwilach prowadzi blog o .NET i tzw. patterns & practices.