Ile pamięci zużywa PHP? memory_limit, zval i zużycie RAM

Zużycie pamięci w PHP - memory_limit, zval i pomiar RAMIle pamięci zużywa skrypt PHP i od czego właściwie zależy ta wartość? Sam rozmiar przetwarzanych danych niewiele mówi o rzeczywistym zapotrzebowaniu aplikacji na RAM. Tablice, obiekty, ciągi znaków, wyniki zapytań do bazy czy wczytane pliki są przechowywane przez Zend Engine w określonych strukturach, które również zajmują pamięć. Dlatego plik mający 50 MB może podczas przetwarzania wymagać kilkuset megabajtów, a pozornie niewielka tablica potrafi doprowadzić do błędu Allowed memory size exhausted.

W artykule przyjrzymy się temu, jak PHP zarządza pamięcią, co naprawdę oznacza ustawienie memory_limit, czym różnią się wyniki memory_get_usage() i memory_get_peak_usage() oraz dlaczego zmienna zajmuje więcej miejsca niż wynikałoby to z samej przechowywanej wartości. Omówimy również koszt tablic PHP, mechanizm copy-on-write, wpływ dużych wyników z bazy danych i plików oraz sposoby diagnozowania fragmentów kodu, które powodują gwałtowny wzrost wykorzystania pamięci.

Co naprawdę oznacza memory_limit?

memory_limit jest jednym z podstawowych limitów zasobów w konfiguracji PHP. Określa maksymalną ilość pamięci, jaką skrypt może zaalokować za pośrednictwem mechanizmów zarządzania pamięcią PHP. Wartość -1 oznacza brak takiego ograniczenia.

Typowa konfiguracja może wyglądać tak:

memory_limit = 128M

Nie należy jednak interpretować tego jako prostego stwierdzenia, że cały proces systemowy PHP może zajmować maksymalnie 128 MB pamięci RAM.

PHP posiada własny mechanizm zarządzania pamięcią znajdujący się pomiędzy kodem aplikacji a systemowym alokatorem pamięci. Część pamięci może być wykorzystywana również przez rozszerzenia, biblioteki systemowe czy sam proces PHP-FPM. Z tego powodu pamięć widoczna z poziomu PHP i pamięć procesu raportowana przez system operacyjny nie zawsze będą identyczne.

W praktyce warto rozróżniać co najmniej trzy wartości:

  • limit pamięci przypisany do skryptu,
  • pamięć kontrolowaną przez Zend Memory Manager,
  • rzeczywisty rozmiar procesu widoczny z poziomu systemu operacyjnego.

To rozróżnienie staje się szczególnie ważne podczas analizowania długotrwałych procesów CLI, workerów kolejek oraz środowisk opartych na PHP-FPM.

Co oznacza 128M w konfiguracji PHP?

PHP wykorzystuje w wielu dyrektywach konfiguracyjnych skrócony zapis wielkości danych. Litery K, M i G można spotkać między innymi przy memory_limit, upload_max_filesize oraz post_max_size.

W tym kontekście wartości są przeliczane na kolejne potęgi liczby 1024.

Zapis PHP Liczba bajtów Obliczenie
1K 1 024 210
1M 1 048 576 220
128M 134 217 728 128 × 1 048 576
1G 1 073 741 824 230

Ma to znaczenie, ponieważ w informatyce równolegle funkcjonują jednostki oparte na liczbie 1000 i 1024. Jeśli potrzebne jest dokładniejsze rozróżnienie bitu, bajtu, KB, MB, GB oraz jednostek binarnych, szerzej opisuje to artykuł jednostki informacji - bit, bajt, kB, MB, GB i TB.

Od PHP 8.2 dostępna jest funkcja ini_parse_quantity(), która pozwala przeliczyć zapis stosowany w konfiguracji PHP na wartość liczbową wyrażoną w bajtach.

$limit = ini_get("memory_limit");

echo $limit . PHP_EOL;
echo ini_parse_quantity($limit) . PHP_EOL;

