Сайт вебмастера

Производительность массивов в PHP

07-05-2024Время чтения ~ 8 мин.Albireo Framework / CMS 627

Обычно в программировании мы стараемся писать код так, чтобы свести потребление памяти к минимуму. Конечно, можно полагаться на внутренние алгоритмы PHP, но если этого не сделать, то можно выхватить ошибку о нехватки памяти. Во времена PHP 5 она встречалась достаточно часто, поскольку хостинги обычно ограничивали лимиты до 32MB. Даже если использовалась база данных, полученная выборка могла привести к нехватки памяти. Поэтому во всех случаях старались оптимизировать SQL-запросы так, чтобы они возвращали как можно меньше данных.

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

То есть в принципе ситуация понятна, но это если используется база данных. Но что делать, если приходится работать с «чистыми» данными?

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

Типичный сайт, скажем до 1000 файлов, очевидно вряд ли вызовет особые проблемы. Но если мы хотим сделать сайт из 10000 файлов? Как именно следует работать с таким большим массивом?

Причём речь не о простом массиве, а ассоциативном, где элементы могут быть разными. Это усложняет алгоритмы работы с массивом.

Например мы хотим получить все записи (файлы), где поле type равно blog. Но может быть и другая задача. Скажем, нужны записи, где tags равен astro, но при этом у самой записи это значение может быть указано через запятую:

tags: astro, sun, black hole

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

То есть суть в том, что работа с массивом может оказаться достаточно сложной и здесь возникает вопрос о производительности PHP при работе с большими массивами.

Для проверки своих гипотез, я, с помощью Python, сгенерировал несколько php-файлов примерно такого содержания:

<?php return [
    'c4ca4238a0b923820dcc509a6f75849b' => [
       'id' => '1',
 
       'bool1' => '0',
       'bool2' => '1',
       'bool3' => '1',
       'bool4' => '0',
       'bool5' => '0',
       
       'plus1' => '+',
       'plus2' => '+',
       'plus3' => '+',
       'plus4' => '+',
       'plus5' => '-',
       
       'date1' => '2019-09-26 02:35',
       'date2' => '2024-04-16 00:25',
       'date3' => '2020-05-10 17:51',
       'date4' => '2024-04-18 21:01',
       'date5' => '2020-06-09 10:45',
       
       'category' => 'astronomy, favorite, dino',
       'tags' => 'favorite, astronomy, dino',
       'type' => '',

    ],
и т.д. 10 тыс. элементов

Считывается этот файл обычным require():

$ar = require BASE_DIR . $fn;

Меня интересовало две вещи. Первая — скорость операций — по сути функций, которые работают с массивом. Это выборки и сортировки. Вторая — сколько при этом потребляется памяти.

Память отслеживается на каждой операции так:

round(memory_get_usage()/1024/1024, 2)

Время с секундах:

$start = microtime(true);
... операция
$end = microtime(true) - $start;

Всё это сохраняется в отдельном лог-массиве, а потом выводится для статистики.

Всё тестирование я выполнял на обычной странице Albireo CMS, поскольку меня интересует именно её возможность работать с большим количеством файлов. Теоретически, конечно же, можно было сделать и миллион элементов массива, но это нереальные данные. Я исхожу из некоего магического числа 10000, которое равно числу файлов на сайте. Мне это видится как очень-очень большой сайт.

Загрузка файла

Исходный файл состоит из 10 тыс. элементов, имеет размер примерно 6.8МБ — это с учетом его форматирования. Перед его загрузкой потребление памяти примерно 1.6МБ (на моём компе). Загрузка файла занимает примерно полсекунды.

Потребление памяти

Сразу после загрузки файла потребление памяти скакануло до 25МБ и после каждого теста постепенно росло, где-то на 1МБ, где-то на 5МБ.

Естественно, если сделать unset(), то потребление падает до исходных 1.6МБ.

Причина такого поведения в том, что в PHP все данные представляют собой хэш-таблицу, а значит под неё выделяется достаточный объём памяти на все манипуляции. Если сделать memory_get_usage(true), то можно увидеть сколько ОС выделила памяти PHP. В моём случае оказалось 48МБ.

По современным меркам — совсем ничего, поскольку хостинги сейчас выделяют намного больше, от 128Мб до 512МБ. Это многократно превышает реальные потребности, но сам факт, что массив 6МБ требует 48МБ несколько настораживает.

В Сети есть статьи, где описывается как именно PHP работает с памятью и массивами, поэтому для него это типовое поведение. После того, как массив оказывается невостребованный, PHP автоматически его удаляет из памяти. В Albireo CMS подключение файла происходит в виде отдельной функции, как раз для изоляции от основного «ядра», поэтому здесь поведение PHP оказалось очень благотворным. После того, как работа с массивом закончилось, потребление упало до исходных 1.6МБ.

Теперь посмотрим как работает PHP с массивом.

Функции для массивов

У меня есть несколько функций ядра, которые выполняет разную задачу. Например функция searchInArray() ищет в массиве только те элементы, где есть определённое поле. В качестве основы я стараюсь использовать нативные php-функции, например array_filter().

Формально можно использовать и обычный foreach-цикл, но нативные функции пусть и немного, но всё-таки будут работать быстрее. К тому же нельзя исключать и тот факт, что они могут улучшаться от версии к версии. Поэтому, там где это возможно, лучше использовать встроенные php-функции.

Такие функции, действительно оказались очень быстрыми: 0.00722 сек., 0.0088 сек, 0.038 сек. и т.п.

Это на моём стареньком ноуте.

Но, некоторые функции отрабатывают достаточно долго, например сортировка по дате работает 1.89 сек.

Анализ показал, что всё дело в функциях, которые анализируют значение элемента массива. Скажем для сравнения даты я используют strtotime(), потому что дату и время пользователь может указать в произвольном формате и пусть PHP с ним сам разбирается. И эта функция работает как раз и медленнее остальных. Заменить её на что-то другое проблематично.

Строки сравниваются с помощью strcasecmp() — она тоже не самая быстрая: 0.121 сек.

Алгоритмы для массивов

Делая выборку из массива, мы по сути создаём свой язык запросов. Есть даже такие php-библиотеки «Array Query», где используется что-то вроде SQL, только в виде функций. Понятно, что это очень специфичная вещь, но суть её в том, что в той же базе, любая таблица по сути и есть массив. И сервер базы как раз и работает с этим массивом.

Но поскольку у нас «чистый» массив, то как именно получить нужные данные уже зависит от нас. Алгоритмы должны быть подобраны так, чтобы функции отрабатывали как можно быстрей. Например если нам нужны только blog-записи с сортировкой по дате, то лучше вначале выполнить поиск элементов с указанным blog, а уже потом сортировать их по дате. Первая операция очень быстрая, вторая — медленная, но для неё уже будет меньше элементов.

Ради ускорения иногда приходится делать неочевидные вещи. Например explode() очень быстрая операция, но если нужна дополнительная обработка входящей строки, то она обрастает trim(), str_replace() и т.д. Поэтому может оказаться проще сразу проверить есть ли в строке разделитель и если нет, то пропустить все дополнительные операции. На 1000 элементах это вообще не окажет никакого влияния, но на 10 тысячах можно сэкономить несколько десятых секунды.

Обход в foreach-цикле

Многие вещи проще сделать в foreach-цикле. Например если у записи стоит отметка draft, то она исключается из выборки. Или более сложный случай, когда элемент содержит несколько значений, как в примере с меткой, рубрики и т.д. В итоге мы не просто используем foreach, а в нём формируем дополнительные данные, которые могут использоваться уже после выхода из цикла.

Скажем если нужно найти корневой элемент в Materialized Path и получить его level проще уже после поиска всех элементов массива. Операция затратная, поэтому, чем меньше элементов, тем быстрее будет обработка.

Оптимизация PHP

Чтобы улучшить показатели PHP, я проверил работу с массивам с включенным opcache. Теперь это стандартная возможность. Я использовал рекомендуемые настройки в php.ini:

opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=4000
opcache.revalidate_freq=60
opcache.enable_cli=1

Многие показатели сразу улучшились. Например время загрузки файла сократилось на несколько порядков: 0.000123 сек. (с 0.5 сек).

Что касается времени выполнения функций массива, то оно осталось без изменений. Но вот что касается потребления памяти, то оно сильно сократилось. Исходное значение (без массива) 0.7Мб. Большинство операция требовали до 1.5 МБ, а самая затратная чуть меньше 7МБ. Всего же ОС выделила PHP 14МБ памяти.

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

Если исключить сложные выборки, то типовой вывод записей формируется за 0.0211 сек. Думаю, что для 10 тыс. элементов неплохой результат.

Увеличение объёма массива

Что будет если увеличить объём массива? Для этого я оставил всё тоже количество элементов, но добавил в каждый ещё поле content из случайных слов разного объёма (от 1 до 10КБ). Это как бы имитирует ситуацию, когда в массиве получаются большие данные, например текст записи.

Размер такого файла получился примерно 40МБ.

Без opcache потребление памяти выросло примерно до 70МБ, а всего было выделено 92МБ. При включении opcache память уменьшилась до 1..7МБ, хотя всего было выделено 30МБ.

То есть можно предположить, что при использовании opcache, PHP резервирует объём примерно равный объёму массива. Без opcache резервируется объём в 2-3 раза больше объёма массива. Таким образом есть некий предел, напрямую связанный с выделенной на сервере памятью для PHP.

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

Итого

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

Второй вывод — если массив большой, то лучше сразу включить opcache. Как минимум, это сильно сэкономит потребляемую память.

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

Ну и четвертый — нужно всё это тестировать на больших объемах. Только так получается выявить проблемные места.


Слава Украине! Смерть рашистам! Пусть рашка сгорит в аду!

Похожие записи

Оставьте комментарий!