Python Memory Management: The Essential Guide

Arwin Lashawn 04 grudnia 2020

Kontekst

Python nie jest znany jako „szybki” język programowania. Jednak zgodnie z wynikami 2020 Stack Overflow Developer Survey, Python jest drugim najpopularniejszym językiem programowania za JavaScriptem (jak można się domyślić). Jest to w dużej mierze spowodowane jego super przyjazną składnią i możliwością zastosowania do niemal każdego celu. Podczas gdy Python nie jest najszybszym językiem, jego świetna czytelność w połączeniu z bezkonkurencyjnym wsparciem społeczności i dostępnością bibliotek uczyniła go niezwykle atrakcyjnym do robienia rzeczy za pomocą kodu.

Zarządzanie pamięcią w Pythonie również odgrywa rolę w jego popularności. Jak to? Zarządzanie pamięcią w Pythonie jest zaimplementowane w sposób, który ułatwia nam życie. Słyszałeś kiedyś o menedżerze pamięci Pythona? Jest to menedżer utrzymujący pamięć Pythona w ryzach, dzięki czemu możesz skupić się na swoim kodzie, zamiast martwić się o zarządzanie pamięcią. Ze względu na swoją prostotę, Python nie daje jednak dużej swobody w zarządzaniu wykorzystaniem pamięci, w przeciwieństwie do języków takich jak C++, gdzie można ręcznie przydzielać i zwalniać pamięć.

Jednak dobre zrozumienie zarządzania pamięcią w Pythonie to świetny początek, który pozwoli Ci pisać bardziej wydajny kod. Ostatecznie, można go egzekwować jako nawyk, który może być potencjalnie przyjęty w innych językach programowania, które znasz.

Co więc zyskujemy pisząc kod wydajny pamięciowo?

  • Prowadzi to do szybszego przetwarzania i mniejszego zapotrzebowania na zasoby, a mianowicie wykorzystanie pamięci losowego dostępu (RAM). Więcej dostępnej pamięci RAM oznacza więcej miejsca na pamięć podręczną, która pomoże przyspieszyć dostęp do dysku. Wspaniałą rzeczą w pisaniu kodu, który jest wydajny pod względem pamięci jest to, że niekoniecznie wymaga pisania więcej linii kodu.
  • Inną korzyścią jest to, że zapobiega wyciekowi pamięci, problem, który powoduje, że użycie pamięci RAM stale rośnie, nawet gdy procesy są zabijane, co ostatecznie prowadzi do spowolnienia lub upośledzenia wydajności urządzenia. Jest to spowodowane brakiem uwolnienia używanej pamięci po zakończeniu procesów.

W świecie technologii, być może słyszałeś, że „zrobione jest lepsze niż doskonałe”. Jednak powiedzmy, że masz dwóch programistów, którzy używali Pythona do tworzenia tej samej aplikacji i ukończyli ją w tym samym czasie. Jeden z nich napisał bardziej wydajny pamięciowo kod, który skutkuje szybciej działającą aplikacją. Czy wolałbyś wybrać aplikację, która działa płynnie, czy tę, która zauważalnie działa wolniej? To jest jeden dobry przykład, gdzie dwie osoby spędzają tyle samo czasu na kodowaniu, a jednak mają zauważalnie różne wydajności kodu.

Oto, czego nauczysz się w tym przewodniku:

  • Jak zarządza się pamięcią w Pythonie?
  • Python Garbage Collection
  • Monitorowanie problemów z pamięcią w Pythonie
  • Najlepsze praktyki zwiększania wydajności kodu Pythona

Jak zarządzana jest pamięć w Pythonie?

Zgodnie z dokumentacją Pythona (3.9.0) dotyczącą zarządzania pamięcią, zarządzanie pamięcią w Pythonie obejmuje prywatną stertę, która jest używana do przechowywania obiektów i struktur danych programu. Pamiętaj również, że to menedżer pamięci Pythona zajmuje się większością brudnej roboty związanej z zarządzaniem pamięcią, dzięki czemu możesz po prostu skupić się na swoim kodzie.

Alokacja pamięci Pythona

