Dzięki za pamięć

Strach pomyśleć, że w czasach powszechnych Garbage Collectorów czy to w .NET-cie czy innej Javie, są jeszcze programiści którzy ręcznie zarządzają pamięcią 😱. Ale nawet, mając pod ręką wspaniałego pomocnika, jakim jest GC, nie warto zapominać, jak CLR zarządza pamięcią i warto wiedzieć kilka tricków jak można optymalizować działanie programu w tej czy innej sytuacji.

Każdy junior idąc na rozmowę kwalifikacyjną wie, że w .net są dwa typy struktur – tzw reference & value types, które różnią się sposobem alokacji w pamięci – reference type alokuje się w obszarze pamięci zwanym managed heap i dodatkowo umieszcza się referencję do tego obszaru pamięci na stosie, natomiast value types alokuje się bezpośrednio na stosie. Ale to jeszcze nie wszystko, managed heap dzieli się na dwa segmenty – obiekty < 85KB trafiają do small object heap, natomiast obiekty > 85 KB do large object heap.

Jak do tej pory, to wszystko wygląda całkiem prosto, ale zarządzanie pamięcią to nie taka prosta rzecz jak się wydaje – GC dzieli obiekty w managed heap na 3 generacje:

  • Gen 0 – krótko żyjące obiekty, np. lokalne zmienne
  • Gen 1 – krótko żyjące obiekty, ale te co przeżyły czyszczenie generacji zerowej
  • Gen 2 – długo żyjące obiekty, np. statyczne zmienne cykl życia których odpowiada cyklowi życia procesu

Warto zaznaczyć kilka uwag: obiekty z LOH trafiają odrazu do Gen 2; czyszczenie generacji zerowej odbywa się często, natomiast czyszczenie wyższych generacji odbywa się nie tak często. W tym miejsce warto wspomnieć, że działanie GC może być uzależnione od tego na jakim środowisku uruchamiamy nasz proces – na desktopie czy serwerze.

Jakby tego było mało, to wspomnę tylko, że proces aplikacji .netowej korzysta z przestrzeni adresowej nie większej niż 2GB, ale to można lekko zmodyfikować podnosząc limit do 4GB na 32-bitowym systemie lub nawet 128TB na systemie 64-bitowym, ale niestety rozmiar obiektu na obu architekturach nie może przekraczać 2GB. Pamiętajmy jeszcze o ref lub modyfikatorach parametrów in i out, co w ten czy inny sposób modyfikuję działanie aplikacji tak i zarządzania pamięcią. Poza tym, sam rozmiar stosu domyślnie jest limitowany (1MB) ale na szczęście można go ustawić albo w nagłówku EXE albo bezpośrednio dla tworzonego wątku.

Wiele drobnych „ale” i zarządzanie pamięcią w tzw managed środowiskach już nie wygląda tak różowo. Ano, choć inżynierowej oprogramowania na platformie .NET nie zarządzają ręcznie pamięcią, to jednak muszą znać trochę jak sam CLR nią zarządza, gdyż znając kilka szczegół można zoptymalizować działanie oprogramowania.

Weźmy dla przykładu rozróżnienie sterty na small object heap i large object heap – jeśli jest szansa, że inicjalizowany lokalny obiekt może w jakiś sposób przekroczyć limit w 85KB oraz ta inicjalizacja odbywa się często – warto odrazu go „zoptymalizować” dorzucając kilka bajtów/megabajtów więcej. Taki z jednej strony nie zbyt optymalny kod, spowoduje że obiekt będzie odrazu wpadał do LOH zamiast SOH, a co za tym idzie – liczba zużytej pamięci przez proces spadnie oraz zyskamy na szybkości utylizacji śmieci przez GC. Dlaczego? Otóż, alokując obiekt rozmiarem, np 84KB, trafia on do Gen 0, czyszczenie Gen 0 odbywa się najczęściej – na maszynach o deskotopwej konfiguracji raz na dwie sekundy a na maszynach o konfiguracji serwerowej kilka razy na sekundę. Jeśli obiekt ten jest inicjalizowany często, np. poprzez wywołanie serwisu przez zewnętrznego klienta, to połączenie konfiguracji desktopowej i „małego” rozmiaru obiektu możemy zaobserwować jako cykliczne zwiększenie zużycia pamięci przez proces gdzie przez 2 sekundy zużycie rośnie a potem w ciągu ok. 1s zużycie spada. Jeśli uruchomić ten kod na konfiguracji serwerowej – zużycie pamięci spada i wygląda w miarę płasko, a to tylko z powodu częstego czyszczenia Gen 0 – ale nic na świecie nie jest różowe (oprócz koloru różowego), w tej konfiguracji wzrasta zużycie procesora, więc takie podejście jest dobre tylko dla systemów gdzie zużycie procesora jest niewielkie a zużycie pamięci jest duże.
Natomiast alokując ten obiekt w LOH poprzez sztuczne dorzucenie dodatkowych megabajtów do obiektu, spowoduje o wiele lepszą utylizację pamięci nawet na desktopowej konfiguracji środowiska – obiekty w Gen 2 są rzadko czyszczone, a gdy już są czyszczone to GC nie robi nic z obiektami oprócz oznaczenia obszarów pamięci jako „wolne” i tam właśnie trafiają nowe obiekty alokowane w LOH. Dla wizualizacji tego procesu graficznie można posłużyć się obrazkiem od Microsoft:

Oczywiście jest jeden duży minus Gen 2 i całego tego LOH – nie zawsze można zwolnić dużo pamięci z powrotem do systemu, gdyż akurat najbardziej z prawej strony mogą zostać te najdłużej żyjące obiekty i cały ten obszar od początku segmentu pamięci Gen 2 aż do tego obiektu pozostanie przez długi czas „wolny” gotowy do ponownego użycia przez proces, ale akurat proces już więcej nie potrzebuje tej pamięci i nie może ten obszar zdecommitować 🤷🏻‍♂️ (dla referencji – VirtualAlloc & VirtualFree).
Począwszy od .NET Framework 4.5.1, Microsoft dodała możliwość ustawienia czy GC może defragmentować (kompaktować) pamięć przeznaczoną dla LOH – https://docs.microsoft.com/en-us/dotnet/api/system.runtime.gcsettings.largeobjectheapcompactionmode?view=net-5.0, ale ja w tej chwili nie mam case kiedy to by się przydało, czyszczenie Gen 2 jest bardzo kosztowne co zazwyczaj oznacza też całkowite blokowanie wykonania procesu.

Zatem, należy zadać sobie pytanie – czy w .NET rzeczywiście programiści nie zarządzają pamięcią? 😉

Na koniec, kilka linków na temat pamięci w .net z którymi warto zapoznać się, gdyż ten wpis ma na celu tylko przybliżenie problematyki zarządzania pamięcią. Jakbym jednak zapomniał jakiś ważny szczegół – proszę mnie poprawić:
https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
https://docs.microsoft.com/en-us/aspnet/core/performance/memory?view=aspnetcore-5.0
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc
https://docs.microsoft.com/en-us/windows/win32/procthread/thread-stack-size
https://docs.microsoft.com/en-us/dotnet/api/system.weakreference?view=net-5.0