Garbage Collector w Pythonie: Wszystko, co musisz wiedzieć o zarządzaniu pamięcią.
Garbage Collection (GC) to mechanizm zarządzania pamięcią w CPythonie. Działa on dwuetapowo: pierwszy system pracuje non-stop i usuwa większość obiektów natychmiast, a drugi – “właściwy” GC – wkracza okresowo, aby posprzątać po tym pierwszym, zajmując się najbardziej złożonymi przypadkami.
Model alokacji: Baloniki, Sznurki i Haki
Aby zrozumieć, dlaczego Python w ogóle potrzebuje sprzątania, musimy spojrzeć na to, jak “wiesza” dane w pamięci. W większości języków (jak C++ czy Java) małe dane (np. proste liczby) trzyma się bezpośrednio “w ręce” (na stosie). W Pythonie wszystko – absolutnie wszystko – jest balonikiem na stercie.
1. Balonik (Obiekt na Stercie/Heap)
Kiedy piszesz x = 1000, Python nadmuchuje nowy balonik na stercie. Technicznie jest to struktura w C nazywana PyObject. Każdy taki balonik posiada “etykietę” (nagłówek), która zawiera dwie kluczowe informacje:
ob_refcnt(Licznik referencji): Liczba sznurków, które aktualnie trzymają ten balonik, żeby nie odleciał.ob_type: Wskaźnik do “formy”, z której powstał balon (np. informacja, że toint,listczystr). Dzięki temu balonik wie, jak się zachowywać.
2. Sznurek (Referencja/Pointer)
Sznurek to nie jest sama wartość. To wskaźnik (pointer), czyli adres w pamięci prowadzący do balonika.
- Kiedy robisz
y = x, nie tworzysz nowego balonika. Po prostu wiążesz drugi sznurek do tego samego obiektu. - W tym momencie
ob_refcntwewnątrz balonika wzrasta do 2.
3. Hak/Ręka (Ramka stosu/Stack Frame)
W Twoim kodzie “Ręką”, która trzyma sznurki, jest Stos Wywołań (Stack). Jednak restrykcyjnie rzecz ujmując, na stosie nie leżą same sznurki luzem, ale Ramki (PyFrameObject).
- Każda funkcja, którą uruchamiasz, tworzy nową “ramkę” (haczyk).
- To w tej ramce przechowywane są nazwy zmiennych lokalnych.
- Gdy funkcja kończy działanie, ramka zostaje zniszczona (ręka puszcza sznurki), a liczniki referencji baloników, które trzymała, spadają.
Nagłówek(etykieta) zmiennej to struktura PyObject, zawiera ona dwie kluczowe rzeczy: ob_refcnt (licznik referencji) oraz ob_type (wskaźnik na typ obiektu)
Sprzątanie – Jak działa mechanizm czyszczenia pamięci na przykładzie?
Żeby wszystko było było wydajne i wartości nie były pominięte, za czyszczenie pamięci odpowiadaja dwa systemy – jeden działa non-stop, a drugi (właściwy GC) przychodzi falami.
Mechanizm pierwszy – Reference counting
To główny zarządca pamięci. Każdy obiekt wie, ile “sznurków” do niego prowadzi.
x = 10 # Licznik obiektu '10' rośnie
y = x # Licznik rośnie dalej
del x # Licznik maleje
Gdy licznik spadnie do zera, Python natychmiast przebija balonik i zwalnia pamięć. Jest to szybkie i przewidywalne. (Chociaż w tym przykładzie tak nie będzie, ze względu na interning, ale to przy okazji głębszego wejścia w temat alokacji pamięci)
Interning to mechanizm, w którym Python optymalizuje pamięć, przechowując tylko jedną kopię małych liczb całkowitych lub krótkich napisów. Dla małych liczb (-5 do 256) licznik nigdy nie wyniesie 0, ponieważ Python trzyma je w pamięci na stałe (pre-alokacja), by nie tworzyć ich w kółko.
I teraz w przypadku:
- Zadeklarowania zmiennej wewnątrz funkcji, żyją one tylko do momentu jej zakończenia.
- Nadpisania zmiennej, poprzedni obiekt straci połączenie
- Jawnie usuniesz referencję przy pomocy funkcji del
Licznik jest dekrementowany przy usuwaniu (del) lub wyjściu z zasięgu, aż nie wyniesie zero i obiekt zadeklarowany w pamięci nie zostanie z niej usunięty.
Skoro licznik referencji sam usuwa obiekty, to po co nam dodatkowy GC?
Garbage collector – kiedy referencje nie wystarczą
Reference Counting ma jedną wadę: cykliczne referencje. Jeśli dwie listy wskazują na siebie nawzajem, a my odetniemy do nich dostęp z zewnątrz, ich liczniki nigdy nie spadną do zera (wyniosą 1). Pamięć zostaje “wycieknięta”.
Tu wkracza właściwy Garbage Collector. Ignoruje on typy proste (atomy), a skupia się na kontenerach (listy, słowniki, klasy), bo tylko one mogą tworzyć cykle.
#Stworzymy sobie teraz dwie listy i przypiszemy jedną do drugiej
a = []
b = []
a.append(b) # a wskazuje na b
b.append(a) # b wskazuje na a
del a
del b
Po usunięciu list nie masz już do nich dostępu, ale one (i tylko one) nadal na siebie nawzajem wskazują – Ich licznik referencji wynosi 1, więc nasz Reference Counting ich nie usunie.
Wtedy właśnie wchodzi Garbage Collector:
- Przeszuka pamięć w poszukiwaniu takich zamkniętych wskaźników, do których nikt z zewnątrz nie ma dostepu
- Rozpoznaje, że te obiekty nie mają użyteczności i wymusza ich usunięcie.
Brzmi prosto? wejdźmy trochę w to głębiej
Hipoteza Pokoleniowa w Pythonie
Python zakłada, że “większość obiektów umiera młodo”. Jednocześnie obiekty które przetrwały długo (I mają przetrwać długo), np. zmienne środowiskowe, globalne słowniki – zostaną z nami do końca działania programu. Dlatego dzieli obiekty na trzy pokolenia:
Trzy Pokolenia
- Pokolenie 0: Tu trafiają wszystkie nowo utworzone obiekty. Jest to najmniejsza grupa i najczęściej sprawdzana przez Pythona.
- Pokolenie 1: Jeśli obiekt z Pokolenia 0 przetrwał proces sprzątania (nadal jest używany), dostaje awans do pokolenia 1.
- Pokolenie 2: Jeśli obiekt przetrwa sprzątanie w Pokoleniu 1, dostaje ten przydział. Python zagląda tutaj bardzo rzadko, żeby zaoszczędzić kosztownych procesów na dużej liczbie danych.
Kiedy następuje zatem sprzątanie?
Python nie sprząta w losowych momentach, jest to robione na podstawie liczników alokacji. Sprawdzmy to na kodzie
import gc
print(gc.get_threshold())
#(700, 10, 10)
Co oznaczają te liczby?
- Próg 0 (Liczba 700): GC uruchomi się dla Pokolenia 0, gdy liczba alokacji minus liczba usuniętych obiektów (tych, które zniknęły przez Reference Counting) przekroczy 700.
- Próg 1 i 2: To liczniki “ile razy sprzątaliśmy niższe pokolenie”. Jeśli pokolenie 0 zostało posprzątane 10 razy, Python decyduje się na skanowanie pokolenia 1 itd.
Tworzenie dużej ilości małych obiektów może mieć wpływ wydajnościowy związany ze skanowaniem Garbage Collectora.
Jak wygląda sam proces sprzątania?
Gdy GC rusza do pracy, wykonuje proces skanowania:
- Identyfikacja kontenerów: Bierze wszystkie obiekty w danym pokoleniu.
- Symulacja (Odejmowanie): Tworzy tymczasową kopię liczników referencji i dla każdego obiektu odejmuje wszystkie referencje, które pochodzą od innych obiektów w tym samym pokoleniu.
- Weryfikacja: Jeśli po tym “wirtualnym odjęciu” obiekt nadal ma referencję > 0, oznacza to, że coś spoza tego pokolenia (np. zmienna globalna lub obiekt z pokolenia 2) wciąż go używa. Taki obiekt (i wszystko, na co on wskazuje) musi zostać.
- Egzekucja: Obiekty, do których nie da się “dojść” z zewnątrz, zostają usunięte jako cykle.
Zjawisko “Stop-the-world”
Warto mieć na uwadze, że mechanizm GC nie jest darmowy pod kątem wydajności. W momencie gdy licznik alokacji przekroczy zdefiniowany próg i następuje skanowanie (w szczególności podczas skanowania Pokolenia 2, Python inicjuje tzw. faze “Stop-the-world”. Oznacza to że wykonywanie Twojego kodu zostaje na ułamek sekundy wstrzymane, aby GC mógł bezpieczenie przeanalizować relacje między obiekatmi bez ryzyka, że w trakcie skanowania ich stan się zmieni. Choć przy małych aplikacjach jest to niezauważalne, w suystemach przetwarzających miliony obiektów w czasie rzeczywistym, zbyt częst wzbudzanie się GC może prowadzić do mikro-przycięć (latency spikes). Właśnie dlatego optymalizacja polegająca na tworzeniu zbędnych, krótkotrwałych obiektów w krytycznych pętlach jest tak istotna.
Podsumowanie
Czyszczenia pamięci w Pythonie to współpraca dwóch mechanizmów dbających o to żeby nie doszło do sytuacji “zapchania się” pamięci danymi. A są nimi:
- Reference Counting – Działa natychmiastowo i usuwa obiekty, gdy tylko przestaną być potrzebne. Charakteryzuje się szybkością i niezdolnością do wykrywania cykli w złożonych strukturach danych.
- Generational GC – Wkracza falami, rozbija skomplikowane cykle referencyjne których mechanizm licznika referencji nie potrafi obsłużyć.
Dzięki takiemu połączeniu mamy wydajny system, python ufa, że “obiekty umierają młodo” – dlatego najczęściej sprząta najświeższe dane, oszczędzając zasoby na obiekty z kategorii długowiecznych. Zrozumienie tych mechanizmów pozwoli na pisanie wydajnego kodu, zwłaszcza przy zwiększonej skali projektu.