Wszystko w Pythonie jest obiektem. Aby te obiekty były użyteczne, muszą być przechowywane w pamięci, aby można było uzyskać do nich dostęp. Zanim będą mogły być przechowywane w pamięci, dla każdego z nich musi być najpierw przydzielony kawałek pamięci.

Na najniższym poziomie, surowy alokator pamięci Pythona najpierw upewni się, że w prywatnej stercie jest dostępne miejsce do przechowywania tych obiektów. Robi to poprzez interakcję z menedżerem pamięci systemu operacyjnego. Spójrz na to jak na program Pythona, który prosi system operacyjny o kawałek pamięci do pracy.

Na następnym poziomie, kilka alokatorów specyficznych dla obiektów działa na tej samej stercie i wdraża różne polityki zarządzania w zależności od typu obiektu. Jak być może już wiesz, niektóre przykłady typów obiektów to ciągi znaków i liczby całkowite. Podczas gdy łańcuchy i liczby całkowite mogą nie różnić się zbytnio od siebie, biorąc pod uwagę ile czasu zajmuje nam ich rozpoznanie i zapamiętanie, są one traktowane przez komputery zupełnie inaczej. Dzieje się tak dlatego, że komputery mają inne wymagania dotyczące przechowywania i szybkości dla liczb całkowitych w porównaniu z ciągami znaków.

Jedną z ostatnich rzeczy, które powinieneś wiedzieć o tym, jak zarządzana jest sterta w Pythonie, jest to, że masz nad nią zerową kontrolę. Teraz możesz się zastanawiać, w jaki sposób możemy napisać kod wydajny pamięciowo, skoro mamy tak małą kontrolę nad zarządzaniem pamięcią w Pythonie? Zanim się do tego zabierzemy, musimy dokładniej zrozumieć kilka ważnych terminów dotyczących zarządzania pamięcią.

Statyczna vs. Dynamiczna alokacja pamięci

Teraz, gdy już rozumiesz, czym jest alokacja pamięci, nadszedł czas, aby zapoznać się z dwoma typami alokacji pamięci, mianowicie statyczną i dynamiczną, i rozróżnić je.

Statyczna alokacja pamięci:

  • Jak sugeruje słowo „statyczna”, zmienne alokowane statycznie są stałe, co oznacza, że muszą być przydzielone wcześniej i trwają tak długo, jak długo działa program.
  • Pamięć jest alokowana w czasie kompilacji lub przed wykonaniem programu.
  • Wdrażana przy użyciu struktury danych stosu, co oznacza, że zmienne są przechowywane w pamięci stosu.
  • Pamięć, która została przydzielona, nie może być ponownie użyta, a więc nie ma możliwości ponownego wykorzystania pamięci.

Dynamiczna alokacja pamięci:

  • Jak sugeruje słowo „dynamiczna”, zmienne przydzielane dynamicznie nie są stałe i mogą być przydzielane w trakcie działania programu.
  • Pamięć jest przydzielana w czasie wykonywania programu lub podczas jego wykonywania.
  • Implementowane przy użyciu struktury danych sterty, co oznacza, że zmienne są przechowywane w pamięci sterty.
  • Pamięć, która została przydzielona, może zostać zwolniona i ponownie wykorzystana.

Jedną z zalet dynamicznego przydzielania pamięci w Pythonie jest to, że nie musimy się wcześniej martwić, ile pamięci potrzebujemy dla naszego programu. Kolejną zaletą jest to, że manipulacja strukturą danych może być wykonywana swobodnie, bez konieczności martwienia się o potrzebę większej alokacji pamięci, jeśli struktura danych się rozrośnie.

Jednakże, ponieważ dynamiczna alokacja pamięci jest wykonywana podczas wykonywania programu, pochłania ona więcej czasu na jego ukończenie. Ponadto pamięć, która została przydzielona, musi zostać uwolniona po jej wykorzystaniu. W przeciwnym razie mogą wystąpić problemy takie jak wycieki pamięci.

Powyżej natknęliśmy się na dwa rodzaje struktur pamięci – pamięć sterty i pamięć stosu. Przyjrzyjmy się im dokładniej.

Pamięć stosu

Wszystkie metody i ich zmienne są przechowywane w pamięci stosu. Pamiętasz, że pamięć stosu jest alokowana w czasie kompilacji? Skutecznie oznacza to, że dostęp do tego typu pamięci jest bardzo szybki.

