В этой статье я хочу исследовать расход памяти у массивов (и значений в целом) в PHP используя следующий скрипт в качестве примера, который создаёт 100 000 уникальных целочисленных элементов массива и в конце измеряет количество использованной памяти.
Это перевод (для таких как я, которые этого часто не замечают).
В начале я хочу поблагодарить Johannes и Tyrael за их помощь в поисках укромных мест расхода памяти.
<?php
$startMemory = memory_get_usage();
$array = range(1, 100000);
echo memory_get_usage() - $startMemory, ' bytes';
Как вы думаете сколько получится? Если целое число это 8 байт (на 64 архитектурах и используя тип long) и есть 100 000 целых чисел, то, очевидно, потребуется 800 000 байт. Это около 0,76 Мб.
Теперь попробуйте запустить код. Это можно сделать on-line. В результате получится 14 649 024 байт. Да, вы не ослышались, это 13,97 Мб — в 18 раз больше, чем мы прикинули.
Итак, откуда появилось это 18 кратное увеличение?
Краткое изложение
Для тех, кто не хочет разбираться со всем этим, вот краткий обзор вовлечённых компонент.
| 64 bit | 32 bit
---------------------------------------------------
zval | 24 bytes | 16 bytes
+ cyclic GC info | 8 bytes | 4 bytes
+ allocation header | 16 bytes | 8 bytes
===================================================
zval (value) total | 48 bytes | 28 bytes
===================================================
bucket | 72 bytes | 36 bytes
+ allocation header | 16 bytes | 8 bytes
+ pointer | 8 bytes | 4 bytes
===================================================
bucket (array element) total | 96 bytes | 48 bytes
===================================================
total total | 144 bytes | 76 bytes
Приведённые выше числа могут меняться в зависимости от вашей операционной системы, компилятора и опций компилирования. Например, если вы компилируете PHP с debug или thread-safety, то получите различные значения. Но я думаю, что приведённые размеры вы увидите на рядовой сборке PHP 5.3 на 64 разрядном Линуксе.
Если умножить эти 144 байта на наши 100 000 чисел, то получится 14 400 000 байт, что составляет 13,73 Мб. Довольно близко к реальному результату, остальное — это в основном указатели для неинициализированных блоков(buckets), но я расскажу об этом позже.
Теперь, если вы хотите иметь более детальный анилиз значений, которые указаны выше, то читайте дальше :).
Объединение zvalue_value
Сначала давайте обратим внимание на то, как PHP хранит значения. Как вы знаете PHP является слаботипизированным языком, поэтому ему нужен способ быстрого переключения между значениями. PHP использует объединение (union), который определён следующим образом zend.h#307 (комментарии мои):
typedef union _zvalue_value {
long lval; // Для целых и булей
double dval; // Для чисел с плавающей точкой
struct { // Для строк
char *val; // состоит из строки как таковой
int len; // и её длины
} str;
HashTable *ht; // Для массивов (хеш-таблицы)
zend_object_value obj; // Для объектов
} zvalue_value;
Если вы не знаете C, то это не проблема — код очень прост: объединение означает, что значение может выступать в роли различных типов. Например, если вы используете zvalue_value->lval, то значение будет интерпретировано как целое число. С другой стороны, если используете zvalue_value->ht, то значение будет интерпретировано как указатель на хеш-таблицу (aka массив).
Не будем на этом задерживаться. Важным для нас только то, что размер объединения равен размеру его крупнейшего компонента. Самый большой компонент — это строка (на самом деле структура zend_object_value имеет тоже размер, но этот момент я опущу для простоты). Структура состоит из указателя (8 байт) и целого числа (4 байта). Итого 12 байт. Благодаря выравниванию памяти (структуры в 12 байт — это не круто, потому что они не являются произведением 64 бит/8 байт) конечный размер структуры будет 16 байт и, соответственно, всего объединения в целом.
Итак, теперь мы знаем, что нам нужно не 8 байт для каждого значения, а 16 — за счёт динамической типизации PHP. Умножив на 100 000 получим 1 600 000 байт, т.е. 1,53 Мб. Но реальный объём 13,97 Мб, поэтому мы не достигли пока цели.
Структура zval
Вполне логично, что union хранит только значение, а PHP, очевидно, нужно хранить так же его тип и некоторую информацию для сборки мусора. Структура, которая содержит эту информацию, называется zval и вы, наверное, уже слышали о ней. Для получения дополнительной информации о том, зачем это PHP, я рекомендую прочитать статью Sara Golemon. Как бы то ни было эта структура определяется следующим образом:
struct _zval_struct {
zvalue_value value; // Значение
zend_uint refcount__gc; // Количество ссылок на значение (для GC)
zend_uchar type; // Тип
zend_uchar is_ref__gc; // Является ли это значение ссылкой (&)
};
Размер структуры определяется суммой размеров всех её компонент: zvalue_value — 16 байт (расчёт выше), zend_uint — 4 байта, zend_uchar — 1 байт каждый. В общей сложности 22 байта. Опять же из-за выравнивания памяти реальный размер будет 24 байта.
Так что, если мы храним 100 000 значений по 24 байта, то это будет 2 400 000 байт или 2,29 Мб. Разрыв сокращается, но реальное значение ещё более чем в шесть раз больше.
Сборщик мусора для циклических ссылок (PHP 5.3)
PHP 5.3 представила новый сборщик мусора для циклических ссылок. Для этого PHP хранит некоторую дополнительную информацию. Я не хочу здесь объяснять как это работает, вы можете почерпнуть необходимую информацию из мануала. Для наших расчётов размеров важно, что каждый zval оборачивается zval_gc_info:
typedef struct _zval_gc_info {
zval z;
union {
gc_root_buffer *buffered;
struct _zval_gc_info *next;
} u;
} zval_gc_info;
Как вы видите Zend только добавляет объединение, которое содержит два указателя. Как вы помните размер объединения определяется самым большим компонентом. Оба компонента — это указатели по 8 байт. Соответственно, размер объединения тоже 8 байт.
Если мы добавим полученные выше 24 байта, то мы получим 32 байта. Умножаем это на 100 000 и полуаем 3,05 Мб.
Менеджер памяти ZEND
C в отличие от PHP не управляет памятью за вас. Вы должны самостоятельно следить за распределением памяти. Для этого PHP использует оптимизированный для своих нужд собственный менеджер памяти: The Zend Memory Manager. MM Zend основан на malloc от Doug Lea и всяческих дополнительных специфических для PHP особенностей и оптимизаций (таких как ограничение памяти, очистка после каждого запроса и тому подобное).
Что важного для нас в этом так это то, что MM добавляет заголовок для каждого выделения памяти, которое проходит через него. И определяется следующим образом:
typedef struct _zend_mm_block {
zend_mm_block_info info;
#if ZEND_DEBUG
unsigned int magic;
# ifdef ZTS
THREAD_T thread_id;
# endif
zend_mm_debug_info debug;
#elif ZEND_MM_HEAP_PROTECTION
zend_mm_debug_info debug;
#endif
} zend_mm_block;
typedef struct _zend_mm_block_info {
#if ZEND_MM_COOKIES
size_t _cookie;
#endif
size_t _size; // выделенный размер
size_t _prev; // предыдущий блок (не уверен что это)
} zend_mm_block_info;
Как вы видите определение включает в себя множество проверок опций компилирования. Если хотя бы одна из эти опций будет включена заголовок для выделенной памяти будет больше, и будет он самым большим, если вы скомпилируете PHP с защитой кучи(heap protection), защитой потоков(thread safety), отладкой и MM cookies.
Для примера мы будем считать, что все эти опции отключены. В этом случае остается только две компоненты size_t _size и _prev. size_t занимет 8 байт (64 бита), так что заголовок имеет размер в 16 байт — и этот заголовок добавляется для каждого выделения памяти.
Так что мы должны скорректировать размер zval снова. На самом деле это будет не 32 байта, а 48, из-за этого заголовка. Умножаем на наши 100 000 элементов и получаем 4,58 Мб. Реальный размер 13,97 Мб, так что мы уже покрыли примерно треть.
Блоки
До сих пор мы рассматривали значения по отдельности. Но структура массива в PHP забирает много места. На самом деле термин «Массив» здесь подобран неудачно. В PHP массив — это на самом деле хеш таблицы/словари. Так как же хэш-таблицы работают? В основном для каждого ключа генерируется хэш, и этот хэш используется для перехода в «реальный» C массив. Хэши могут конфликтовать, все элементы, которые имеют одинаковые хэши хранятся в связанном списке. При обращении к элементу PHP сначала вычисляет хэш, ищет нужный блок(bucket), и проходит по списку в поисках точного совпадения элемент за элементом. Блок определяется следующим образом (zend_hash.h#54):
typedef struct bucket {
ulong h; // Хэш (или ключ для целочисленных ключей)
uint nKeyLength; // Длина ключа (для строковых ключей)
void *pData; // Данные
void *pDataPtr; // ??? Что это ???
struct bucket *pListNext; // PHP массивы упорядочены. Указатель на следующий элемент
struct bucket *pListLast; // и на предыдущий
struct bucket *pNext; // Следущий элемент в этом (двойном) связном списке
struct bucket *pLast; // Предыдущий элемент в этом (двойном) связном списке
const char *arKey; // Ключ (для строковых ключей)
} Bucket;
Как вы видите необходимо хранить «груз» данных, чтобы получить абстрактный массив данных вроде такого, какой используется в PHP (массивы PHP являются массивами, словарями и связными списками в одно и тоже время, что, конечно, требует много данных). Размер отдельных компонент это: 8 байт для типа ulong, 4 байта для uint и 7 раз по 8 байт для указателей. В результате получается 68. Добавляем выравнивание и получаем 72 байта.
Для блоков как и для zval должны быть добавлены заголовки в 16 байт, что даёт нам 88 байт. Так же нам нужно хранить указатели на эти блоки в «настоящем» массиве C (Bucket **arBuckets;), я упомнил об этом выше, что добавляет ещё 8 байт на элемент. Так что в целом каждый блок расходует в 96 байтах памяти.
И так, если нам нужен блок для каждого значения — это будет 96 байт для bucket и 48 байт для zval, что составляет 144 байта в общей сложности. Для 100 000 элементов это будет 14 400 000 байт или 13,73 Мб.
Загадка решена.
Подождите, осталось ещё 0,24 Мб!
Эти последние 0,24 Мб обусловлены неинициализированными блоками: размер «реального» массива C в идеале должен быть равен количеству элементов. Таким образом мы получаем наименьшее количество коллизий (если вы не хотите тратить много памяти). Но PHP, очевидно, не может перераспределять весь массив каждый раз когда добавляется новый элемент — это было бы ооочень медленно. Вместо этого PHP всегда удваивает размер внутреннего массива блоков, если оно попадает в предел. Таким образом, размер массива всегда является степенью двойки.
В нашем случае это 2 ^ 17 = 131 072. Но нам нужно только 100 000 из этих блоков, поэтому мы оставляем 31 072 блока неиспользованными. Те, память под эти блоки выделена не будет (поэтому нам не надо тратить полные 96 байт), но память под указатель(который хранится в внутреннем массиве блоков) на блок должна быть использована. Поэтому мы дополнительно используем 8 байт (на указатель) * 31 072 элементов. Это 248 576 байт или 0,23 Мб. Что соответствует недостающей памяти. (Конечно, отсутствуют ещё несколько байт, но я не хочу полностью покрыть всё. Это такие вещи как сама структура хэш-таблицы, переменные и т.д.)
Загадка действительно решена.
О чём нам это говорит?
PHP не C. И это говорит нам только об этом. Вы не можете ожидать от супер-динамического языка PHP эффективного использования памяти как в C. Не можете и всё.
Но, если вы хотите сохранить память вы можете рассмотреть использование SplFixedArray для больших статических массивов.
Посмотрим на модифицированный скрипт:
<?php
$startMemory = memory_get_usage();
$array = new SplFixedArray(100000);
for ($i = 0; $i < 100000; ++$i) {
$array[$i] = $i;
}
echo memory_get_usage() - $startMemory, ' bytes';
В основном он делает то же самое, но, если вы его запустите, то вы заметите, что он использует «всего лишь» 5 600 640 байт. Что составляет 56 байт на элемент, а это намного меньше, чем 144 байта на элемент обычного массива. Это происходит потому, что фиксированный массив не нуждается в bucket структуре: так что требуется только один zval (48 байт) и один указатель (8 байт) для каждого элемента, что даст нам наблюдаемые 56 байт.
P.S. Все замечания по поводу перевода прошу писать в ЛС, а я постараюсь оперативно их исправить.
Автор: sectus