|

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 to int, list czy str). 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_refcnt wewną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ą.

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)

Przykładowa grafika przedstawiająca przypisywanie wartości do zmiennych w Pythonie

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
Grafika pokazuje wzajemne wskazywanie list na siebie które nie pozwala zlikwidować licznika referencji do zera

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:

  1. Przeszuka pamięć w poszukiwaniu takich zamkniętych wskaźników, do których nikt z zewnątrz nie ma dostepu
  2. 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.
Grafika pokazuje jak działa mechanizm generacji w GC

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.

Jak wygląda sam proces sprzątania?

Gdy GC rusza do pracy, wykonuje proces skanowania:

  1. Identyfikacja kontenerów: Bierze wszystkie obiekty w danym pokoleniu.
  2. 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.
  3. 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ć.
  4. 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.