Gdy w Pythonie wywoływana jest metoda, alokowana jest ramka stosu. Ta ramka stosu będzie obsługiwać wszystkie zmienne metody. Po zwróceniu metody ramka stosu jest automatycznie niszczona.

Zauważ, że ramka stosu jest również odpowiedzialna za ustawianie zakresu dla zmiennych metody.

Pamięć sterty

Wszystkie obiekty i zmienne instancji są przechowywane w pamięci sterty. Kiedy w Pythonie tworzona jest zmienna, jest ona przechowywana w prywatnej stercie, która następnie pozwoli na alokację i deallokację.

Pamięć sterty umożliwia globalny dostęp do tych zmiennych przez wszystkie metody Twojego programu. Po zwróceniu zmiennej, Pythonowy garbage collector zabiera się do pracy, której działanie omówimy później.

Przyjrzyjrzyjmy się teraz strukturze pamięci Pythona.

Python ma trzy różne poziomy, jeśli chodzi o strukturę pamięci:

  • Areny
  • Pole
  • Bloki

Zaczniemy od największego z nich wszystkich – aren.

Areny

Wyobraźmy sobie biurko z 64 książkami pokrywającymi całą jego powierzchnię. Wierzch biurka reprezentuje jedną arenę, która ma stały rozmiar 256KiB, który jest przydzielany w stercie (Zauważ, że KiB różni się od KB, ale możesz założyć, że są one takie same dla tego wyjaśnienia). Arena reprezentuje największy możliwy kawałek pamięci.

Precyzując, areny są mapowaniami pamięci, które są używane przez alokator Pythona, pymalloc, który jest zoptymalizowany dla małych obiektów (mniejszych lub równych 512 bajtów). Areny są odpowiedzialne za przydzielanie pamięci, dlatego kolejne struktury nie muszą już tego robić.

Tę arenę można następnie dalej podzielić na 64 pule, które są kolejną największą strukturą pamięci.

Pools

Wracając do przykładu z biurkiem, książki reprezentują wszystkie pule w ramach jednej areny.

Każda pula miałaby zazwyczaj stały rozmiar 4Kb i może mieć trzy możliwe stany:

  • Pusty: Pula jest pusta, a zatem dostępna do alokacji.
  • Używana: Pula zawiera obiekty, które powodują, że nie jest ona ani pusta, ani pełna.
  • Full: Pula jest pełna, a więc nie jest dostępna do dalszej alokacji.

Zauważ, że rozmiar puli powinien odpowiadać domyślnemu rozmiarowi strony pamięci w systemie operacyjnym.

Pula jest następnie dzielona na wiele bloków, które są najmniejszymi strukturami pamięci.

Bloki

Powracając do przykładu biurka, strony w każdej książce reprezentują wszystkie bloki w ramach puli.

W przeciwieństwie do aren i puli, rozmiar bloku nie jest stały. Rozmiar bloku waha się od 8 do 512 bajtów i musi być wielokrotnością liczby osiem.

Każdy blok może przechowywać tylko jeden obiekt Pythona o określonym rozmiarze i ma trzy możliwe stany:

  • Nietknięty: Nie został przydzielony
  • Free: Został przydzielony, ale został zwolniony i udostępniony do przydzielenia
  • Allocated: Has been allocated

Zauważ, że trzy różne poziomy struktury pamięci (areny, pule i bloki), które omówiliśmy powyżej, są przeznaczone specjalnie dla mniejszych obiektów Pythona. Duże obiekty są kierowane do standardowego alokatora C w Pythonie, co byłoby dobrą lekturą na inny dzień.

Python Garbage Collection

Garbage collection to proces przeprowadzany przez program w celu zwolnienia wcześniej przydzielonej pamięci dla obiektu, który nie jest już używany. Możesz myśleć o alokacji śmieci jako o recyklingu pamięci lub jej ponownym wykorzystaniu.

W dawnych czasach programiści musieli ręcznie alokować i deallokować pamięć. Zapomnienie o deallokacji pamięci doprowadziłoby do wycieku pamięci, prowadząc do spadku wydajności wykonania. Co gorsza, ręczna alokacja i dealokacja pamięci może nawet doprowadzić do przypadkowego nadpisania pamięci, co może spowodować całkowite zawieszenie się programu.

