Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь

в 12:56, , рубрики: Garbage collection, garbage collector, php, Программирование, сборка мусора, сборщик мусора, Серверная оптимизация

Содержание

  1. Введенние.

  2. Zval.

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

  4. Сборщик мусора.

  5. Алгоритм работы сборщика мусора.

  6. Смотрим глазами.

  7. Слабые ссылки.

  8. Бонус-трэк: WeakMap.

  9. Заключение.

Введенние

В PHP память для всех наших переменных выделяется динамически и совершенно незаметно для программиста. Каждый раз, когда вы что-то записываете в переменную - вы увеличиваете потребление памяти. И обычно вы только это и делаете - говорите PHP: дай, дай, ещё дай. График потребления памяти всё время рос бы вверх, пока не достиг memory_limit и PHP не выдал бы вам Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 54 bytes). Рос бы, если бы в язык не было встроено несколько механизмов высвобождения этой памяти. И каждый из них запускается в строго определённые моменты работы:

  1. При выходе локальной переменной из области видимости

  2. При достижении счётчика ссылок на переменную нуля

  3. При работе сборщика мусора

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

Область видимости - это контекст (или окружение) в котором определена переменная. Я не буду останавливаться на всевозможных вариантах областей видимости т. к. нас по большей части интересует только контекст локальных переменных функции. При завершении работы функции, память занимаемая её локальными переменными будет освобождена.

<?php

function doSmth(int $baseMemory): void {
  $array = range(0, 99);
  printf('memory: %s%s', memory_get_usage() - $baseMemory, PHP_EOL);
}

$baseMemory = memory_get_usage();
doSmth($baseMemory);
echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;
memory: 2616
memory: 32

Здесь, после завершения doSmth память занимаемая локальной переменной $array высвобождается, а сама переменная исчезает с арены (ей-богу не знаю, что за 32 байта остаются занятыми, должно быть 0 (напишите, если вы в курсе в комментариях)). Тут всё просто. Для того, чтобы разобраться с оставшимися двумя вариантами нам нужно понять как PHP хранит наши переменные.

Zval

Zval (Zend value) - это структура на языке C, которую PHP использует для представления значений переменных. По-другому говорят, что это zval-контейнер переменной. Из-за сложностей реализации всех этих сишных структур и их взаимосвязей, мы будем рассматривать их упрощённую схему. Если же вам хочется деталей, то пожалуйте сюда. Zval содержит в себе 4 свойства (или поля), нас интересуют только одно - refcount - количество переменных ссылающихся на zval-контейнер. Посмотрим на всё это в коде, так будет понятней:

<?php

$a1 = uniqid();
xdebug_debug_zval('a1');
$baseMemory = memory_get_usage();
$a2 = $a1;
xdebug_debug_zval('a1');
xdebug_debug_zval('a2');
echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;

Вывод:

a1: (refcount=1, is_ref=0)='64bb8f0969519'
a1: (refcount=2, is_ref=0)='64bb8f0969519'
a2: (refcount=2, is_ref=0)='64bb8f0969519'
memory: 0

На строке 3 мы определяем переменную $a1 в которую записываем случайную строку. Затем используя функцию расширения Xdebug распечатываем информацию о zval-контейнере на который ссылается переменная. И как мы видим refcount=1. Далее идёт присвоение $a2 = $a1 и после этого refcount равен уже двум, причём у обеих переменных т.е. обе переменные ссылаются на один и тот же zval-контейнер, который и хранит в себе значение переменной. Поэтому и кол-во потребляемой памяти не увеличивается. Визуально это можно представить так:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 1

Как было сказано выше, PHP высвободит память, занимаемую zval-контейнером, когда счётчик ссылок уменьшиться до нуля. Сделать это можно при помощи конструкции unset. Давайте применим её к обеим переменным попутно глядя на refcount:

<?php

