Работа с памятью (и всё же она есть)

:

Существует распространенное мнение, что «рядовому» PHP разработчику практически не нужно заботиться об управлении памятью, однако «заботиться» и «знать» всё же немного разные понятия. Попытаюсь осветить некоторые аспекты управлению памятью при работе с переменными и массивами, а также интересные «подводные камни» внутренней оптимизации PHP. Как вы сможете убедиться, оптимизация это хорошо, но если не знать как именно она «оптимизирует», то можно столкнуться с «неочевидными граблями», которые могут вас заставить изрядно понервничать.

Общие сведения


Небольшой ликбез

Переменная в PHP как бы состоит из двух частей: " имени", которое хранится в hash_table symbol_table, и " значения", которое хранится в zval контейнере.
Такой механизм позволяет создавать несколько переменных ссылающихся на одно значение, что в отдельных случаях позволяет оптимизировать потребление памяти. О том, как это выглядит на практике будет написано далее.

Наиболее частыми элементами кода, без которых сложно себе представить более менее функциональный скрипт, являются следующие моменты:
— создание, присвоение и удаление переменных (чисел, строк и т.п.),
— создание массивов и их обход (в качестве примера будет использована функция foreach),
— передача и возврат значений для функций/методов.

Именно об этих аспектах работы с памятью и будет последующее описание. Получилось достаточно объемно, но ничего мега-сложного не будет и всё будет достаточно просто, очевидно и с примерами.

Первый пример работы с памятью

Для начала базовый пример того, как будет производиться анализ потребления памяти.
Для этого нам потребуется пара простых функций (файл func.php):
<?php
function memoryUsage ( $usage ,  $base_memory_usage )  {
printf ( "Bytes diff: %d\n" ,  $usage  -  $base_memory_usage ) ;
}
function someBigValue ( )  {
return  str_repeat ( 'SOME BIG STRING' ,  1024 ) ;
}
?>

И простой первый пример теста потребления памяти для строки:

<?php
include ( 'func.php' ) ;
echo  "String memory usage test.\n\n" ;
$base_memory_usage  =  memory_get_usage ( ) ;
$base_memory_usage  =  memory_get_usage ( ) ;
echo  "Start\n" ;
memoryUsage ( memory_get_usage ( ) ,  $base_memory_usage ) ;
$a  = someBigValue ( ) ;
echo  "String value setted\n" ;
memoryUsage ( memory_get_usage ( ) ,  $base_memory_usage ) ;
unset ( $a ) ;
echo  "String value unsetted\n" ;
memoryUsage ( memory_get_usage ( ) ,  $base_memory_usage ) ;
?>

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

Результат кода вполне очевиден:

String memory usage test.

Start
Bytes diff: 0
String value setted
Bytes diff: 15448
String value unsetted
Bytes diff: 0

Тот же самый пример, но вместо unset($a) используем $a=null;:

Start
Bytes diff: 0
String value setted
Bytes diff: 15448
String value set to null
Bytes diff: 76

Как видите, переменная не была полностью уничтожена. Под нее остается выделенным еще 76 байт.
Достаточно прилично, если учесть, что ровно столько же выделяется и под переменные типа boolean, integer, float. Речь идет не об объеме памяти, выделяемой под значение переменной, а о полном потреблении памяти для хранения сведений о присвоенной переменной (zval контейнер со значением и само имя переменной).
Так что если вы хотите освободить память при помощи присвоения, то не является принципиальным присвоение именно null значения. Выражение $a=10000; даст тот же результат для расхода памяти.

В документации PHP сказано, что приведение к null уничтожит переменную и ее значение, однако, по данному скрипту видно что это не так, что собственно является багом (документации).

Зачем использовать присвоение null, если можно unset()?
Присвоение — это присвоение, (спасибо КО), то есть изменяется значение переменной, соответственно, если новое значение требует меньше памяти, то она высвобождается сразу, однако это требует вычислительных ресурсов (пусть и сравнительно немного).
unset() в свою очередь освобождает память, выделенную под имя переменной и ее значение.
Отдельно стоит упомянуть момент, что unset() и присвоение null совершенно по разному работают со ссылками на переменные. Unset() уничтожит только ссылку, в то время как присвоение null изменит значение, на которое ссылаются имена переменных, соответственно все переменные станут ссылаться на значение null.