W Pythonie zbieranie śmieci odbywa się automatycznie i dlatego oszczędza wiele bólu głowy związanego z ręcznym zarządzaniem alokacją i dealokacją pamięci. W szczególności Python używa liczenia referencji w połączeniu z generacyjnym zbieraniem śmieci, aby zwolnić nieużywaną pamięć. Powodem, dla którego samo liczenie referencji nie jest wystarczające dla Pythona, jest fakt, że nie czyści ono skutecznie cyklicznych odwołań.

Pokoleniowy cykl odśmiecania zawiera następujące kroki –

  1. Python inicjalizuje „listę odrzutów” dla nieużywanych obiektów.
  2. Uruchamiany jest algorytm wykrywania cykli referencyjnych.
  3. Jeśli obiektowi brakuje zewnętrznych referencji, jest on umieszczany na liście odrzuconych.
  4. Zwalnia alokację pamięci dla obiektów z listy odrzuconych.

Aby dowiedzieć się więcej na temat zbierania śmieci w Pythonie, możesz sprawdzić nasz post Python Garbage Collection: A Guide for Developers post.

Monitoring Python Memory Issues

Chociaż wszyscy kochają Pythona, nie stroni on od problemów z pamięcią. Istnieje wiele możliwych powodów.

Zgodnie z dokumentacją Pythona (3.9.0) dotyczącą zarządzania pamięcią, menedżer pamięci Pythona niekoniecznie zwalnia pamięć z powrotem do systemu operacyjnego. W dokumentacji stwierdzono, że „w pewnych okolicznościach menedżer pamięci Pythona może nie wywołać odpowiednich działań, takich jak zbieranie śmieci, zagęszczanie pamięci lub inne środki zapobiegawcze.”

W rezultacie może być konieczne jawne zwolnienie pamięci w Pythonie. Jednym ze sposobów, aby to zrobić, jest zmuszenie garbage collectora Pythona do zwolnienia nieużywanej pamięci poprzez użycie modułu gc. W tym celu należy po prostu uruchomić gc.collect(). Daje to jednak zauważalne korzyści tylko przy manipulowaniu bardzo dużą liczbą obiektów.

Oprócz sporadycznie błędnej natury garbage collectora Pythona, zwłaszcza gdy mamy do czynienia z dużymi zbiorami danych, znane są również wycieki pamięci powodowane przez kilka bibliotek Pythona. Pandas, na przykład, jest jednym z takich narzędzi na radarze. Rozważmy przyjrzenie się wszystkim problemom związanym z pamięcią w oficjalnym repozytorium pandas na GitHubie!

Jednym z oczywistych powodów, który może umknąć nawet bystrym oczom recenzentów kodu, są duże obiekty w kodzie, które nie zostały zwolnione. Na tej samej nucie, nieskończenie rosnące struktury danych są kolejnym powodem do niepokoju. Na przykład, rosnąca struktura danych słownika bez ustalonego limitu rozmiaru.

Jednym ze sposobów rozwiązania problemu rosnącej struktury danych jest konwersja słownika na listę, jeśli to możliwe i ustawienie maksymalnego rozmiaru listy. W przeciwnym razie, po prostu ustaw limit rozmiaru słownika i wyczyść go za każdym razem, gdy limit zostanie osiągnięty.

Teraz możesz się zastanawiać, jak w ogóle wykryć problemy z pamięcią? Jedną z opcji jest skorzystanie z narzędzia do monitorowania wydajności aplikacji (APM). Dodatkowo, wiele użytecznych modułów Pythona może pomóc w śledzeniu problemów z pamięcią. Przyjrzyjmy się naszym opcjom, zaczynając od narzędzi APM.

Narzędzia do monitorowania wydajności aplikacji (APM)

Więc czym dokładnie jest monitorowanie wydajności aplikacji i jak pomaga w śledzeniu problemów z pamięcią? Narzędzie APM pozwala na obserwowanie metryk wydajności programu w czasie rzeczywistym, umożliwiając ciągłą optymalizację w miarę odkrywania problemów, które ograniczają wydajność.