$baseMemory = memory_get_usage();
$a1 = uniqid();
echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;
xdebug_debug_zval('a1');
$a2 = $a1;
xdebug_debug_zval('a1');
xdebug_debug_zval('a2');
unset($a2);
xdebug_debug_zval('a1');
unset($a1);
xdebug_debug_zval('a1');
echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;
memory: 72
a1: (refcount=1, is_ref=0)='64bb903b34b0a'
a1: (refcount=2, is_ref=0)='64bb903b34b0a'
a2: (refcount=2, is_ref=0)='64bb903b34b0a'
a1: (refcount=1, is_ref=0)='64bb903b34b0a'
a1: no such symbol
memory: 32

Как видим после первого вызова unset refcount уменьшился с 2 до 1, а после второго до 0. Но мы этого не видим, а видим сообщение: no such symbol. PHP называет переменные символами и хранит их в символьных таблицах. При удалении переменой она удаляется и из таблицы символов (no such symbol [in symbol table]). Память при этом тоже сбросилась до 0 (нет, не до нуля, проклятые 32 байта опять портят всю картину).

Вот мы и разобрались со вторым механизмом освобождения памяти. Полезный вывод, который можно сделать из этого - это то, что не нужно стесняться использовать unset в коде. Если у вас есть переменная, которая занимает много памяти и она в какой-то момент вам больше не нужна, смело убивайте её. Ну и убедитесь, что кто-то другой не ссылается на тот же zval. Не надо жать, когда PHP выдаст вам сообщение о том, что доступная память исчерпана.

Для непосредственного взаимодействия со zval'ами в PHP и его расширениях есть ещё пара функций:

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

Циклические ссылки образуются, когда у вас есть два объекта, которые ссылаются друг на друга. Типичной структурой данных использующей циклические ссылки является, например, двусвязный список. Или отношение потомок - родитель, когда потомки имеют ссылку на родителя, а родитель на потомков. Когда говорят о циклических ссылках обычно имеют ввиду конструкции с циклическими ссылками. Вот пример такой конструкции:

<?php

$baseMemory = memory_get_usage();

$person1 = new stdClass();
$person1->value = range(1, 100);

echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;

$person2 = new stdClass();
$person2->value = range(1, 100);

echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;

$person1->partner = $person2;
$person2->partner = $person1;

unset($person1);
unset($person2);

echo 'memory: ', memory_get_usage() - $baseMemory, PHP_EOL;
memory: 3064
memory: 6096
memory: 6096

Как видите не взирая на unset память не высвободилась. Почему? Потому что refcount переменных $person1 и $person2 не стал равен 0 после вызова unset. Давайте по порядку. После выполнения 5-ой строки схема взаимодействия переменных и zval'ов будет такая:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 2

После введения в код $person2 на 10-ой строке такой:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 3

Затем выполняем $person1->partner = $person2 и получаем такую картину:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 4

Теперь на zval2 две ссылки: c $person2 и c $person1->partner, refcount равен 2. После выполнения $person2->partner = $person1 будет так:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 5

Refcount у обоих zval'ов равен двум. Делаем unset($person1):

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 6

И unset($person2):

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 7

Всё. Переменных $person1 и $person2 больше нет, мы не можем дотянуться до контейнеров, но они есть т.к. каждый ссылается друг на друга у каждого из них refcount не равен 0, а единице. А PHP удаляет только те переменные, у которых refcount равен 0. Т. о. не взирая на нашу попытку высвободить память она будет занята навсегда. Тут-то на сцену и выходит сборщик мусора.

Сборщик мусора

Сборщик мусора умеет разрешать циклические ссылки и высвобождать занимаемую таким образом память. В разделе документации «Garbage Collection» говорится, что начиная с версии 5.3.0 в PHP был внедрён алгоритм, описанный в статье «Concurrent Cycle Collection in Reference Counted Systems».

Для взаимодействия со сборщиком мусора у PHP есть ряд функций, названия которых начинаются на gc_. Сборщик можно включить, выключить, вызвать вручную и получить статистику. Он не работает непрерывно, а запускается, когда в системе накопится десять тысяч подозрительных объектов. До этого, все они будут складываться в так называемом root-буфере (root buffer). Да, PHP не хочет во время работы скрипта проверять каждый раз циклическая это ссылка или какая-то иная. Он откладывает это на потом. Но как определяется эта подозрительность? Как понять, что у объекта потенциально может возникнуть циклическая зависимость?