Dla ustawienia 128M funkcja zwróci odpowiadającą mu liczbę bajtów. Jest to wygodniejsze i bezpieczniejsze niż samodzielne parsowanie wartości zapisanych w konfiguracji.

memory_get_usage() - co właściwie mierzy?

Podstawową funkcją wykorzystywaną do obserwowania zużycia pamięci wewnątrz skryptu jest memory_get_usage().

echo memory_get_usage();

Wartość zwracana jest w bajtach. Funkcja przyjmuje jednak opcjonalny parametr logiczny, który istotnie zmienia znaczenie wyniku.

echo memory_get_usage(false);
echo PHP_EOL;
echo memory_get_usage(true);

Przy domyślnej wartości false otrzymujemy ilość pamięci aktualnie wykorzystywanej przez struktury PHP. Ustawienie true powoduje zwrócenie całkowitej pamięci zaalokowanej przez mechanizm zarządzania pamięcią PHP, także tej, która została już pobrana z systemu, lecz w danej chwili nie musi być aktywnie wykorzystywana przez zmienne.

Można więc otrzymać dwie wyraźnie różniące się wartości:

printf(
    "used: %d\nreal: %d\n",
    memory_get_usage(false),
    memory_get_usage(true)
);

Nie oznacza to automatycznie wycieku pamięci. Zend Memory Manager może zachowywać wcześniej zaalokowane obszary i wykorzystywać je ponownie podczas kolejnych operacji.

Szczytowe zużycie pamięci jest ważniejsze niż końcowe

Pomiar wykonany na końcu skryptu nie zawsze mówi wiele o tym, co wydarzyło się wcześniej. Aplikacja może przez większość czasu wykorzystywać 20 MB, następnie chwilowo potrzebować 150 MB, zwolnić dużą strukturę i zakończyć działanie przy 30 MB.

Jeżeli memory_limit ustawiono na 128M, skrypt zakończy się jednak podczas chwilowego skoku i nigdy nie dotrze do końcowego pomiaru.

Dlatego do diagnostyki znacznie lepiej nadaje się memory_get_peak_usage().

echo memory_get_peak_usage();
echo PHP_EOL;
echo memory_get_peak_usage(true);

Funkcja zwraca największe zużycie pamięci osiągnięte od początku działania skryptu.

Od PHP 8.2 można dodatkowo użyć memory_reset_peak_usage(). Pozwala to wyzerować zapamiętany szczyt i zmierzyć tylko wybraną część programu.

memory_reset_peak_usage();

$data = loadLargeDataset();

printf(
"Peak: %.2f MiB\n",
memory_get_peak_usage() / 1024 / 1024
);

Takie podejście pozwala oddzielić koszt uruchomienia frameworka lub aplikacji od kosztu konkretnego fragmentu logiki biznesowej.

Dlaczego zmienna zajmuje więcej niż jej wartość?

Załóżmy, że w PHP tworzymy zwykłą zmienną liczbową:

$value = 42;

Nie oznacza to, że PHP potrzebuje wyłącznie miejsca na samą wartość liczbową. Zend Engine musi wiedzieć również, jaki typ danych przechowuje zmienna i jak należy nią zarządzać.

Podstawową strukturą wykorzystywaną wewnętrznie do reprezentowania wartości jest zval. Zawiera ona nie tylko właściwą wartość, ale również informacje potrzebne silnikowi do jej interpretacji i obsługi.

W przypadku typów bardziej złożonych dochodzą następne struktury. Łańcuch znaków musi przechowywać swoją zawartość i długość, tablica - elementy oraz klucze, a obiekt - informacje o klasie, właściwościach i innych danych wymaganych przez silnik.

Dlatego prosty sposób szacowania:

100 000 elementów * rozmiar samej wartości

nie pozwala wiarygodnie przewidzieć pamięci potrzebnej na przechowanie 100 000 elementów w PHP.

Dlaczego tablice PHP potrafią zużywać dużo pamięci?

array w PHP jest strukturą bardzo uniwersalną. Może reprezentować zwykłą listę:

$ids = [10, 20, 30];