Bazując na raportach generowanych przez narzędzia APM, będziesz w stanie mieć ogólne pojęcie o tym, jak działa Twój program. Ponieważ możesz otrzymywać i monitorować metryki wydajności w czasie rzeczywistym, możesz podjąć natychmiastowe działania w przypadku zaobserwowanych problemów. Po zawężeniu możliwych obszarów programu, które mogą być winowajcami problemów z pamięcią, można następnie zanurzyć się w kodzie i przedyskutować go z innymi współtwórcami kodu, aby dokładniej określić konkretne linie kodu, które należy naprawić.

Wyśledzenie źródła problemów z wyciekiem pamięci samo w sobie może być zniechęcającym zadaniem. Naprawianie go jest kolejnym koszmarem, ponieważ musisz naprawdę zrozumieć swój kod. Jeśli kiedykolwiek znajdziesz się w takiej sytuacji, nie szukaj dalej, ponieważ ScoutAPM jest biegłym narzędziem APM, które może konstruktywnie analizować i optymalizować wydajność Twojej aplikacji. ScoutAPM daje wgląd w czasie rzeczywistym, dzięki czemu można szybko wskazać &rozwiązać problemy, zanim zauważą je klienci.

Moduły profilujące

Istnieje wiele przydatnych modułów Pythona, których można użyć do rozwiązania problemów z pamięcią, niezależnie od tego, czy jest to wyciek pamięci, czy awaria programu spowodowana nadmiernym zużyciem pamięci. Dwa z zalecanych to:

  1. tracemalloc
  2. memory-profiler

Zauważ, że tylko moduł tracemalloc jest wbudowany, więc upewnij się, że najpierw zainstalujesz inny moduł, jeśli chcesz go użyć.

tracemalloc

Zgodnie z dokumentacją Pythona (3.9.0) dla tracemalloc, użycie tego modułu może dostarczyć następujących informacji:

  • Traceback where an object was allocated.
  • Statystyki dotyczące przydzielonych bloków pamięci na nazwę pliku i na numer linii: całkowity rozmiar, liczba i średni rozmiar przydzielonych bloków pamięci.
  • Oblicz różnicę między dwoma migawkami, aby wykryć wycieki pamięci.

Zalecanym pierwszym krokiem, który powinieneś zrobić w celu określenia źródła problemu z pamięcią, jest najpierw wyświetlenie plików przydzielających najwięcej pamięci. Możesz to łatwo zrobić używając pierwszego przykładu kodu pokazanego w dokumentacji.

Nie oznacza to jednak, że pliki, które alokują niewielką ilość pamięci, nie będą rosły w nieskończoność, powodując wycieki pamięci w przyszłości.

memory_profiler

Ten moduł to niezła zabawa. Pracowałem z tym i jest to mój osobisty faworyt, ponieważ daje możliwość po prostu dodania dekoratora @profile do dowolnej funkcji, którą chcesz zbadać. Wyjście otrzymane jako wynik jest również bardzo łatwe do zrozumienia.

Innym powodem, który sprawia, że jest to mój osobisty faworyt jest to, że moduł ten pozwala na wykreślenie wykresu użycia pamięci w oparciu o czas. Czasami trzeba po prostu szybko sprawdzić, czy zużycie pamięci wzrasta w nieskończoność, czy nie. Jest to idealne rozwiązanie, ponieważ nie trzeba robić profilowania pamięci linia po linii, aby to potwierdzić. Możesz po prostu obserwować wykres po uruchomieniu profilera przez określony czas. Oto przykład wykresu, który jest wyprowadzany –

Zgodnie z opisem w dokumentacji memory-profiler, ten moduł Pythona służy do monitorowania zużycia pamięci przez proces, jak również do analizy line-by-line tego samego dla programów Pythona. Jest to czysty moduł Pythona, który zależy od biblioteki psutil.

Zalecam przeczytanie tego bloga Medium, aby dokładniej zbadać, jak używany jest memory-profiler. Dowiesz się tam również, jak używać innego modułu Pythona, muppy (najnowszy to muppy3).

Najlepsze praktyki zwiększania wydajności kodu Pythona

Dość już tych wszystkich szczegółów na temat zarządzania pamięcią. Teraz zbadajmy niektóre z dobrych nawyków w pisaniu wydajnego pamięciowo kodu Pythona.

