Управление памятью в PHP

19.11.2023 в 20:37
24407
+2213

Всем привет. Сегодня разговор пойдёт о том, каким образом в PHP реализовано хранение данных в памяти и о том, что же на самом деле скрывает за собой довольно простой код. В данной статье я хотел бы больше показать на практике результаты работы разного кода, опуская многие детали внутренней реализации. На мой взгляд, для разработчика на языке PHP более приоритетны знания о том, как писать код на PHP, нежели всё внутреннее устройство интерпретатора. Ну а заинтересовавшиеся данной темой люди легко нагуглят материалы по данной теме. Итак, приступим.

Функции для анализа памяти

В первую очередь стоит познакомиться с некоторыми функциями, позволяющими узнать об использовании памяти. Начнём с функции memory_get_usage($real_usage = null).

Эта функция возвращает количество памяти в байтах, выделенной скрипту в данный момент. По умолчанию (без передачи параметров), она возвращает количество памяти, запрошенное процессом для его работы:

echo memory_get_usage();
352952

При передаче ей единственно возможного аргумента типа boolean, равного true, функция вернёт реально выделенное количество памяти, которое всегда больше, чем запрошенное значение:

echo memory_get_usage(true);
2097152

Поиграемся немного с этими функциями, напишем небольшой скрипт, который будет сначала забивать память какими-либо данными, а потом уничтожать их:

<?php
function echoMemoryUsage() {
    echo 'Requested: ' . (int)(memory_get_usage() / 1024) . ' KB';
    echo PHP_EOL . '';
    echo 'Allocated: ' . (int)(memory_get_usage(true) / 1024) . ' KB';
    echo PHP_EOL . PHP_EOL;
}

echoMemoryUsage();

// Создадим строку из 10 млн. символов 'f'
$bigString = str_repeat('f', 10000000);

echoMemoryUsage();

// Удалим переменную
unset($bigString);

echoMemoryUsage();

Результат выполнения данного скрипта:

Requested: 343 KB
Allocated: 2048 KB

Requested: 10583 KB
Allocated: 12288 KB

Requested: 343 KB
Allocated: 2048 KB

Как мы видим, в процессе работы скрипта количество запрашиваемой и выделенной памяти сначала увеличилось, а затем вернулось в исходное состояние.

Рассмотрим также функцию memory_get_peak_usage($real_usage = null)
Она возвращает максимальный объем памяти в байтах, который был выделен PHP скрипту за всё прошедшее время выполнения до данного момента. Как и у предыдущей рассмотренной нами функции у неё есть единственный аргумент, имеющий такой же смысл. Дополним наш скрипт вызовом этой функции в конце:

<?php
function echoMemoryUsage() {
    echo 'Requested: ' . (int)(memory_get_usage() / 1024) . ' KB';
    echo PHP_EOL . '';
    echo 'Allocated: ' . (int)(memory_get_usage(true) / 1024) . ' KB';
    echo PHP_EOL . PHP_EOL;
}

echoMemoryUsage();

// Создадим строку из миллиона символов 'f'
$bigString = str_repeat('f', 10000000);

echoMemoryUsage();

unset($bigString);

echoMemoryUsage();

echo 'Peak requested: ' . (int)(memory_get_peak_usage() / 1024) . ' KB';
echo PHP_EOL;
echo 'Peak allocated: ' . (int)(memory_get_peak_usage(true) / 1024) . ' KB';

Теперь вывод будет таким:

Requested: 344 KB
Allocated: 2048 KB

Requested: 10584 KB
Allocated: 12288 KB

Requested: 344 KB
Allocated: 2048 KB

Peak requested: 10584 KB
Peak allocated: 12288 KB

Функция отработала ожидаемым образом. Её можно использовать в конце работы скрипта как вариант быстрого анализа эффективности кода по памяти.

Что происходит на самом деле?

Итак, мы научились узнавать об использовании памяти. Рассмотрим теперь более детально следующий код:

$var = 123;
unset($var);

Чтобы понять, как он работает, нам всё же придётся немного углубиться во внутреннюю реализацию языка PHP. Все значения переменных являются структурой zval. Структура zval состоит из четырех полей. Не будем заострять своё внимание на том, как именно это всё устроено. Нам лишь достаточно знать о том, что это за поля, и для чего они нужны. Итак, эти поля:

  • значение;
  • тип этого значения;
  • число ссылок на это значение;
  • флаг того, что данное значение используется по ссылке (подробности позже, пока не заморачивайтесь).

