Czy w Pythonie może wyciekać pamięć?

Niedawno ktoś zadał mi pytanie: „czy w Pythonie może w ogóle wystąpić memory leak?„. Postanowiłem nieco przyjrzeć się temu tematowi i sprawdzić jak dużo pułapek dotyczących zużycia pamięci czeka na nas w tym języku. Wiele osób jest przekonanych, że wbudowane w Pythona mechanizmy są w stanie zawsze ustrzec programistę przed wyciekaniem pamięci. Czy jest tak w rzeczywistości? Myślę, że najlepszą odpowiedzią jest „to zależy”. Zależy chociażby od tego w jaki sposób zdefiniujemy memory leak. Ale zacznijmy od początku…

Automatyczne odśmiecanie pamięci

Python jest językiem, w którym zarządzanie pamięcią odbywa się w sposób automatyczny. Programista nie musi troszczyć się o jej alokację i dealokację, gdyż za tę drugą odpowiada garbage collector (GC), działający w oparciu o metodę zliczania referencji1. Jej idea jest prosta – każdy obiekt posiada licznik, wskazujący liczbę aktualnie istniejących odwołań do tego obiektu. Kiedy jego wartość spadnie do zera, obiekt jest usuwany.  Spójrzmy na poniższy fragment kodu, gdzie w komentarzach umieściłem wartości licznika referencji dla stworzonej instancji klasy A:

W linii 8 widać, że do wartości licznika można nawet bezpośrednio „dobrać się”, używając funkcji z modułu sys. Wydrukowaną w tym miejscu liczbą będzie ponieważ przekazując jako argument funkcji tworzona jest dodatkowa (tymczasowa) referencja do niego (którą jest parametr funkcji). Skoro zatem, jak udowadnia powyższy przykład, Python śledzi użycia wszystkich obiektów, to chyba jesteśmy całkowicie bezpieczni? Nie da się przecież doprowadzić w żaden sposób do klasycznego wycieku pamięci, który z kolei łatwo wywołać np. w języku C++:

W ostatniej linijce bezpowrotnie tracimy dostęp do zaalokowanej wcześniej pamięci zawierającej liczbę 100. Miejsce, które powinniśmy manualnie zwolnić przy pomocy delete pozostanie zarezerwowane, a w miarę działania takiego programu zużywana przez niego pamięć operacyjna będzie rosnąć. Można sprawdzić to, np. umieszczając ten krótki kod w nieskończonej pętli – gwarantuje że już po chwili system będzie musiał podjąć jakieś nadzwyczajne działania wobec tak pamięciożernego programu. W tym momencie możemy przejść do pierwszego spostrzeżenia – w programie napisanym w Pythonie może dojść do wycieku pamięci jeśli błędy zakradną się do kodu napisanego w C lub C++, z którego on korzysta, np. w postaci dodatkowych bibliotek. Memory leak jest też możliwy w przypadku nieprawidłowego posługiwania się takimi bibliotekami, więc podejmując interakcję z kodem narażonym na wycieki pamięci należy mieć to na uwadze i zachować ostrożność.

Problem cyklicznych referencji

Chociaż odśmiecanie pamięci za pomocą zliczania referencji ma swoje plusy, posiada również wady. Jedną z nich jest brak obsługi cyklicznych referencji. Jeśli dwa obiekty zawierają wzajemne odwołania do siebie, to mimo iż nigdzie indziej nie są używane, metoda oparta o liczniki referencji nie poradzi sobie z ich dealokacją. Na szczęście nie oznacza to, że w Pythonie problemem są zwykłe cykliczne referencje. Takie obiekty też podlegają odśmiecaniu, ale używany jest do tego inny algorytm, znany pod nazwą mark-and-sweep. Co określony czas uruchamiana jest funkcja, której celem jest wykrywanie cyklicznych referencji i ich usuwanie dla obiektów, które nie są osiągalne.

Niestety sprawa nieco komplikuje się jeśli postanowimy zaimplementować metodę __del__ dla obiektów, które biorą udział w cyklicznych referencjach. Taką sytuację przedstawia poniższy kod (ale ostrzegam przed jego uruchamianiem!):