В статье сказано:

There are two observations that are fundamental to these algorithms. The first obser- vation is that garbage cycles can only be created when a reference count is decremented to a non-zero value — if the reference count is incremented, no garbage is being created, and if it is decremented to zero, the garbage has already been found.

Иными словами подозрительными становятся те объекты, refcount которых уменьшился хотя бы один раз, но при этом не достиг нуля. По такой схеме в буфер могут попасть совершенно невинные конструкции, но PHP будет разбираться с этим потом. Давайте посмотрим на такой код.

<?php

$a1 = new stdClass();
$a2 = $a1;
$a3 = $a1;
xdebug_debug_zval('a1');
print_r(gc_status());
unset($a3);
print_r(gc_status());
a1: (refcount=3, is_ref=0)=class stdClass {  }
Array
(
    [runs] => 0
    [collected] => 0
    [threshold] => 10001
    [roots] => 0
)
a1: (refcount=2, is_ref=0)=class stdClass {  }
Array
(
    [runs] => 0
    [collected] => 0
    [threshold] => 10001
    [roots] => 1
)

Видим, что на refcount переменной $a равен 3, а в статистика по сборщику показывает следующее:

  1. [runs] => 0, сборщик запускался 0 раз

  2. [collected] => 0, было собрано 0 переменных

  3. [threshold] => 10001, размер буфера (а не 10000 тысяч, как я говорил выше)

  4. [roots] => 0, кол-во элементов в буфере

Затем мы делаем unset($a3) уменьшаая refcount на единицу с 3 до 2 и zval-контейнер, как потенциально проблемный отправляется в root buffer ([roots] => 1). Очевидно, что никаких циклических ссылок тут нет: абсолютно плоская структура, просто теперь вместо трёх переменных на zval ссылаются две.

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 8

Что же касается структуры, созданной нами ранее, то теперь с учётом знания о root-буфере мы можем изобразить её так:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 9

Как бы там ни было, мусор возникает при декременте счётчика ссылок.

Алгоритм работы сборщика мусора

Это опциональная глава. Я честно сам не до конца понял подробностей работы алгоритма. Главное, что он работает и мы убедимся в этом в следующей главе. А как именно - да Бог с ним. Я и вам предлагаю не заморачиваться на этих деталях. Но если вдруг, то смотрите статью и, например, вот этот пост: «Ломаем сбор мусора и десериализацию в PHP».

Алгоритм использует цветовую раскраску и выполняется в 2 этапа (и несколько шагов):

  1. Накопление мусора: шаги 1 и 2 из списка ниже.

  2. Очистка мусора: шаги 3 - 7 из списка ниже.

По шагам:

  1. Изначально, все свежесозданные zval-контейнеры имеют дефолтный чёрный цвет. Естественно, чтобы больше всех запутать на всех моих графиках zval'ы белые.

  2. Когда refcount zval'а уменьшается и не достигает 0, он окрашивается пурпурным и отправляется в root buffer.

  3. После того, как root buffer набьётся кандидатами на удаление, сборщик пробегается по всем элементам буфера осуществляя поиск в глубину и уменьшает refcount у всех zval'ов на 1. Так же он помечает их серым цветом, чтобы не уменьшить у одного и того же zval'а refcount несколько раз.

  4. Затем забег по всем элементам буфера методом поиска в глубину повторяется снова и все zval'ы c refcount равным нулю помечаются белым. А у zval'ов с refcount больше нуля, значение счётчика восстанавливается в исходное состояние и они помечаются снова чёрным.

  5. Последним шагом сборщик снова проходится по буферу удаляя из него zval'ы. Zval'ы помеченные белым удаляются вообще с высвобождением занимаемой памяти.

Смотрим глазами

Всё, время слов прошло, переходим к делу. Ниже код, аналогичный тому, что мы видели выше, с циклическими зависимостями. Запускаем его в цикле for и на каждой итерации замеряем время и кол-во потребляемой памяти.