Примечание:
Встречается заблуждение, что unset() является функцией, однако, это не верно. unset() — это языковая конструкция (как например if), о чем прямо сказано в документации, соответственно ее нельзя использовать для обращения через значение переменной:

$unset_func_name  =  'unset' ;
$unset_func_name ($some_var ) ;

Немного дополнительной информации для праздных размышлений (при изменении примера выше):
$a = array();
выделит 164 байта, unset($a) всё вернет.

class A { }
$a = new A();
выделит 184 байта, unset($a) всё вернет.

$a = new stdClass();
выделит 272 байта, но после unset($a) «утекут» 88 байт (куда именно и почему они утекли, мне пока не удалось выяснить).

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

Массивы


Массивы в PHP «съедают» достаточно памяти, и именно в них как правило хранят значительные объемы данных при обработке, поэтому следует очень аккуратно относиться к работе с ними. Однако, работа с массивами в PHP имеет свои «прелести оптимизации» и об одном из таких моментов, связанных с потреблением памяти, стоит упомянуть.

Коварный пример №1

< ?php
include ( 'func.php' ) ;
echo  "Array memory usage example." ;
$base_memory_usage  = memory_get_usage ( ) ;
$base_memory_usage  = memory_get_usage ( ) ;
echo  'Base usage.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
$a  = array (someBigValue ( ), someBigValue ( ), someBigValue ( ), someBigValue ( ) ) ;
echo  'Array is set.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
foreach  ($a as $k =>$v )  {
$a [$k ]  = someBigValue ( ) ;
unset ($k, $v ) ;
echo  'In FOREACH cycle.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
}
echo  'Usage right after FOREACH.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
unset ($a ) ;
echo  'Array unset.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
? >

На первый взгляд может показаться, что потребление памяти массивом $a не будет меняться (за исключением установки переменных $k и $v), однако PHP имеет особенный подход при работе с массивами в этом случае.

Посмотрите на вывод:

Array memory usage example.Base usage.
Bytes diff: 0
Array is set.
Bytes diff: 61940
In FOREACH cycle.
Bytes diff: 77632
In FOREACH cycle.
Bytes diff: 93032
In FOREACH cycle.
Bytes diff: 108432
In FOREACH cycle.
Bytes diff: 123832
Usage right after FOREACH.
Bytes diff: 61940
Array unset.
Bytes diff: 0

Получается, что в последней итерации цикла foreach в данном случае потребление массивом памяти возросло в два раза, хотя по самому коду это не очевидно. Но сразу после цикла, потребление памяти вернулось к прежнему значению. Чудеса да и только.
Причиной тому является оптимизация использования массива в цикле. На время работы цикла, при попытке изменить исходный массив, неявно создается копия структуры массива (но не копия значений), которая и становится доступной по завершению цикла, а исходная структура уничтожается. Таким образом, в вышеприведенном примере, если вы присваиваете новые значения исходному массиву, то они не будут заменены сразу, а для них будет выделена отдельная память, которая будет возвращена по выходу из цикла.
Этот момент очень легко пропустить, что может привести к значительному потреблению памяти на время работы цикла с большими массивами данных, например при выборке из БД.

Примечание:
Внутри самого цикла, уже после изменения значения $a[$k], вы не сможете получить значение которое всё еще хранится в исходном массиве если не сохранили значение $v. Повторное обращение к $a[$k] выдаст уже новое значение.

Дополнение от пользователя zibada (в кратце):
Важно учесть, что выделение памяти под новый «временный массив» в случае внесения изменений, произойдет единовременно для всей структуры массива, но отдельно для каждого изменяемого элемента. Таким образом, если имеется массив с большим количеством элементов, (но не обязательно с большими значениями), то единовременное потребление памяти при таком копировании будет существенно.

Коварный пример №2
Чуть-чуть изменим код.

echo  'Array is set.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
$b  =  &$a ;  // Добавим это
foreach  ($a as $k =>$v )  {
$a [$k ]  = someBigValue ( ) ;
unset ($k, $v ) ;
echo  'In FOREACH cycle.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
}
unset ($b ) ;  // И это
echo  'Usage right after FOREACH.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;

Сам код цикла мы никак не меняли, единственное что мы изменили, это увеличили счетчик ссылок на исходный массив, но это в корне поменяло работу цикла:

Bytes diff: 0
Array is set.
Bytes diff: 61940
In FOREACH cycle.
Bytes diff: 61988
In FOREACH cycle.
Bytes diff: 61988
In FOREACH cycle.
Bytes diff: 61988
In FOREACH cycle.
Bytes diff: 61988
Usage right after FOREACH.
Bytes diff: 61940
Array unset.
Bytes diff: 0