Wykorzystaj biblioteki Pythona i wbudowane funkcje

Tak, to jest dobry nawyk, który może być dość często pomijany. Python ma bezkonkurencyjne wsparcie społeczności i jest to odzwierciedlone w licznych bibliotekach Pythona dostępnych dla niemal każdego celu, od wywołań API do nauki o danych.

Jeśli istnieje biblioteka Pythona, która pozwala na zrobienie tego samego, co już zaimplementowałeś, co możesz zrobić, to porównać wydajność kodu, gdy używasz biblioteki w porównaniu z tym, gdy używasz własnego kodu. Szanse są takie, że biblioteki Pythona (szczególnie te popularne) będą bardziej wydajne pamięciowo niż twój kod, ponieważ są one stale ulepszane w oparciu o opinie społeczności. Czy wolałbyś polegać na kodzie, który został stworzony w ciągu jednej nocy, czy na takim, który był rygorystycznie ulepszany przez dłuższy czas?

Najlepiej ze wszystkich, biblioteki Pythona pozwolą Ci zaoszczędzić wiele linii kodu, więc dlaczego nie?

Nieużywanie „+” do łączenia ciągów

W pewnym momencie wszyscy byliśmy winni łączenia ciągów za pomocą operatora „+”, ponieważ wygląda to tak łatwo.

Zauważ, że ciągi są niezmienne. Dlatego za każdym razem, gdy dodajesz element do łańcucha za pomocą operatora „+”, Python musi utworzyć nowy łańcuch z nową alokacją pamięci. Przy dłuższych łańcuchach nieefektywność pamięciowa kodu będzie bardziej widoczna.

Używanie itertools do wydajnego zapętlania

Pętle są istotną częścią automatyzacji rzeczy. W miarę jak będziemy coraz częściej używać pętli, w końcu dojdziemy do wniosku, że musimy używać zagnieżdżonych pętli, które są znane z tego, że są nieefektywne ze względu na ich wysoką złożoność w czasie wykonywania.

W tym miejscu na ratunek przychodzi moduł itertools. Zgodnie z dokumentacją itertools Pythona, „Moduł standaryzuje podstawowy zestaw szybkich, wydajnych pamięciowo narzędzi, które są użyteczne same lub w połączeniu. Razem umożliwiają one konstruowanie wyspecjalizowanych narzędzi w sposób zwięzły i wydajny w czystym Pythonie.”

Innymi słowy, moduł itertools umożliwia wydajne pamięciowo zapętlanie poprzez pozbycie się niepotrzebnych pętli. Co ciekawe, moduł itertools nazywany jest klejnotem, ponieważ umożliwia komponowanie eleganckich rozwiązań dla niezliczonych problemów.

Jestem prawie pewien, że będziesz pracował z co najmniej jedną pętlą w swoim następnym kawałku kodu, więc spróbuj zaimplementować itertools!

Podsumowanie i myśli końcowe

Stosowanie dobrych nawyków zarządzania pamięcią w Pythonie nie jest dla przypadkowego programisty. Jeśli zwykle radzisz sobie z prostymi skryptami, nie powinieneś napotkać problemów związanych z pamięcią. Dzięki sprzętowi i oprogramowaniu, które w chwili, gdy to czytasz, są coraz doskonalsze, podstawowy model każdego urządzenia, niezależnie od marki, powinien działać bez problemu. Potrzeba kodu wydajnego pamięciowo zaczyna się ujawniać dopiero wtedy, gdy zaczynasz pracować nad dużą bazą kodu, zwłaszcza produkcyjnego, gdzie wydajność jest kluczowa.

Nie sugeruje to jednak, że zarządzanie pamięcią w Pythonie jest pojęciem trudnym do uchwycenia, ani że nie jest ono ważne. Dzieje się tak dlatego, że nacisk na wydajność aplikacji rośnie z każdym dniem. Pewnego dnia, nie będzie to już tylko kwestia „zrobienia”. Zamiast tego programiści będą rywalizować o dostarczenie rozwiązania, które nie tylko będzie w stanie skutecznie rozwiązać potrzeby klientów, ale także zrobi to z błyskawiczną prędkością i przy minimalnych zasobach.