[Перевод] Улучшение производительности PHP 7
PHP — это программное обеспечение, написанное на языке С. Кодовая база PHP содержит около 800 тысяч строк кода и в седьмой версии была существенно переработана.
В этой статье мы рассмотрим, что изменилось в движке Zend седьмой версии по сравнению с пятой, а также разберёмся, как можно эффективно использовать внутренние оптимизации. В качестве исходной точки возьмём PHP 5.6. Зачастую многое зависит от того, как те или иные вещи написаны и представлены движку. При написании критически важного кода необходимо уделять внимание его производительности. Изменив несколько мелочей, вы можете сильно ускорить работу движка, зачастую без ущерба для других аспектов вроде читабельности кода или управления отладкой. Свои рассуждения я докажу с помощью профилировщика Blackfire.
Если вы хотите повысить производительность PHP, мигрировав на седьмую версию, то вам нужно будет:
- Мигрировать кодовую базу без внесения изменений в неё (или просто преобразовать её в код, совместимый с PHP 7). Этого будет достаточно для повышения скорости работы.
- Воспользоваться нижеприведёнными советами, чтобы понять, как изменились разные части кода виртуальной машины PHP и как их использовать для ещё большего увеличения производительности.
Упакованные массивы
Упакованные массивы — первая из замечательных оптимизаций в PHP 7. Они потребляют меньше памяти и во многих случаях работают гораздо быстрее традиционных массивов. Упакованные массивы должны удовлетворять критериям:
- Ключи — только целочисленные значения;
- Ключи вставляются в массив только по возрастанию.
Пример 1:
$a = ['foo', 'bar', 'baz'];
Пример 2:
$a = [12 => 'baz', 42 => 'bar', 67 => [] ];
Эти массивы внутренне очень хорошо оптимизированы. Но очевидно, что на трёхъячеечном массиве вы не почувствуете разницы по сравнению с PHP 5.
К примеру, во фреймворке Symfony упакованные массивы существуют только в генераторе карты классов (class map generator), создающем код наподобие:
array (
0 => 'Symfony\\Bundle\\FrameworkBundle\\EventListener\\SessionListener',
1 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\NativeSessionStorage',
2 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\PhpBridgeSessionStorage',
3 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\Handler\\NativeFileSessionHandler',
4 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\Proxy\\AbstractProxy',
5 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\Proxy\\SessionHandlerProxy',
6 => 'Symfony\\Component\\HttpFoundation\\Session\\Session',
/* ... */
Если выполнить этот код на PHP 5 и PHP 7 и проанализировать с помощью Blackfire, то получим:
Как видите, общая продолжительность компилирования и выполнения этого объявления массива уменьшилась примерно на 72%. В PHP 7 такие массивы становятся похожими на NOP«ы, а в PHP 5 заметное время тратится на компилирование и на загрузку в ходе runtime.
Давайте возьмём массив побольше, на 10 000 ячеек:
for ($i=0; $i<10000; $i++) {
$a[] = $i;
}
То же самое сравнение:
Продолжительность использования процессора снизилась примерно в 10 раз, а потребление памяти уменьшилось с 3 Мб до 0,5 Мб.
Команда разработчиков PHP много сил потратила на оптимизацию массивов, потому что массивы — одна из главных структур, на которой базируется язык (вторая по значению структура — объекты, внутренне реализованные по той же модели).
Потребление памяти массивами в PHP 7 гораздо ниже, чем в PHP 5. А при использовании упакованных массивов экономия ещё выше.
Не забывайте:
- Если вам нужен список, то не используйте строки в ключах (это не даст применять оптимизацию упакованных массивов);
- Если ключи в списке только целочисленные, постарайтесь распределить их по возрастанию (в противном случае оптимизация тоже не сработает).
Такие списки можно использовать в разных частях вашего приложения: например для перечисления (как карта классов в Symphony) или для извлечения результатов из базы в определённом порядке и с числовыми данными в колонке. Это часто бывает нужно в веб-приложениях (
$pdo->query("SELECT * FROM table LIMIT 10000")->fetchAll(PDO::FETCH_NUM)
).Целочисленные и значения с плавающей запятой в PHP 7 бесплатныВ PHP 7 совершенно иной способ размещения переменных в памяти. Вместо кучи они теперь хранятся в пулах стековой памяти. У этого есть побочный эффект: вы можете бесплатно повторно использовать контейнеры переменных (variable containers), память не выделяется. В PHP 5 такое невозможно, там для каждого создания/присвоения переменной нужно выделить немного памяти (что ухудшает производительность).
Взгляните на этот код:
for ($i=0; $i<10000; $i++) {
$$i = 'foo';
}
Здесь создаётся 10 000 переменных с именами от $0 до $10000 с «foo» в качестве строкового значения. Конечно, при первичном создании контейнера переменной (как в нашем примере) потребляется какая-то память. Но что будет, если теперь мы повторно используем эти переменные для хранения целочисленных значений?
/* ... продолжение ... */
for ($i=0; $i<10000; $i++) {
$$i = 42;
}
Здесь мы просто повторно использовали уже размещённые в памяти переменные. В PHP 5 для этого потребовалось бы заново выделить память для всех 10 000 контейнеров, а PHP 7 просто берёт готовые и кладёт в них число 42, что никак не влияет на память. В седьмой версии использование целочисленных и значений с плавающей запятой совершенно бесплатно: память нужного размера уже выделена для самих контейнеров переменных.
Посмотрим, что скажет Blackfire:
В PHP 7 отказ от дополнительного обращения к памяти при изменении переменной приводит к экономии процессорных циклов во втором цикле for
. В результате использование процессора уменьшается на 50%. А присваивание целочисленного значения ещё больше снижает потребление памяти по сравнению с PHP 5. В пятой версии на размещения в памяти 10 000 целочисленных значений тратится 80 000 байтов (на платформе LP64), а также куча дополнительной памяти на аллокатор. В PHP 7 этих расходов нет.
Encapsed-строки — это значения, в которых выполняется внутреннее сканирование на наличие переменных. Они объявляются с помощью двойных кавычек, или Heredoc-синтаксиса. Алгоритм анализирует значение и отделяет переменные от строк. Например:
$a = 'foo';
$b = 'bar';
$c = "Мне нравится $a и $b";
При анализе строки $c движок должен получить строку: «Мне нравится foo и bar». Этот процесс в PHP 7 также был оптимизирован.
Вот что делает PHP 5:
- Выделяет буфер для «Мне нравится»;
- Выделяет буфер для «Мне нравится foo»;
- Добавляет (копирует в памяти) в последний буфер «Мне нравится» и «foo», возвращает его временное содержимое;
- Выделяет новый буфер для «Мне нравится foo и»;
- Добавляет (копирует в памяти) » Мне нравится foo» и «и» в этот последний буфер и возвращает его временное содержимое;
- Выделяет новый буфер для «Мне нравится foo и bar»;
- Добавляет (копирует в памяти) «Мне нравится foo и» и «bar» в этот последний буфер и возвращает его содержимое;
- Освобождает все промежуточные использованные буферы;
- Возвращает значение последнего буфера.
Много работы, верно? Такой алгоритм в PHP 5 аналогичен тому, что используется при работе со строками в С. Но дело в том, что он плохо масштабируется. Этот алгоритм не оптимален при работе с очень длинными encapsed-строками, включающими в себя большое количество переменных. А ведь encapsed-строки часто используются в PHP.
В PHP 7 всё работает иначе:
- Создаётся стек;
- В него помещаются все элементы, которые нужно добавить;
- Когда алгоритм доходит до конца encapsed-строки, единовременно выделяется память необходимого размера, в которую перемещаются все части данных, в нужные места.
Телодвижения с памятью остались, однако никакие промежуточные буферы, как в PHP 5, уже не используются. В PHP 7 лишь один раз выделяется память для финальной строки, вне зависимости от количества частей строки и переменных.
Код и результат:
$w = md5(rand());
$x = md5(rand());
$y = md5(rand());
$z = md5(rand());
$a = str_repeat('a', 1024);
$b = str_repeat('a', 1024);
for ($i=0; $i<1000; $i++) {
$$i = "В этой строке много $a, а также много $b, выглядит рандомно: $w - $x - $y - $z";
}
Мы создали 1000 encapsed-строк, в которых находим статичные строковые части и шесть переменных, две из которых весят по 1 Кб.
Как видите, в PHP 7 использование процессора снизилось в 10 раз по сравнению с PHP 5. Обратите внимание, что с помощью Blackfire Probe API (в примере не показано) мы профилировали всего лишь цикл, а не весь скрипт.
В PHP 7:
$bar = 'bar';
/* используйте это */
$a = "foo и $bar";
/* вместо этого */
$a = "foo и " . $bar;
Операция конкатенации не оптимизирована. Если вы используете конкатенацию строк, то в результате будете делать те же странные вещи, что и в PHP 5. А encapsed-строки помогут воспользоваться преимуществами нового алгоритма анализа, выполняющего оценочную конкатенацию (evaluated concatenation) с помощью структуры «Rope».Reference mismatch
Reference mismatch возникает тогда, когда в качестве аргумента, передаваемого по ссылке (passed-by-ref), вы передаёте функции нессылочную переменную (non-ref variable), или наоборот. Например, так:
function foo(&$arg) { }
$var = 'str';
foo($var);
Вы знаете, какой в этом случае творится кошмар в движке PHP 5? Если возникает несовпадение ссылки, движку приходится дуплицировать переменную, прежде чем передавать её функции в качестве аргумента. Если переменная содержит что-то большое, вроде массива на несколько тысяч записей, то копирование займёт много времени.
Причина в том, как в PHP 5 построена работа с переменными и ссылками. Переходя к телу функции, движок ещё не знает, измените ли вы значение аргумента. Если измените, то передача аргумента по ссылке должна привести к отражению вовне сделанного изменения при передаче ссылочной переменной (reference variable).
А если вы не измените значение аргумента (как в нашем примере)? Тогда движок должен создать ссылку из нессылочной (non-reference) переменной, которую вы передаёте в ходе вызова функции. В PHP 5 движок полностью дуплицирует содержимое переменной (при очень небольшом количестве указателей много раз вызывая memcpy()
, что приводит ко множеству медленных обращений к памяти).
Когда в PHP 7 движок хочет создать ссылку из нессылочной переменной, он просто оборачивает её в заново созданную бывшую ссылку (former). И никаких копирований в память. Всё дело в том, что в PHP 7 работа с переменными построена совсем иначе, а ссылки существенно переработаны.
Взгляните на этот код:
function bar(&$a) { $f = $a; }
$var = range(1,1024);
for ($i=0; $i<1000; $i++) {
bar($var);
}
Здесь двойное несовпадение. При вызове
bar()
вы заставляете движок создавать ссылку из $var
к $a
, как говорит &$a
сигнатура. Поскольку $a
теперь является частью ссылочного набора (reference set) ($var-$a
) в теле bar()
, вы можете влиять на него с помощью значения $f
: это другое несовпадение. Ссылка не влияет на $f
, так что $a-$f-$var
можно не соединять друг с другом. Однако $var-$a
соединены в одну часть, а $f
находится в одиночестве во второй части, пока вы не заставите движок создать копии. В PHP 7 можно довольно легко создавать переменные из ссылок и превращать их в ссылки, только в результате может происходить копирование при записи (copy-on-write).Помните, что если вы не полностью разобрались в работе ссылок в PHP (а этим могут похвастаться не так уж много человек), то лучше вообще их не использовать.
Мы видели, что PHP 7 снова позволяет экономить немало ресурсов по сравнению с PHP 5. Но в наших примерах мы не касались случаев копирования при записи. Здесь всё было бы иначе, и PHP пришлось бы делать дамп памяти под нашу переменную. Однако использование PHP 7 облегчает ситуации, когда несовпадения нарочно могут не выполняться, например если вызвать count($array)
, когда частью ссылки является $array
. В таком случае в PHP 7 не будет дополнительных расходов, зато в PHP 5 процессор раскалится (при достаточно большом массиве, например при сборе данных из SQL-запросов).
Концепция неизменяемых массивов появилась в PHP 7, они являются частью расширения OPCache. Неизменяемым называется массив, который заполнен неизменяемыми элементами, чьи значения не требуют вычислений и становятся известны во время компилирования: строковые значения, целочисленные, значения с плавающей запятой или содержащие всё перечисленное массивы. Короче, никаких динамических элементов и переменных:
$ar = [ 'foo', 42, 'bar', [1, 2, 3], 9.87 ];
Неизменяемые массивы были оптимизированы в PHP 7. В PHP 5 не делается разницы между массивами, содержащими динамические элементы (
$vars
), и статичными при компилировании. В PHP 7 же неизменяемые массивы не копируются, не дуплицируются, а остаются доступными только для чтения.Пример:
for ($i = 0; $i < 1000; $i++) {
$var[] = [
0 => 'Symfony\\Bundle\\FrameworkBundle\\EventListener\\SessionListener',
1 => 'Symfony\\Component\\HttpFoundation\\Session\\Storage\\NativeSessionStorage',
/* ... go to many immutable items here */
];
}
Этот код тысячу раз создаёт один и тот же массив (обрезанный, его можно представить в виде массива из сотни или тысячи ячеек).
В PHP 5 такой массив дуплицируется в памяти тысячу раз. Если он весит несколько сотен килобайт или даже мегабайты, то при тысячекратном дублировании мы займём большой объём памяти.
В PHP 7 OPCache помечает эти массивы как неизменяемые. Массив создаётся единожды, а где необходимо, используется указатель на его память, что приводит к огромной экономии памяти, особенно если массив велик, как в приведённом примере (взято из фреймворка Symfony 3).
Посмотрим, как меняется производительность:
Снова огромная разница между PHP 5 и PHP 7. PHP 5 нужно создавать массив 1000 раз, что занимает 27 Мб памяти. В PHP 7 с OPCache задействуется всего 36 Кб!
Если вам удобно использовать неизменяемые массивы, то не стесняйтесь. Только не надо нарушать неизменяемость, выдавая подобный код:
$a = 'value';
/* Не делайте так */
$ar = ['foo', 'bar', 42, $a];
/* Лучше так: */
$ar = ['foo', 'bar', 42, 'value'];
Прочие соображения
Мы рассмотрели несколько внутренних хитростей, объясняющих, почему PHP 7 работает куда быстрее PHP 5. Но для многих рабочих нагрузок это микрооптимизации. Чтобы получить от них заметный эффект, нужно использовать огромные объёмы данных или многочисленные циклы. Подобное чаще всего случается, когда в фоновом режиме вместо обработки HTTP-запросов запускаются рабочие PHP-процессы (workers).
В таких случаях вы можете очень сильно уменьшить потребление ресурсов (процессора и памяти), всего лишь по-другому написав код.
Но не верьте своим предчувствиям относительно возможных оптимизаций производительности. Не надо слепо патчить код, используйте профилировщики, чтобы подтвердить или опровергнуть свои гипотезы и проверить реальную производительность кода.