Небольшое изменение: (61988 — 61940 = 48 байт на хранение переменной-ссылки $b).
В остальном же мы видим, что если массив, используемый для цикла, имеет больше чем одну ссылку на себя, тогда для него не применяется оптимизация из примера №1, т.е. для присвоения используется оригинальный массив.
Точно такой же результат мы получим, если используем для цикла массив $b или же используем в цикле передачу значения по ссылке:
echo  'Array is set.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
foreach  ($a as $k => &$v )  {
$a [$k ]  = someBigValue ( ) ;  // Или $v = someBigValue();
unset ($k, $v ) ;
echo  'In FOREACH cycle.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
}
echo  'Usage right after FOREACH.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;

Результат:

Bytes diff: 0
Array is set.
Bytes diff: 61940
In FOREACH cycle.
Bytes diff: 61940
In FOREACH cycle.
Bytes diff: 61940
In FOREACH cycle.
Bytes diff: 61940
In FOREACH cycle.
Bytes diff: 61940
Usage right after FOREACH.
Bytes diff: 61940
Array unset.
Bytes diff: 0

Здесь стоит отдельно отметить, что добавление передачи $v по ссылке хоть и не увеличивает счетчик ссылок исходного массива, но тоже приводит к отключению «оптимизации».

Передача по ссылке или передача через копирование

Рассмотрим случай, «что делать» если требуется передать в метод или функцию (или вернуть из них), какое-либо очень большое значение. Первым очевидным решением обычно рассматривают использование передачи/возвращения по ссылке.
Однако в документации по PHP сказано: Не используйте возврат по ссылке для увеличения производительности. Ядро PHP само занимается оптимизацией.
Попытаемся разобраться в том, что же это за «оптимизация».

Для начала самый простой пример (пока без передачи аргументов):


$a  = someBigValue ( ) ;
$b  = $a ;
echo  "String value setted" ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
unset ($a, $b ) ;
...

По «прямой логике», в памяти должно выделиться два блока под значение переменных. Однако PHP оптимизирует этот момент:
Start
Bytes diff: 0
String value setted
Bytes diff: 15496
String value unsetted
Bytes diff: 0

В данном случае 15448 байт занимается переменная $a, остальные же 48 байт выделены под переменную $b, хотя между ними и не установлена связь по ссылке. Данное потребление памяти сохраняется до тех пор, пока мы как-либо не изменим одну из этих переменных, а точнее сказать вообще что-либо не сделаем с ее значением, даже если мы его не меняем по факту:
$a  = someBigValue ( ) ;
$b  = $a ;
$b  = strval ($b ) ;
echo  "String value setted" ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
unset ($a, $b ) ;

В результате получим вывод:

Bytes diff: 0
String value setted
Bytes diff: 30896
String value unsetted
Bytes diff: 0

Как мы видим, попытка «тронуть» значение переменной $b приводит к тому, что теперь скрипт выделяет для ее хранения отдельную область памяти. То же самое произойдет если мы попытаемся «тронуть» значение $a.

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

$a  = array (someBigValue ( ), someBigValue ( ) ) ;  // 31052 байта
$b  = $a ;  // + 48 байт = 31100 байта
$b [ 0 ]  = someBigValue ( ) ;
echo  "String value setted" ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
unset ($a, $b ) ;

Данный пример даст выход:

Bytes diff: 0
String value setted
Bytes diff: 46704
String value unsetted
Bytes diff: 0

То есть в результате новая память (15к+ байт) была выделена для создания только копии значения для нулевого элемента массива, а не для всего массива $b. Значение $b[1] всё еще «оптимизированно связано» с $a[1].

Всё выше описанное действует аналогично и для передачи/возврата значений через «оптимизированное копирование» внутрь/из функций и методов. Если внутри метода вы никак не «трогаете» переданное значение, то для него не будет выделена отдельная область памяти (память будет выделена только под имя переменной, чтобы связать ее со значением). Если же вы передаете «через копирование» и изменяете значение внутри метода, то перед попыткой сделать изменение уже будет создана действительная полная копия значения.

Таким образом PHP действительно избавляет от необходимости использовать передачу по ссылке для оптимизации использования памяти. Передача по ссылке имеет практическое значение только если исходное значение требуется изменить с отображением этих изменений извне метода.