<?php

$time = microtime(true);

for ($i = 0; $i < 50000; $i ++) {
    $person1 = new stdClass();
    $person1->value = range(1, 100);

    $person2 = new stdClass();
    $person2->value = range(1, 100);

    $person1->partner = $person2;
    $person2->partner = $person1;

    echo microtime(true) - $time, ';', memory_get_usage() / 1024 / 1024, PHP_EOL;
}

Рисуем полученные данные:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 10

Классическая картина работы сборщика. Давайте разбираться, что тут видно.

  • На каждой итерации создаётся два объекта и они отправляются в root-буфер. 10001 / 2 = 5000.5. А кол-во итераций в цикле 50000. Вот мы и видим 10 взлётов и 9 падений. Если бы я поставил 50005 итераций, то увидели бы ещё один сброс памяти.

  • Скрипт отработал примерно за 1 секунду.

  • За 5000 итераций PHP накапливает чуть меньше 30 мегабайт мусора. Очистка происходит практически мгновенно.

Хорошо, давайте попробуем аккумулировать больше памяти и посмотрим, что будет. Заменим range(1, 100) на range(1, 4000) и установим memory_limit в 1 гигабайт. Если не увеличить memory_limit, то раньше чем запуститься сборщик мусора PHP свалится с ошибкой: PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 69632 bytes) in...

<?php

ini_set('memory_limit', '1G');
$time = microtime(true);

for ($i = 0; $i < 50000; $i ++) {
    $person1 = new stdClass();
    $person1->value = range(1, 4000);

    $person2 = new stdClass();
    $person2->value = range(1, 4000);

    $person1->partner = $person2;
    $person2->partner = $person1;

    echo microtime(true) - $time, ';', memory_get_usage() / 1024 / 1024, PHP_EOL;
}

График поменялся:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 11

Теперь, чтобы высвободить примерно 700 мегабайт памяти PHP тратит примерно 0.2 секунды. Тенденция ясна. На высвобождение нужно время. Но не только. Нужны ещё ресурсы процессора. Я запустил этот скрипт ещё раз и параллельно запустил Docker Desktop. Картинка стала такой:

Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 12
  • Первое, что бросается в глаза - это кривизна графика. Да, при наличии рядом тяжеловесного процесса нуждающегося в CPU всё выглядит не так радужно. Память выдают уже с задержками (это ступеньки). Но и высвобождается она теперь местами за 0.6 секунд, а не за 0.2.

  • Общее время работы скрипта увеличилось с 6 до 10 секунд.

  • Видно, как первые секунды я вспоминал, что я хотел запустить (а, docker!), а потом как его запустить )

Слабые ссылки

Ожидать когда буфер сборщика забьется мусором слишком беспечная политика [бездействия]. Начиная с версии 7.4.0 у PHP появился механизм слабых ссылок, которые позволяют разруливать проблемы с циклическими ссылками самостоятельно, не дожидаясь когда PHP остановит ваш скрипт и начнёт прибирать за вами. Давайте перейдём к коду и модифицируем пример выше:

<?php

ini_set('memory_limit', '1G');
$time = microtime(true);

for ($i = 0; $i < 50000; $i ++) {
    $person1 = new stdClass();
    $person1->value = range(1, 4000);

    $person2 = new stdClass();
    $person2->value = range(1, 4000);

    $person1->partner = WeakReference::create($person2);
    $person2->partner = WeakReference::create($person1);

    echo microtime(true) - $time, ';', memory_get_usage() / 1024 / 1024, PHP_EOL;
}
Управление памятью в PHP. Сборка мусора, слабые ссылки и прочая челядь - 13

Неплохо, да? И по памяти, и по времени. Полмегабайта против 700 и полторы секунды против 6. Можно предположить, что разница в 4.5 секунды уходит на запросы выделения памяти, те самые: дай, дай, ещё дай. Как же это работает? Очень просто: слабая ссылка не увеличивает refcount zval'а. Т. о. на каждой итерации при переопределении $person1 и $person2 их refcount'ы равные 1 сбрасываются до нуля и память высвобождается тут же: в root buffer ничего не попадает, 0 циклов сборки мусора.