Okazuje się, że jest to przypadek, w którym obiekty nie zostaną nigdy odśmiecone, a co za tym idzie, zużycie pamięci momentalnie urośnie do maksymalnych rozmiarów. Wspomina o tym m.in. dokumentacja metody gc.garbage. Garbage collector nie wie w jakiej kolejności miałby usuwać wzajemnie powiązane ze sobą obiekty, więc w ogóle tego nie robi. Istnieją trzy główne sposoby radzenia sobie z tym problemem:

  • unikać implementowania metody __del__
  • używać słabych referencji
  • korzystać z Pythona w wersji minimum 3.4 (zachowanie to zostało bowiem poprawione – link)

O ile pierwsza i ostatnia metoda nie wymagają większego omówienia, o tyle posługiwanie się słabymi referencjami (ang. weak references) nie dla wszystkich może być oczywiste. Ich koncepcja polega na tym, że nie zwiększają one licznika referencji obiektu, do którego się odnoszą. Jeśli więc w naszym przykładzie listę dzieci danego węzła uczynimy listą słabych referencji, to po opuszczeniu swojego zakresu obiekty first_child oraz second_child będą mogły zostać normalnie odśmiecone, ponieważ ich licznik referencji będzie wynosił okrągłe zero. Innymi słowy – zlikwidowaliśmy cykl referencji:

Warto jeszcze dodać, że weakref.ref przyjmuje jako opcjonalny argument callback, który zostanie wywołany gdy niszczony będzie obiekt wskazywany przez referencję.

Uwaga na listy

Jeśli zużycie pamięci operacyjnej przez nasz program rośnie nie wiadomo dlaczego i wbrew naszym intencjom, to całkiem możliwe, że gdzieś w kodzie nieodpowiednio posługujemy się listami lub słownikami. Na przykład zamiast umieścić listę jakichś obiektów jako zwykłego membera klasy czynimy ją zmienną o zasięgu klasowym (statycznym) bądź globalnym. Cokolwiek będziemy wrzucać do takiej listy, pozostanie w niej przez cały czas działania aplikacji, a przecież naszym zamierzeniem było skojarzyć ją z istniejącymi instancjami.

Inny, mniej oczywisty błąd, to używanie mutowalnych argumentów domyślnych. Załóżmy, że chcemy, aby nasza funkcja append dodawała element do listy tylko wtedy, gdy spełnia on zadany warunek. W przypadku gdy nie zostanie podana lista, chcemy aby element został dołączony do pustej listy.

Skoro coś tu nie gra, to zapewne domyślacie się (lub wiecie), że na ekran zostanie wypisana lista [20, 21] zamiast [21]. Dzieje się tak dlatego, ponieważ w Pythonie argumenty domyślne ewaluowane są podczas tworzenia funkcji, a nie podczas ich wywoływania. Ta druga taktyka została zaadaptowana np. w Rubym, w którym bliźniaczy kod wyświetli jako rezultat listę jednoelementową:

Na szczęście niektóre IDE (na pewno PyCharm, z którego osobiście korzystam) zwracają uwagę na użycie w kodzie mutowalnych argumentów domyślnych, więc zapobieganie tego typu błędom nie jest szczególnie trudne, jeśli na bieżąco eliminuje się okazje do nich.

Podsumowanie

Programując w Pythonie jesteśmy nieporównywalnie bardziej bezpieczni niż pisząc w językach bez automatycznego zarządzania pamięcią, chociaż i tu istnieje pewien zbiór przypadków, w których da się spowodować coś, co skutkami przypomina memory leak. Najniebezpieczniejszym z nich (a zarazem takim, który technicznie można nazwać poprawnie wyciekiem pamięci) wydaje mi się sytuacja dotycząca zaimplementowanego finalizera dla obiektów wewnątrz cyklu referencji, dlatego cieszy fakt, iż zostało to naprawione w nowszych wersjach Pythona. Oczywiście niniejszy wpis nie wyczerpuje tematu możliwych do spotkania problemów z pamięcią, jakie występują w tym języku, dlatego na koniec pozwolę sobie polecić bibliotekę objgraph, która znacznie ułatwia analizę tego, co dzieje się z referencjami w trakcie działania programu.

Przypisy

  1. Zliczanie referencji jest używane w standardowej implementacji CPython, jednak inne implementacje tego języka (takie jak np. Jython lub IronPython) mogą wykorzystywać inne techniki odśmiecania.

Linki