Код для примера:

< ?php
include ( 'func.php' ) ;
function testUsageInside ($big_value, $base_memory_usage )  {
echo  'Usage inside function then $big_value NOT changed.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
$big_value [ 0 ]  = someBigValue ( ) ;
echo  'Usage inside function then $big_value[0] changed.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
$big_value [ 1 ]  = someBigValue ( ) ;
echo  'Usage inside function then also $big_value[1] changed.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
}
echo  "Array memory usage example." ;
$base_memory_usage  = memory_get_usage ( ) ;
$base_memory_usage  = memory_get_usage ( ) ;
echo  'Base usage.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
$a  = array (someBigValue ( ), someBigValue ( ), someBigValue ( ), someBigValue ( ) ) ;
echo  'Array is set.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
testUsageInside ($a, $base_memory_usage ) ;
echo  'Usage right after function call.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
unset ($a ) ;
echo  'Array unset.'. PHP_EOL ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
? >

Вывод:

Array memory usage example.
Base usage.
Bytes diff: 0
Array is set.
Bytes diff: 61940
Usage inside function then $big_value NOT changed.
Bytes diff: 61940
Usage inside function then $big_value[0] changed.
Bytes diff: 77632
Usage inside function then also $big_value[1] changed.
Bytes diff: 93032
Usage right after function call.
Bytes diff: 61940
Array unset.
Bytes diff: 0

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

Исключительно в познавательных целях, стоит обратить внимание на эти два значения:

Array is set.
Bytes diff: 61940
Usage inside function then $big_value NOT changed.
Bytes diff: 61940

Потребление памяти не увеличилось при передаче управления в функцию, хотя по сути появилась новая переменная $big_value. Это связано с тем, что еще на стадии разбора текста скрипта интерпретатор определил будет ли эта функция использована в коде и заранее выделил для имен ее входных параметров место в памяти (если функция не используется, то интерпретатор ее игнорирует и не выделяет под нее память). А так как имеет место «оптимизированная передача через копирование», то уже существующее имя переменной $big_value было просто неявно «связано» с большим массивом $a. В результате было передано значение в функцию «через копирование» не потратив ни единого дополнительного байта.

Примечание:
В PHP5 (в отличие от PHP4), все объекты по-умолчанию передаются по ссылке, хотя по факту, это неполноценная ссылка. См. эту статью.

Краткие выводы


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

Отдельно стоило бы затронуть механизм расходования и оптимизации памяти при использовании объектов, однако ввиду обилия возможных примеров этот момент требует отдельной статьи. Возможно когда-нибудь.

PS: Можно было бы разбить это на несколько статей, но не вижу в этом смысла, так как подобную информацию лучше всё же хранить «вместе». Полагаю тем, кому данная информация несет практический смысл, так будет удобнее. Тестировалось на PHP 5.3.2 (Ubuntu 32bit), так что ваши значения по выделенным байтам могут отличаться.

Еще много полезного, но на английском:
nikic.github.com/2011/12/12/How-big-are-PHP-arrays-really-Hint-BIG.html
nikic.github.com/2011/11/11/PHP-Internals-When-does-foreach-copy.html
blog.golemon.com/2007/01/youre-being-lied-to.html
hengrui-li.blogspot.com/2011/08/php-copy-on-write-how-php-manages.html
sldn.softlayer.com/blog/dmcaloon/PHP-Memory-Management-Foreach
blog.preinheimer.com/index.php?/archives/354-Memory-usage-in-PHP.html
derickrethans.nl/talks/phparch-php-variables-article.pdf

UPD
В основной части статьи не был освещен важный момент.
Если есть переменная на которую создана ссылка, то при ее передаче в функцию в качестве аргумента она будет скопирована сразу, то есть не будет применена copy-on-write оптимизация.
Пример:

< ?php
include ( 'func.php' ) ;
function testFunc ($a, $base_memory_usage )  {
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;
}
$base_memory_usage  =  0 ;
$base_memory_usage  = memory_get_usage ( ) ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;  // 0 bytes
$a  = someBigValue ( ) ;
$b  =  &$a ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;  // 15496 bytes
testFunc ($a, $base_memory_usage ) ;  // 30896 bytes
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;  // 15496 bytes
unset ($a, $b ) ;
memoryUsage (memory_get_usage ( ), $base_memory_usage ) ;  // 0 bytes
? >