В первой строке нашего кода происходит создание переменной. В этот момент в куче выделяется память под значение, создаётся структура zval и заполняется указанным значением 123. Затем в специальном месте, называемом таблицей символов, создаётся имя данной переменной $var. Наконец, устанавливается связь между именем переменной и значением в памяти. Теперь переменная $var ссылается на значение 123.

В данный момент поля структуры zval имеют следующие значения:

  • тип: целое число
  • значение: 123
  • число ссылок: 1
  • является ли значение использующимся по ссылке: нет, или 0 (подробности позже).

Что же происходит, когда мы вызываем unset($var)? В таблице символов удаляется имя $var, после чего в структуре zval в поле число ссылок устанавливается значение 0. Таким образом, на это значение больше не ссылается ни одно имя. PHP, видя это, уничтожает данное значение, освобождая таким образом занимаемую им память.
Что произойдёт, если вместо unset, мы присвоим данной переменной значение null? Произойдёт разрыв связи между именем $var и её значением. $var перестаёт ссылаться на какое-либо значение. Теперь $var – это просто имя, и ничего кроме. Число ссылок на значение в этом случае тоже станет равно 0 и оно будет уничтожено.

Ссылки

Рассмотрим следующий код:

$var1 = 123;
$var2 = $var1;
$var1 = 12345;

В первой строке мы создаём переменную и присваиваем ей значение. Тут всё понятно, это мы проходили.
На следующей строке переменной $var2 присваивается значение $var1. Однако, внутри не всё так просто, как кажется. Мы полагаем, что в куче будет выделена память, значение скопировано, и новое имя начнёт указывать на новое созданное значение. Это не так :)
На самом деле, после выполнения второй строки имя $var2 будет указывать всё ещё на тот же zval. При этом у этого zval в поле число ссылок теперь будет число 2. Прошу обратить внимание, что под ссылками мы сейчас подразумеваем не ссылки PHP, которые начинаются со знака &, а связь между именем и значением. Больше никаких изменений в этом zval на данный момент не произойдёт.
Перейдём к третьей строке. А вот теперь будет создан новый zval! Назовём его zval2, а предыдущий — zval1. Рассмотрим теперь, что же именно произошло. А произошло следующее: была выделена память под значение 12345 и создан новый zval (zval2) с этим значением, имя $var1 теперь ссылается на zval2. $var2 продолжает ссылаться на zval1, число ссылок на zval1 уменьшилось и теперь равняется 1. Этот механизм называется copy-on-write. Благодаря ему PHP создаёт новое значение только в тот момент, когда это действительно необходимо. Надеюсь, тут всё понятно.

Теперь рассмотрим следующий код:

$var1 = 123;
$var2 = &$var1;
$var1 = 12345;
echo $var2;

и результат его работы:

12345

Рассмотрим вторую строку. Благодаря символу & переменной $var2 происходит присваивание по ссылке значения переменной $var1. В результате этого появляется лишь ещё одно имя, ссылающееся на то же значение. И теперь пришла пора поговорить о четвёртом поле в структуре zval, которое в примерах выше всегда было равно 0. Здесь-то оно и превращается в единицу. И значения полей полученного zval становятся следующими:

  • тип: целое число
  • значение: 123
  • число ссылок: 2
  • является ли значение использующимся по ссылке: да (1).

Благодаря этому флагу PHP определяет, что этот zval не должен быть скопирован перед изменением, а должно быть изменено лишь его значение.
В результате этого, при выполнении кода в третьей строке, значение, на которое ссылается имя $var1 будет заменено на 12345. На это же значение ссылается имя $var2. В результате этого при чтении значения $var2 мы получим то же значение — 12345.
Теперь, если сделать unset($var1), значение не удалится, оно будет по-прежнему доступно по имени $var2. В zval при этом число ссылок уменьшится до 1, а поле, означающее что значение является использующимся по ссылке станет нулевым.

Передача значений в PHP

Управление памятью в PHP: передача в функцию, классы, массивы

Теперь же давайте поговорим о том, как реализована передача значений в функцию, а также о передаче и присваивании объектов и массивов, ведь они, как Вы уже наверное догадались, ведут себя несколько иначе.

Итак, начнём. Помните в прошлой статье мы говорили о таблице символов, в которой хранятся имена переменных? Так вот таких таблиц на самом деле может быть несколько.

Давайте рассмотрим следующий код:

function func($var)
{
    $var++;
}
$var = 123;
func($var);
echo $var;