Слабые ссылки не являются особенностью PHP. Подобные конструкции в различных ипостасях есть и в других подобных языках программирования:

Бонус-трэк: WeakMap

WeakMap - это новая фича доступная с версии 8.0. Работает похожим на WeakReference образом. В документации к классу почему-то настойчиво предлагается использовать его для кэширования. Но в первую очередь нужно понимать, что WeakMap - это карта и предназначена она для связывания ключа и значения, как в обычном ассоциативном массиве. Только вместо ключа используется объект. Refcount объекта при добавлении его в карту не увеличивается, также как и при использовании WeakReference. Т.е. при удалении объекта, весь тот багаж, который вы связали с объектом удалиться так же. Смотрите пример в официальной документации. Вот ещё один:

<?php

class Person {

  private $name;
  private $value;

  public function __construct(string $name) {
    $this->name = $name;
    $this->value = range(1, 1000);
  }

  public function __destruct() {
      echo __METHOD__, ': ', $this->name, PHP_EOL;
  }
}

$map = new WeakMap();
$baseMemory = memory_get_usage();

$person = new Person('Сергей Иванович Петров');

$map[$person] = [
  'father'   => new Person('Иван Лаврентьевич Петров'),
  'mother'   => new Person('Мария Васильевна Суркова'),
  'wife'     => new Person('Ирина Витальевана...'),
  'son'      => new Person('Санька'),
  'daughter' => new Person('Манька'),
];

echo memory_get_usage() - $baseMemory, PHP_EOL;
unset($person);
echo memory_get_usage() - $baseMemory, PHP_EOL;
124712
Person::__destruct: Сергей Иванович Петров
Person::__destruct: Иван Лаврентьевич Петров
Person::__destruct: Мария Васильевна Суркова
Person::__destruct: Ирина Витальевана...
Person::__destruct: Санька
Person::__destruct: Манька
672

Всё семейство при утрате единственного кормильца погибает.

Заключение

Следите за памятью, следите за собой. Хватит греть планету :)

P.S. Если вдруг вы захотите сгенерить такие же графики работы сборщика мусора, то вам сюда.

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

echo microtime(true) - $time, ';', memory_get_usage() / 1024 / 1024, PHP_EOL;

Например так:

$ php gc.php > gc.csv
$ head -n 3 gc.csv
3.6954879760742E-5;0.38493347167969
0.00029397010803223;0.39071655273438
0.00033807754516602;0.39649963378906
...

Затем вам нужно установить R скачав его отсюда. Cохраните этот код в файле chart.R:

if (! ("ggplot2" %in% rownames(installed.packages()))) {
  install.packages("ggplot2", repos = "https://mirror.truenetwork.ru/CRAN/")
}

library("ggplot2")

args = commandArgs(trailingOnly = T)

inCsv  = args[1]
outImg = sub(pattern = "(.*)\..*$", "\1.png", inCsv)

x = read.csv2(inCsv, F, dec=".")
colnames(x) = c("time", "memory")

plot = ggplot(x, aes(x = time, y = memory)) +
  geom_line(linewidth = 2, color = "#CC6666") +
  scale_x_continuous(labels = scales::number_format(accuracy = 0.01, big.mark = "")) +
  scale_y_continuous(labels = scales::number_format(big.mark = ""), limits = c(0, NA)) +
  ggtitle("PHP memory consumption") +
  xlab("Time, microseconds") +
  ylab("Memory, megabytes")

ggsave(outImg, plot, device = 'png', width = 12, height = 8, units = 'in')

Затем скармливаете .csv-файл этому скрипту так:

$ Rscript chart.R gc.csv
$ ls gc.png
gc.png

И рядом окажется .png-файл с таким же названием. Во время первого запуска R будет какое-то время скачивать зависимости, но это только один раз.

Автор: Александр Зеленин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js