ale dokładnie ten sam typ może pełnić funkcję mapy klucz-wartość:

$user = [
    "id" => 15,
    "name" => "Anna",
    "active" => true,
];

PHP musi więc obsługiwać klucze liczbowe i tekstowe, zachowywać kolejność elementów oraz umożliwiać szybkie wyszukiwanie wartości. Wewnętrznie tablica jest strukturą znacznie bardziej rozbudowaną niż prosty ciąg kolejnych liczb zapisanych bezpośrednio obok siebie w pamięci.

Koszt można łatwo sprawdzić eksperymentalnie:

$before = memory_get_usage();

$data = range(1, 1_000_000);

$after = memory_get_usage();

printf(
"Array: %.2f MiB\n",
($after - $before) / 1024 / 1024
);

Wyniku takiego testu nie należy traktować jako uniwersalnej stałej. Zależy on między innymi od wersji PHP, architektury systemu oraz sposobu reprezentacji danych.

Znacznie ważniejszy jest sam wniosek: milion liczb zapisanych w tablicy PHP może wymagać wielokrotnie więcej pamięci niż wynikałoby z prostego przemnożenia liczby elementów przez rozmiar pojedynczej liczby.

Copy-on-write - kiedy kopia nie jest jeszcze kopią?

Jednym z mechanizmów ograniczających niepotrzebne kopiowanie danych jest copy-on-write.

Rozważmy przykład:

$a = range(1, 1_000_000);
$b = $a;

Na poziomie kodu wygląda to tak, jakby cała tablica została skopiowana. PHP nie musi jednak natychmiast tworzyć drugiej fizycznej kopii wszystkich danych. Obie zmienne mogą przez pewien czas korzystać z tej samej reprezentacji.

Sytuacja zmienia się podczas modyfikacji jednej z nich:

$a = range(1, 1_000_000);
$b = $a;

$b[0] = 999;

W tym momencie wartości muszą zostać logicznie rozdzielone. To właśnie wtedy zapotrzebowanie na pamięć może gwałtownie wzrosnąć.

Ma to duże znaczenie podczas przetwarzania rozbudowanych tablic. Kod może pozornie wykonywać tylko niewielką zmianę jednego elementu, podczas gdy konsekwencją może być konieczność utworzenia dużej niezależnej struktury.

Duży wynik z bazy danych i pamięć PHP

W aplikacjach produkcyjnych problemy z pamięcią częściej wynikają z przetwarzania danych niż z ręcznego tworzenia ogromnych tablic.

Typowym przykładem jest pobranie bardzo dużego wyniku z bazy danych:

$stmt = $pdo->query(
    "SELECT * FROM orders"
);

$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

Jeżeli tabela zawiera kilkaset tysięcy rekordów, aplikacja próbuje zbudować w pamięci dużą strukturę PHP zawierającą wszystkie wyniki. Każdy wiersz staje się kolejną tablicą, a każda kolumna kolejnym elementem tej struktury.

Rozmiar danych zapisanych w bazie nie jest więc równy ilości pamięci potrzebnej do ich reprezentowania w PHP.

Jeżeli aplikacja ma jedynie przetworzyć każdy rekord i zapisać wynik, lepszym rozwiązaniem może być iterowanie po rezultatach:

$stmt = $pdo->query(
    "SELECT id, email FROM users"
);

while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
processUser($row);
}

Jeszcze innym rozwiązaniem jest dzielenie danych na partie, np. po 1000 lub 5000 rekordów. Dzięki temu rozmiar aktywnego zestawu danych pozostaje mniej więcej stały niezależnie od tego, czy tabela zawiera 10 tysięcy czy 10 milionów rekordów.

Przy dużych zbiorach danych znacznie ważniejsza od podnoszenia memory_limit jest kontrola nad tym, ile rekordów aplikacja przechowuje jednocześnie w pamięci.

Duże pliki - wczytywać czy strumieniować?

Podobny problem występuje podczas pracy z dużymi plikami.

Najprostszy kod wygląda niewinnie:

$data = file_get_contents("large-file.csv");

Cała zawartość pliku musi jednak zostać zapisana w pamięci jako ciąg znaków. Jeżeli następnie podzielimy ten ciąg na linie, wykonamy transformacje tekstowe albo przekonwertujemy dane do tablic, chwilowe zapotrzebowanie na pamięć może znacznie przekroczyć rozmiar pliku na dysku.

Dla danych, które można przetwarzać sekwencyjnie, znacznie korzystniejszy jest odczyt strumieniowy:

$handle = fopen("large-file.csv", "rb");

while (($row = fgetcsv($handle)) !== false) {
processRow($row);
}

fclose($handle);

W tym przypadku aplikacja nie potrzebuje jednocześnie całego pliku. Pobiera kolejne fragmenty, przetwarza je i przechodzi dalej.

Ta różnica jest kluczowa dla skalowania. Przy wczytywaniu całego pliku zużycie pamięci rośnie wraz z jego rozmiarem. Przy prawidłowym strumieniowaniu może pozostawać na zbliżonym poziomie nawet wtedy, gdy plik jest wielokrotnie większy.

Czy unset() natychmiast zwalnia RAM?

Instrukcja unset() usuwa zmienną:

unset($data);

Jeżeli dana wartość nie jest już nigdzie używana, związana z nią pamięć może zostać zwolniona z punktu widzenia PHP. Nie oznacza to jednak, że identyczna liczba bajtów zostanie natychmiast oddana systemowi operacyjnemu.

Zend Memory Manager może zachować pobrany wcześniej obszar, aby wykorzystać go podczas następnych alokacji. Dlatego po wykonaniu unset() możliwy jest wyraźny spadek wartości memory_get_usage(false) przy znacznie mniejszej zmianie memory_get_usage(true).

Ma to szczególne znaczenie podczas analizowania procesów PHP-FPM. Duży rozmiar procesu widoczny po obsłużeniu ciężkiego requestu nie jest jeszcze dowodem na klasyczny wyciek pamięci.

W przypadku długotrwałych workerów sytuacja jest jednak bardziej wymagająca. Jeśli kolejne zadania powodują systematyczne zwiększanie się rzeczywistego zużycia pamięci i wartość ta nigdy nie wraca do stabilnego poziomu, warto przeanalizować zarówno kod aplikacji, jak i używane rozszerzenia.

PHP 8.5 i max_memory_limit

PHP 8.5 wprowadziło dodatkową dyrektywę max_memory_limit. Rozwiązanie to jest szczególnie interesujące z punktu widzenia administratorów serwerów i środowisk, w których aplikacja może samodzielnie modyfikować swój limit pamięci.

Kod PHP może próbować zmienić wartość memory_limit podczas działania:

ini_set("memory_limit", "1G");

Nie zawsze jest jednak pożądane, aby dowolna aplikacja mogła ustawić sobie praktycznie nieograniczoną ilość pamięci. max_memory_limit pozwala administratorowi wyznaczyć górną granicę dla ustawienia memory_limit.

W praktyce pojawiają się więc dwa poziomy kontroli:

  • memory_limit - limit przypisany konkretnemu skryptowi,
  • max_memory_limit - maksymalna wartość dopuszczona przez konfigurację środowiska.

Ma to znaczenie szczególnie przy PHP-FPM. Jeżeli pula może uruchomić kilkadziesiąt procesów równolegle, zezwolenie każdemu z nich na wykorzystanie bardzo dużej ilości pamięci może doprowadzić do wyczerpania RAM całego serwera znacznie wcześniej niż do limitu pojedynczego skryptu.

Przykładowo 40 procesów, z których każdy wykorzysta około 256 MiB, daje:

40 * 256 MiB = 10 240 MiB

czyli około 10 GiB pamięci tylko dla tych procesów. Samo ustawienie wysokiego memory_limit nie jest więc neutralną decyzją administracyjną.

Jak diagnozować przekroczenie memory_limit?

Najczęstszą reakcją na komunikat:

Allowed memory size of ... bytes exhausted