Результат:

123

В данном случае одна из этих таблиц предназначена для хранения имён в глобальной области видимости, а вторая будет хранить имена, использующиеся внутри функции func().
Что же произойдёт в момент вызова функции func()? А произойдёт нечто похожее на присваивание, которое мы уже рассматривали. В момент передачи аргумента в функцию произойдёт создание нового имени в таблице символов функции, это имя в данный момент будет ссылаться на то же значение, что и глобальная переменная $var.

Структура zval при этом сейчас содержит информацию о двух ссылках на данное значение. И лишь в момент модификации переменной внутри функции значение будет скопировано и изменено, а имя из таблицы символов функции начнёт ссылаться на это новое значение.
Как мы видим, при передаче в функцию происходит нечто похожее на процесс присваивания переменной, и снова в действии механизм copy-on-write.

Рассмотрим теперь следующий пример:

<?php
function func(&$x)
{
    $x++;
}
$x = 123;
func($x);
echo $x;

Он выведет:

124

От предыдущего кода отличается лишь наличием знака & перед аргументом функции. Рассмотрим, что происходило со значением zval. После определения глобальной переменной $x происходит её передача в функцию по ссылке. Число ссылок на значение установилось равным двум, а флаг, говорящий о том, что значение используется по ссылке, даёт PHP понять, что перед модификацией значение не нужно копировать, а стоит изменять непосредственно его. В общем, история та же, что и с простым присваиванием по ссылке.

Объекты

Рассмотрим такой код:

<?php
function func($obj)
{
    $obj->prop = 123;
}
$obj = new stdClass();
$obj->prop = 1;
func($obj);
var_dump($obj);

Вывод будет следующим:

class stdClass#1 (1) {
public $prop =>
int(123)
}

После передачи объекта в функцию и изменению его свойства внутри неё, мы видим что свойство объекта изменилось и вне функции. Отсюда, как Вы уже догадались, можно сделать вывод, что объекты всегда передаются по ссылке, независимо от того, был ли знак & перед аргументом, или нет. Да и наверняка Вы уже до этого слышали, что объекты в PHP всегда передаются по ссылке. Так вот это не так :)
Но мы же только что видели обратное, как это не так? Объясняю. Видели на самом деле мы лишь изменение свойства объекта, а не объекта как такового. Дело в том, что значение объекта в PHP представляет собой лишь идентификатор объекта, и этот идентификатор уже используется для поиска данных этого объекта. Таким образом в момент передачи в функцию объекта поля zval принимают следующие значения:

  • число ссылок — 2;
  • объект передан по ссылке — 0.

А в момент обращения к свойству!!! объекта происходит поиск свойства по идентификатору объекта и его модификация. Значение самого объекта при этом остаётся неизменным. Проверить это можно следующим кодом:

<?php
function func($obj)
{
    $obj = 123;
}
$obj = new stdClass();
$obj->prop = 1;
func($obj);
var_dump($obj);

Вывод будет следующим:

class stdClass#1 (1) {
public $prop =>
int(1)
}

Как видим, объект не изменился. А всё дело в том, что внутри функции в момент присваивания имени из таблицы символов функции другого значения произошло создание нового значения, а у zval объекта число ссылок уменьшилось до 1.
Если теперь всё же добавить & перед аргументом функции:

<?php
function func(&$obj)
{
    $obj = 123;
}
$obj = new stdClass();
$obj->prop = 1;
func($obj);
var_dump($obj);

То вывод будет следующим:

int(123)

Объяснять, думаю, не нужно. Стандартное поведение при передаче по ссылке, рассмотренное ранее.
Такое поведение объектов, когда при передаче в функцию или присваивании переменной мы можем работать с его свойствами, не производя при этом его копирования позволяет реализовать в PHP эффективное внедрение зависимостей (dependency injection).
Пример:

<?php
class Db {
    private $dbhost;

    public function __construct(Config $config)
    {
        $this->dbhost = $config->db->host;
    }
}

Здесь в конструкторе класса Db происходит передача объекта класса Config. Мы можем использовать его свойства (как читать, так и изменять) не выполняя при этом копирования и не расходуя память. Да, вот такой вот PHP умный и хороший.
Стоит отметить, что иногда нам может потребоваться скопировать объект, и работать с его копией, внося изменения только в ней. Для этого используется конструкция clone.
Использоваться она может так:

$obj2 = clone $obj1;

Или даже непосредственно при передаче в функцию:

func(clone $obj);

Массивы

Отдельно стоит упомянуть о массивах. Они, так же как и скаляры и объекты по умолчанию передаются по значению. Кроме одного единственного случая — если на копию массива при передаче его в функцию нам не хватит памяти, он будет передан по ссылке, и мы при этом никак не будем об этом уведомлены :)
Для работы с массивами в PHP имеется несколько конструкций и функций. Одни функции работают по значению, возвращая в результате новый массив, другие позволяют работать по ссылке, позволяя здорово экономить память. Стоит отметить, что зачастую именно при работе с массивами приходится задумываться о расходуемой памяти. Рассмотрим несколько примеров.
Возьмём функцию array_map($callback, array $array)
Она возвращает массив, содержащий элементы $array после их обработки callback-функцией:

$arr1 = [1, 2, 3];
$arr2 = array_map(function ($item) { return $item * 2; }, $arr1);

После выполнения данного кода будет создан новый массив $arr2, содержащий в себе элементы $arr1, умноженные на 2. При этом в момент выполнения функции значения массива $arr1 копировались и в итоге заняли такое же количество памяти, что и исходный массив.

Теперь рассмотрим функцию array_walk(array &$array, $funcname) {}
Как мы видим, массив она принимает по ссылке. Функция занимается тем что применяет заданную пользователем функцию к каждому элементу массива. Как Вы уже догадались, копирования значений при этом не происходит.

$arr1 = [1, 2, 3];
array_walk($arr1, function (&$item) { $item *= 2; });

Данный код пройдётся по каждому значению $arr1 и изменит непосредственно его значение. С точки зрения производительности по памяти, этот вариант более предпочтителен.
Аналогично можно поступить и с foreach. Использовать элементы массива по значению:

foreach ($arr as $item) {
…
}

или по ссылке:

foreach ($arr as &$item) {
...
}

Циклические ссылки и сборщик мусора в PHP

Управление памятью в PHP: циклические ссылки и сборщик мусора

В прошлых частях урока мы рассмотрели структуру zval, разобрались как именно происходит присваивание и передача в функцию, рассмотрели разницу для разных типов. В предыдущих уроках мы рассматривали ситуации, когда вместе с удалением всех имён для какого-либо значения происходило уменьшение числа ссылок на значение до 0. В результате чего значение благополучно удалялось. Однако так происходит далеко не всегда, и об этих ситуациях мы сейчас и поговорим.

Для начала хотелось бы познакомить вас с функцией xdebug_debug_zval($var_name);

Она становится доступной после включения расширения Xdebug.
Рассмотрим следующий пример:

<?php
$a = 123;
$b = $a;
xdebug_debug_zval('a');

Результат будет следующим:

a: (refcount=2, is_ref=0)=123

Помните, в прошлых статьях мы говорили о полях структуры zval, одно из которых отвечает за количество ссылок на значение (не ссылок через &, а связей между именем и значением), а второе говорит о том, используется ли значение по ссылке (здесь уже в том смысле, когда мы используем &).

Как Вы уже поняли, первое из них это — refcount, а второе — is_ref. Отныне будем использовать именно эти названия. Так вот эта функция выводит эти значения в результате передачи в неё имени переменной, имя передаётся строкой. В нашем случае - 'a'. Теперь, используя эту функцию, Вы можете самостоятельно проверить все результаты, полученные нами в предыдущих двух статьях. Настоятельно рекомендую Вам это проделать. Уверен, Вам уже интересно проверить не только то, что мы разбирали ранее, желаю Вам удачи в Ваших экспериментах ;)

Циклические ссылки

Так вот о чём это я начал говорить в начале. Как возможна ситуация, когда при удалении всех имён, ссылающихся на значение, число ссылок на это значение останется больше 0 и значение продолжит оставаться в памяти.
Напишем код, реализующий следующее: добавить в массив новым элементом самого себя и вывести получившееся значение zval.
Получится следующее:

<?php
$a = [];
$a[] = &$a;
xdebug_debug_zval('a');

Результат вывода:

a: (refcount=2, is_ref=1)=array (0 => (refcount=2, is_ref=1)=…)

Теперь, если мы сделаем unset($a), refcount уменьшится до 1, значение перестанет быть доступным нам по какому-либо имени и повиснет в памяти. К сожалению, проверить это с помощью только что ставшей известной для нас функции невозможно — имени-то нет. Попробуем для проверки этого заявления прибегнуть к изученной нами ранее функции memory_get_usage(). Для этого поместим в массив элемент, представляющий из себя строку из 100 000 символов. Этого будет достаточно, чтобы заметить изменения в памяти. Для создания такой строки прибегнем к функции str_repeat(). Код получится следующим:

<?php
$a = [];
echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;
$a[] = str_repeat('a', 100000);
$a[] = &$a;
echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;
unset($a);
echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;

Результат:

123 KB
220 KB
220 KB

Как мы видим, память не освободилась. Попробуем не добавлять в массив элемент, ссылающийся на этот массив:

<?php
$a = [];
echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;
$a[] = str_repeat('a', 100000);
echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;
unset($a);
echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;

Результат:

123 KB
220 KB
122 KB

Здесь мы видим, что размер запрашиваемой скриптом памяти вернулся к начальному значению. В первом же случае этого не произошло. Так как мы при этом не можем удалить эти «неиспользуемые» данные, то здесь имеет место быть утечка памяти. Примеры, подобные данному встречаются в коде довольно часто. Особенно когда речь идёт об объектах — во многих местах происходит их неявное использование по ссылке, в результате чего один объект в одном из своих свойств может начать содержать ссылку на себя самого.

Как правило, PHP используется непосредственно «для сайтов» и после завершения запроса эти данные будут удалены. Если такая утечка произойдёт в паре мест, то в большинстве случаев ничего ужасного не произойдёт. Однако если это долгоживущий скрипт, запущенный, например в CLI-режиме, то это может привести к выжиранию всей доступной памяти. К счастью, в PHP есть сборщик мусора, или по-английски "garbage collector".

Сборщик мусора (garbage collector)

По умолчанию сборщик мусора всегда включён. Это задаётся директивой zend.enable_gc в файле php.ini.
Он вызывается… На самом деле это довольно долго и сложно объяснять. Для тех, кто всё же хочет узнать прямо сейчас — прошу сюда.
Так вот, скажу я Вам, он сам прекрасно знает, когда приходит его время. Мы же остановимся на практическом варианте и посмотрим на него в действии. Итак, этот механизм вызывается при определённых условиях и занимается тем, что удаляет ненужные значения, возникшие в результате работы с циклическими ссылками. Напишем код, в бесконечном цикле которого будут создаваться объекты, содержащие ссылки на самих себя с последующим удалением их имён. На каждой тысячной итерации будем выводить размер используемой памяти. Код:

<?php
$i = 0;
while (true) {
    $obj = new stdClass();
    $obj->foo = $obj;
    if ($i++ % 1000 === 0) {
        echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;
    }
}

Результат:

603 KB
790 KB
978 KB
1165 KB
1353 KB
1540 KB
1728 KB
1915 KB
2103 KB
2290 KB
603 KB
...

Как мы видим, размер используемой памяти некоторое время возрастает, а затем возвращается к исходному — это сборщик мусора в деле. Круто, да?
А теперь сделаем так, чтобы объект содержал в себе приличный объём данных:

<?php
$i = 0;
while (true) {
    $obj = new stdClass();
    $obj->foo = $obj;
    $obj->bar = str_repeat('a', 100000);
    if ($i++ % 1000 === 0) {
        echo (int)(memory_get_usage() / 1024) . ' KB' . PHP_EOL;
    }
}

Вывод:

799 KB
98736 KB
196674 KB
294611 KB
392549 KB
490486 KB
588424 KB
686361 KB
784299 KB
882236 KB
799 KB
98736 KB

Вроде бы всё хорошо, однако, если Вы запустите этот скрипт у себя, то увидите ощутимые фризы в работе в момент работы garbage collector’а, где-то на секунду выполнение скрипта остановится. Это время напрямую зависит от объема высвобождаемой памяти. И в предыдущем варианте, где объекты были довольно малы работа garbage collector’а на первый взгляд незаметна. Однако, работа скрипта всё же будет на некоторое время остановлена. В этом его минус, за всё приходится платить. Как правило, Вы вряд ли от этого пострадаете, однако, об этом следует помнить, особенно при работе с большими данными. Если есть фризы и для Вас это критично — Вы знаете в какую сторону теперь копать.

А вот теперь все дружно идём в документацию и читаем про то, когда же всё-таки будет вызван сборщик мусора - Сбор циклических ссылок

Спасибо за прочтение, желаю Вам всего хорошего ;)

loader
19.11.2023 в 20:37
24407
+2213
Логические задачи с собеседований