jest kolejne zwiększanie limitu:

128M -> 256M -> 512M -> 1G

Czasami jest to uzasadnione. Obróbka dużych obrazów, generowanie raportów, import danych czy zadania uruchamiane z CLI mogą rzeczywiście potrzebować więcej pamięci niż zwykły request HTTP.

Jeżeli jednak typowa podstrona aplikacji potrzebuje kilkuset megabajtów, samo podnoszenie limitu zwykle jedynie odsuwa problem.

Do znalezienia miejsca, w którym pamięć zaczyna gwałtownie rosnąć, można wykorzystać prostą funkcję diagnostyczną:

function memoryPoint(string $label): void
{
    printf(
        "%s | used: %.2f MiB | real: %.2f MiB | peak: %.2f MiB\n",
        $label,
        memory_get_usage(false) / 1048576,
        memory_get_usage(true) / 1048576,
        memory_get_peak_usage(false) / 1048576
    );
}

memoryPoint("start");

$data = loadData();
memoryPoint("after load");

$result = transformData($data);
memoryPoint("after transform");

unset($data);
memoryPoint("after unset");

Kilka takich punktów kontrolnych często wystarcza, aby ustalić, czy największy wzrost następuje podczas pobierania danych, ich transformacji, budowania drugiej struktury czy zapisywania wyniku.

W praktyce szczególnej uwagi wymagają:

  • bardzo duże tablice PHP,
  • fetchAll() wykonywane na dużych wynikach,
  • wczytywanie całych plików do zmiennej,
  • dekodowanie dużych dokumentów JSON,
  • przetwarzanie dużych dokumentów XML,
  • operacje na obrazach,
  • sortowanie i kopiowanie rozbudowanych tablic,
  • tworzenie kilku przekształconych wersji tych samych danych,
  • długotrwałe workery i procesy CLI.

memory_limit nie zastępuje właściwej architektury

Limit pamięci pełni przede wszystkim funkcję ochronną. Ma zapobiec sytuacji, w której pojedynczy skrypt wykorzysta wszystkie dostępne zasoby serwera. Nie jest jednak narzędziem służącym do naprawiania nieefektywnego sposobu przetwarzania danych.

Jeżeli aplikacja potrzebuje dwa razy więcej pamięci po dwukrotnym zwiększeniu liczby rekordów, warto zadać pytanie, czy naprawdę musi przechowywać cały zbiór jednocześnie.

W wielu przypadkach największe oszczędności nie wynikają z zastąpienia jednej funkcji inną ani z próby zmniejszenia pojedynczej struktury o kilka procent. Znacznie więcej daje zmiana sposobu przepływu danych:

  • iterowanie zamiast pobierania całego wyniku,
  • przetwarzanie rekordów partiami,
  • strumieniowanie plików,
  • generatory zamiast budowania pełnych kolekcji,
  • usuwanie zbędnych kopii dużych struktur,
  • ograniczanie zakresu danych już na poziomie zapytania SQL.

Dobry przykład daje generator, który pozwala przekazywać kolejne wartości bez wcześniejszego przygotowania kompletnej tablicy:

function numbers(int $max): Generator
{
    for ($i = 1; $i !== $max + 1; $i++) {
        yield $i;
    }
}

foreach (numbers(1_000_000) as $number) {
processNumber($number);
}

W takim przypadku kolejne wartości są udostępniane w miarę iterowania. Nie trzeba wcześniej tworzyć tablicy zawierającej milion elementów. Nie oznacza to, że generator zawsze będzie najlepszym rozwiązaniem, ale dobrze ilustruje różnicę między przetwarzaniem strumieniowym a budowaniem kompletnej kolekcji w pamięci.

Dobrze zaprojektowany kod powinien możliwie długo utrzymywać zużycie pamięci na przewidywalnym poziomie. Dopiero gdy wymagania konkretnego zadania rzeczywiście uzasadniają większy RAM, zwiększenie memory_limit staje się decyzją techniczną, a nie sposobem na ukrycie problemu.

Komentarze