PHP — это скриптовый язык, который по умолчанию компилирует те файлы, которые вам нужно запустить. Во время компилирования он извлекает опкоды, исполняет их, а затем немедленно уничтожает. PHP был так разработан: когда он переходит к выполнению запроса R, то «забывает» всё, что было выполнено в ходе запроса R-1.
Очень маловероятно, что на production-серверах PHP-код изменится между выполнением нескольких запросов. Так что можно считать, что при компилированиях всегда считывается один и тот же исходный код, а значит и опкод будет точно таким же. И если извлекать его для каждого скрипта, то получается бесполезная трата времени и ресурсов.
В связи с большой продолжительностью компилирования были разработаны расширения для кэширования опкодов. Их главная задача — единожды скомпилировать каждый PHP-скрипт и закэшировать получившиеся опкоды в общую память, чтобы их мог считать и выполнить каждый рабочий процесс PHP из вашего production-пула (обычно используется PHP-FPM).
В результате сильно повышается общая производительность языка, а на запуск скрипта уходит как минимум вдвое меньше времени (сильно зависит от самого скрипта). Обычно даже ещё меньше, потому что PHP не нужно снова и снова компилировать одни и те же скрипты.
Чем сложнее приложение, тем выше эффективность этой оптимизации. Если программа запускает кучу файлов, например, приложение на базе фреймворка, или продукты наподобие Wordpress, то продолжительность запуска скриптов может уменьшиться в 10-15 раз. Дело в том, что компилятор PHP работает медленно, потому что ему приходится преобразовывать один синтаксис в другой, он пытается понять, что вы написали, и как-то оптимизировать получающийся код ради ускорения его исполнения. Так что да, компилятор медленный и потребляет много памяти. С помощью профилировщиков наподобие Blackfire мы можем спрогнозировать продолжительность компилирования.
Введение в OPCache
Исходный код OPCache был открыт в 2013 году, а в комплект поставки он начал входить с PHP 5.5.0. С тех пор это стандартное решение для кэширования опкодов в PHP. Здесь мы не будем рассматривать другие решения, поскольку из них я знаком только с APC, поддержка которого была прекращена в пользу OPCache. Короче: если вы раньше использовали APC, то теперь используйте OPCache. Теперь это официально рекомендуемое разработчиками PHP решение для задач кэширования опкодов. Конечно, если хотите, то можете использовать и другие инструменты, но никогда не активируйте одновременно более одного расширения для кэширования опкодов. Это наверняка обрушит PHP.
Также имейте ввиду, что дальнейшая разработка OPCache будет вестись только в рамках PHP 7, но не PHP 5. В этой статье мы рассмотрим OPCache для обоих версий, так что вы увидите разницу (она не слишком велика).
Итак, OPCache — это расширение, точнее, zend-расширение, внедрённое в исходный код PHP начиная с версии 5.5.0. Его необходимо активировать с помощью обычного процесса активации через php.ini. Что касается дистрибутивов, то сверьтесь с мануалом, чтобы подружить PHP и OPCache.
Две функции одного продукта
У OPCache есть две основные функции:
- Кэширование опкодов.
- Оптимизация опкодов.
Поскольку OPCache запускает компилятор, чтобы получить и закэшировать окоды, то он может использовать этот этап для их оптимизации. По сути речь идёт разнообразных оптимизациях компилятора. OPCache работает как многопроходный оптимизатор компилятора.
Внутренности OPCache
Давайте посмотрим, как работает OPCache внутри. Если вы хотите сверяться с кодом, то можете взять его, например, отсюда.
Идею кэширования опкодов нетрудно будет понять и проанализировать. Вам потребуется хорошее понимание работы и архитектуры движка Zend, и вы сразу начнёте подмечать места, где можно провести оптимизацию.
Модели общей памяти
Как вы знаете, в разных ОС существует много моделей общей памяти. В современных Unix-системах используется несколько подходов к общему использованию памяти процессами, самые популярные из которых:
- System-V shm API
- POSIX API
- mmap API
- Unix socket API
OPCache может применять первые три, если их поддерживает ваша ОС. INI-настройка opcache.preferred_memory_model явно задать желаемую модель. Если вы оставите нулевое значение параметр, то OPCache выберет первую работающую на вашей платформе модель, последовательно перебирая по таблице:
static const zend_shared_memory_handler_entry handler_table[] = {
#ifdef USE_MMAP
{ "mmap", &zend_alloc_mmap_handlers },
#endif
#ifdef USE_SHM
{ "shm", &zend_alloc_shm_handlers },
#endif
#ifdef USE_SHM_OPEN
{ "posix", &zend_alloc_posix_handlers },
#endif
#ifdef ZEND_WIN32
{ "win32", &zend_alloc_win32_handlers },
#endif
{ NULL, NULL}
};
По умолчанию должна использоваться mmap. Это хорошая модель, развитая и устойчивая. Хотя она и менее информативна для сисадминов, чем модель System-V SHM, как и её команды
ipcs
и ipcrm
.Как только OPCache стартует (то есть стартует PHP), он проверяет модель общей памяти и выделяет один большой сегмент, который потом будет распределять по частям. При этом сегмент уже не будет ни освобождён, ни изменён в размерах.
То есть OPCache при запуске PHP выделяет один большой сегмент памяти, которые не освобождается и не фрагментируется.
Размер сегмента можно задать в мегабайтах с помощью INI-настройки opcache.memory_consumption. Не экономьте, задавайте побольше. Никогда не допускайте исчерпания общей памяти, если это произойдёт, то процессы заблокируются. Об этом мы поговорим ниже.
Задайте размер сегмента согласно вашим потребностям, и не забывайте, что production-сервер, выделенный под PHP-процессы, может потреблять несколько десятков гигабайт памяти для одного лишь PHP. Так что нередко выделяют под сегмент 1 Гб и больше, всё зависит от конкретных нужд. Если вы используете современный стек приложений, на базе фреймворка, с большим количеством зависимостей и т.д… тут не обойтись как минимум без гигабайта.
Сегмент будет использоваться OPCache для нескольких задач:
- Кэширование структуры данных скрипта, включая и кэширование опкодов.
- Создание общего внутреннего (interned) строкового буфера.
- Хранение хэш-таблицы кэшированных скриптов.
- Хранение состояния глобальной общей памяти OPCache.
Помните, что сегмент общей памяти содержит не только опкоды, но и другие вещи, необходимые для работы OPCache. Так что прикиньте, сколько нужно памяти, и задайте нужный размер сегмента.
Кэширование опкодов
Рассмотрим подробности работы механизма кэширования.
Идея заключается в копировании в общую память (shm, shared memory) данных каждого указателя, которые не меняются от запроса к запросу, то есть неизменяемых данных. Их много. После загрузки ранее использовавшегося скрипта из общей памяти восстанавливаются данные указателя в стандартную память процесса, привязанные к текущему запросу. Работающий PHP-компилятор использует диспетчера памяти Zend (Zend Memory Manager, ZMM) для размещения каждого указателя. Этот тип памяти привязан к запросу, так ZMM попытается автоматически освободить указатели по завершении текущего запроса. Кроме того, эти указатели размещаются из «кучи» текущего запроса, так что получается что-то вроде частной расширенной памяти, которая не может использоваться совместно с другими PHP-процессами. Следовательно, задача OPCache заключается в просмотре каждой структуры, возвращаемой PHP-компилятором, чтобы не оставить указатель, выделенный на этот пул, а скопировать его в выделенный пул общей памяти. И здесь мы говорим о времени компилирования. Всё, что было размещено компилятором, считается неизменяемым. Изменяемые данные будут созданы виртуальной машиной Zend в ходе выполнения, так что можно без опаски сохранять в общую память всё, что создано компилятором Zend. Например, функции и классы, указатели имён функций, указатели на OPArray функций, константы классов, имена объявленных переменных классов и, наконец, их контент по умолчанию… Много чего создаётся в памяти PHP-компилятором.
Такая модель используется для надёжного предотвращения блокировок. Позднее мы коснёмся темы блокировки. По сути, OPCache выполняет всю свою работу сразу, до выполнения, поэтому уже в ходе выполнения скрипта OPCache нечего делать. Переменные данные будут созданы в классической «куче» процесса с помощью ZMM, а неизменяемые данные будут восстановлены из общей памяти.
Итак, OPCache подключается к компилятору и заменяет структуру, которую последний должен заполнить в ходе компилирования скриптов, своей собственной. Затем, вместо прямого заполнения таблиц движка Zend и внутренних структур, он заставляет компилятор заполнить структуру
persistent_script
.Вот она:
typedef struct _zend_persistent_script {
ulong hash_value;
char *full_path; /* полный путь с разрешёнными симлинками */
unsigned int full_path_len;
zend_op_array main_op_array;
HashTable function_table;
HashTable class_table;
long compiler_halt_offset; /* позиция __HALT_COMPILER или -1 */
int ping_auto_globals_mask; /* какие autoglobal’ы использованы скриптом */
accel_time_t timestamp; /* время модифицирования скрипта */
zend_bool corrupted;
#if ZEND_EXTENSION_API_NO < PHP_5_3_X_API_NO
zend_uint early_binding; /* линкованный список отложенных объявлений */
#endif
void *mem; /* общая память, использованная структурами скрипта */
size_t size; /* размер использованной общей памяти */
/* Все записи, которые не должны учитываться в контрольной сумме ADLER32,
* должны быть объявлены в этом struct
*/
struct zend_persistent_script_dynamic_members {
time_t last_used;
ulong hits;
unsigned int memory_consumption;
unsigned int checksum;
time_t revalidate;
} dynamic_members;
} zend_persistent_script;
А так OPCache заменяет структуру компилятора своей
persistent_script
, простым переключением указателей функций:new_persistent_script = create_persistent_script();
/* Сохраняет исходное значение op_array, таблицу функции и таблицу класса */
orig_active_op_array = CG(active_op_array);
orig_function_table = CG(function_table);
orig_class_table = CG(class_table);
orig_user_error_handler = EG(user_error_handler);
/* Перекрывает их своими */
CG(function_table) = &ZCG(function_table);
EG(class_table) = CG(class_table) = &new_persistent_script->class_table;
EG(user_error_handler) = NULL;
zend_try {
orig_compiler_options = CG(compiler_options);
/* Конфигурирует компилятор */
CG(compiler_options) |= ZEND_COMPILE_HANDLE_OP_ARRAY;
CG(compiler_options) |= ZEND_COMPILE_IGNORE_INTERNAL_CLASSES;
CG(compiler_options) |= ZEND_COMPILE_DELAYED_BINDING;
CG(compiler_options) |= ZEND_COMPILE_NO_CONSTANT_SUBSTITUTION;
op_array = *op_array_p = accelerator_orig_compile_file(file_handle, type TSRMLS_CC); /* Запускает PHP-компилятор */
CG(compiler_options) = orig_compiler_options;
} zend_catch {
op_array = NULL;
do_bailout = 1;
CG(compiler_options) = orig_compiler_options;
} zend_end_try();
/* Восстанавливает исходники */
CG(active_op_array) = orig_active_op_array;
CG(function_table) = orig_function_table;
EG(class_table) = CG(class_table) = orig_class_table;
EG(user_error_handler) = orig_user_error_handler;
Как видите, PHP-компилятор полностью изолирован и отключён от обычно заполняемых таблиц. Теперь он заполняет структуры
persistent_script
. Далее OPCache должен просмотреть эти структуры и заменить указатели на запрос указателями на общую память. OPCache нужны:- Функции скрипта.
- Классы скрипта.
- Главный OPArray скрипта.
- Путь скрипта.
- Структура самого скрипта.
Также компилятору передаются некоторые опции, отключающие выполняемые им оптимизации, например,
ZEND_COMPILE_NO_CONSTANT_SUBSTITUTION
и ZEND_COMPILE_DELAYED_BINDING
. Это добавляет работы OPCache. Помните, что OPCache подключается к движку Zend, это не патч для исходного кода.Раз у нас теперь есть структура
persitent_script
, мы должны закэшировать её информацию. PHP-компилятор заполнил наши структуры, но с помощью ZMM выделил с краю память: она будет освобождена по завершении текущего запроса. Потом нам нужно просмотреть эту память и скопировать содержимое в сегмент общей памяти, чтобы собранную информацию можно было использовать для нескольких запросов, а не вычислять каждый раз заново.Процесс построен следующим образом:
- PHP-скрипт помещается в кэш и вычисляется общий размер данных каждой переменной (всех целевых объектов указателей).
- В уже выделенной общей памяти резервируется один большой блок аналогичного размера.
- Просматриваются все структуры переменных скрипта, и данные переменных всех целевых объектов указателей копируются в только что зарезервированный блок общей памяти.
- Для загрузки скрипта (когда до этого доходит) делается прямо противоположное.
Итак, OPCache грамотно использует общую память, никогда не фрагментируя её посредством освобождений и уплотнений. Для каждого скрипта он вычисляет точный размер общей памяти, необходимой для хранения информации, а затем копирует туда данные. Память никогда не освобождается и не возвращается обратно OPCache. Поэтому она используется крайне эффективно и не фрагментируется. Это сильно повышает производительность общей памяти, потому что здесь нет связного списка (linked-list) или B-дерева (BTree), которые приходится хранить и просматривать при управлении памятью, которая может быть освобождена (как это делает malloc/free). OPCache сохраняет данные в сегменте общей памяти, а когда они теряют актуальность (из-за проверки актуальности скрипта), то буферы не освобождаются, а помечаются как «потерянная» (wasted). Когда доля потерянной памяти достигает максимума, OPCache перезапускается. Эта модель сильно отличается, например, от APC. Её большое преимущество в том, что со временем производительность не падает, потому что буфер из общей памяти никогда не подвергается управлению (не освобождается, не уплотняется и т.д.). Все эти операции по управлению памятью — чисто техническая вещь, не улучшающая функциональность, но снижающая производительность. OPCache был разработан так, чтобы обеспечивать наивысшую возможную производительность с учётом выполнения PHP-окружения. «Неприкосновенность» сегмента общей памяти также обеспечивает очень хорошую частоту обращений к кэшу процессора (особенно L1 и L2), потому что OPCache также выравнивает указатели памяти в соответствии с L1/L2.
Кэширование скрипта в первую очередь подразумевает вычисление точного размера его данных. Вот алгоритм вычисления:
uint zend_accel_script_persist_calc(zend_persistent_script *new_persistent_script, char *key, unsigned int key_length TSRMLS_DC)
{
START_SIZE();
ADD_SIZE(zend_hash_persist_calc(&new_persistent_script->function_table, (int (*)(void* TSRMLS_DC)) zend_persist_op_array_calc, sizeof(zend_op_array) TSRMLS_CC));
ADD_SIZE(zend_accel_persist_class_table_calc(&new_persistent_script->class_table TSRMLS_CC));
ADD_SIZE(zend_persist_op_array_calc(&new_persistent_script->main_op_array TSRMLS_CC));
ADD_DUP_SIZE(key, key_length + 1);
ADD_DUP_SIZE(new_persistent_script->full_path, new_persistent_script->full_path_len + 1);
ADD_DUP_SIZE(new_persistent_script, sizeof(zend_persistent_script));
RETURN_SIZE();
}
Повторюсь: нам нужно закэшировать:
- Функции скрипта.
- Классы скрипта.
- Главный OPArray скрипта.
- Путь скрипта.
- Структура самого скрипта.
Итерационный алгоритм выполняет глубокий поиск функций, классов и OPArray: он кэширует данные всех указателей. Например, в PHP 5 для функций нужно скопировать в общую память (shm):
- Хэш-таблицы функций
- Таблицу контейнеров хэш-таблицы функций (Bucket **)
- Контейнер хэш-таблицы функций (Bucket *)
- Ключ контейнеров хэш-таблицы функций (char *)
- Указатель данных контейнеров хэш-таблицы функций (void *)
- Данные контейнеров хэш-таблицы функций (*)
- OPArray функций
- Имя файла OPArray (char *)
- Литералы OPArray (имена (char) и значения (zval ))
- Опкоды OPArray (zend_op *)
- Имена функций OPArray function name (char *)
- arg_infos OPArray (zend_arg_info, а также имя и имя класса оба как char)
- Массив break-continue OPArray (zend_brk_cont_element *)
- Статичные переменные OPArray (Полная хэш-таблица и zval*)
- Комментарии документации к OPArray (char *)
- Массив try-catch OPArray try- (zend_try_catch_element *)
- Скомпилированные переменные OPArray (zend_compiled_variable *)
В PHP 7 список несколько отличается из-за разницы структур (например, хэш-таблицы). Как я говорил, идея в том, чтобы копировать в общую память данные всех указателей. Поскольку глубокое копирование может затрагивать пересекающиеся структуры, OPCache использует для хранения указателей таблицу трансляции (translate table): при каждом копировании указателя из обычной памяти, привязанной к запросу, в общую, в таблицу записывается связь между старым и новым адресами указателя. Процесс, отвечающий за копирование, сначала ищет в таблице трансляции, не копировались ли уже эти данные. Если копировались, то он использует старые данные указателя, чтобы не возникало дублирования:
void *_zend_shared_memdup(void *source, size_t size, zend_bool free_source TSRMLS_DC)
{
void **old_p, *retval;
if (zend_hash_index_find(&xlat_table, (ulong)source, (void **)&old_p) == SUCCESS) {
/* we already duplicated this pointer */
return *old_p;
}
retval = ZCG(mem);;
ZCG(mem) = (void*)(((char*)ZCG(mem)) + ZEND_ALIGNED_SIZE(size));
memcpy(retval, source, size);
if (free_source) {
interned_efree((char*)source);
}
zend_shared_alloc_register_xlat_entry(source, retval);
return retval;
}
ZCG(mem)
представляет собой сегмент общей памяти фиксированного размера, заполняемый по мере добавления элементов. Поскольку он уже выделен, то нет нужды выделять память для каждой копии (это снизило бы общую производительность), просто при заполнении сегмента сдвигается граница адресов указателей.Мы рассмотрели алгоритм кэширования скриптов, который берёт из привязанной к запросу «кучи» указатель и данные, а затем копирует их в общую память, если это не было сделано ранее. Загружающий алгоритм делает прямо противоположное: он берёт из общей памяти
persistent_script
и просматривает все его динамические структуры, копируя общие указатели в указатели, размещённые в привязанной к процессу памяти. После этого скрипт готов к запуску с помощью движка Zend (Zend Engine Executor), теперь он не встраивает адреса общих указателей (что приведёт к серьёзным багам, когда один скрипт изменяет структуру другого). Теперь Zend обманут OPCache: он не заметил произошедшей перед исполнением скрипта подмены указателей.Процесс копирования из обычной памяти в общую (кэширование скрипта) и обратно (загрузка скрипта) хорошо оптимизирован, и даже если приходится выполнять много копирований или поисков по хэшу, что не улучшает производительность, всё равно получается гораздо быстрее, чем каждый раз запускать PHP-компилятор.
Совместное использование внутреннего хранилища строк
Внутреннее хранилище строк (interned strings) — это хорошая оптимизация памяти, появившаяся в PHP 5.4. Это выглядит логично: когда PHP встречает строку (char*), он сохраняет её в специальный буфер и снова использует указатель каждый раз, когда встречает ту же строку Вы можете больше узнать о них из этой статьи.
Они работают так:
Все указатели используют один и тот же экземпляр строки. Но тут есть одна проблема: буфер этой внутренней строки используется отдельно для каждого процесса и в основном управляется PHP-компилятором. Это означает, что в пуле PHP-FPM каждый рабочий процесс PHP будет сохранять собственную копию этого буфера. Примерно так:
Это приводит к большим потерям памяти, особенно когда у вас много рабочих процессов, и когда вы используете в коде очень большие строковые (подсказка: поясняющие комментарии в PHP — это строки).
OPCache делит этот буфер между всеми рабочими процессами в пуле. Как-то так:
Для хранения всех этих совместно используемых буферов OPCache использует сегмент общей памяти. Следовательно, при назначении размера сегмента нужно учитывать и ваше использование внутреннего хранилища строк. С помощью INI-настройки opcache.interned_strings_buffer можно настраивать использование общей памяти для хранилища. Ещё раз напомню: удостоверьтесь, что у вас выделено достаточно памяти. Если вам не хватит места для этих строк (слишком низкое значение opcache.interned_strings_buffer), то OPCache не перезапустится. Ведь у него ещё достаточно свободной общей памяти, переполнен только буфер хранилища строк, что не блокирует обработку запроса. Вы просто не сможете сохранять и совместно использовать строки, а также окажутся недоступны строки, использующие память рабочего процесса PHP. Лучше избегать таких ситуаций, чтобы не снижать производительность.
Проверяйте логи: когда у вас кончится память для этого, OPCache предупредит об этом:
if (ZCSG(interned_strings_top) + ZEND_MM_ALIGNED_SIZE(sizeof(Bucket) + nKeyLength) >=
ZCSG(interned_strings_end)) {
/* память кончилась, возвращается та же несохраненная строка*/
zend_accel_error(ACCEL_LOG_WARNING, "Interned string buffer overflow");
return arKey;
}
К таким строкам относятся почти все виды строк, которые встречаются PHP-компилятору во время его работы: имена переменных, «php-строки», имена функций, имена классов… Комментарии, которые сегодня называют «аннотациями», это тоже строки, причём чаще всего огромного размера. Они занимают большую часть буфера, так что не забывайте о них.
Механизм блокировки
Раз уж мы говорим об общей памяти, то должны поговорить и о механизмах блокировки памяти. Суть такая: каждый PHP-процесс, желающий записать в общую память, заблокирует все другие процессы, которые тоже хотят в неё записать. Так что основные трудности связаны с записью, а не с чтением. У вас может быть 150 PHP-процессов, читающих из общей памяти, но при это единовременно писать в неё может только один. Операция записи блокирует не чтение, а только другие операции записи.
Так что в OPCache не должно возникать взаимных блокировок, пока вы не захотите резко прогреть свой кэш. Если после развёртывания кода вы не будете регулировать трафик на сервер, то скрипты начнут интенсивно компилироваться и кэшироваться. А поскольку операция запись кэша в общую память выполняется при условии эксклюзивной блокировки, то у вас встанут все процессы, потому что какой-то счастливчик начал писать в память и заблокировал всех остальных. И когда он снимет блокировку, то все остальные процессы, ожидавшие своей очереди, обнаружат, что файл, который они только что скомпилировали, уже сохранён в общей памяти. И тогда они начнут уничтожать результат компилирования, чтобы загрузить данные из общей памяти. Это непростительная трата ресурсов.
/* эксклюзивная блокировка */
zend_shared_alloc_lock(TSRMLS_C);
/* Проверьте, нужно ли положить файл в кэш (может быть, он уже туда положен
* другим процессом. Эта заключительная проверка выполняется при
* эксклюзивной блокировке) */
bucket = zend_accel_hash_find_entry(&ZCSG(hash), new_persistent_script->full_path, new_persistent_script->full_path_len + 1);
if (bucket) {
zend_persistent_script *existing_persistent_script = (zend_persistent_script *)bucket->data;
if (!existing_persistent_script->corrupted) {
if (!ZCG(accel_directives).revalidate_path &&
(!ZCG(accel_directives).validate_timestamps ||
(new_persistent_script->timestamp == existing_persistent_script->timestamp))) {
zend_accel_add_key(key, key_length, bucket TSRMLS_CC);
}
zend_shared_alloc_unlock(TSRMLS_C);
return new_persistent_script;
}
}
Вам нужно отключить сервер от внешнего трафика, развернуть новый код и подергать curl’ом самые тяжёлые URLы, чтобы curl-запросы постепенно заполняли общую память. Когда вы закончите с большинством своих скриптов, можете пустить трафик на сервер, и тогда начнётся активное чтение из общей памяти, а это не приводит к блокировкам. Конечно, могут оставаться небольшие скрипты, которые ещё не скомпилировались, но поскольку их немного, то это мало скажется на блокировании записи.
Избегайте в ходе выполнения записи PHP-файлов с последующим их использованием. Причина та же: когда вы записываете новый файл в корневую папку production-сервера, а затем используете его, то есть вероятность того, что тысячи рабочих процессов попытаются скомпилировать и закэшировать его в общую память. И тогда возникнет блокировка. Динамически генерируемые PHP-файлы должны добавляться в чёрный список OPCache с помощью INI-настройки opcache.blacklist-filename (она принимает маски (glob pattern)).
Формально механизм блокировки не слишком силён, но встречается во многих разновидностях Unix — он использует знаменитый вызов
fcntl()
:void zend_shared_alloc_lock(TSRMLS_D)
{
while (1) {
if (fcntl(lock_file, F_SETLKW, &mem_write_lock) == -1) {
if (errno == EINTR) {
continue;
}
zend_accel_error(ACCEL_LOG_ERROR, "Cannot create lock - %s (%d)", strerror(errno), errno);
}
break;
}
ZCG(locked) = 1;
zend_hash_init(&xlat_table, 100, NULL, NULL, 1);
}
Мы поговорили о блокировках памяти, возникающих при работе обычных процессов: если вы будете следить за тем, чтобы в общую память единовременно писал только один процесс, то у вас не будет проблем с блокировками.
Но есть и другой вид блокировки, которого нужно избегать: истощение памяти. Этому посвящена следующая глава.
Потребление памяти OPCache
Как вы помните:
- При запуске PHP (когда вы запускаете PHP-FPM) OPCache создаёт один уникальный сегмент общей памяти, используемый для разных нужд.
- В рамках этого сегмента OPCache никогда не освобождает память. Сегмент заполняется по мере необходимости.
- OPCache блокирует общую память во время записи.
- Общая память используется для:
- Кэширования структуры данных скрипта, включая и кэширование опкодов.
- Создания буфера общего внутреннего хранилища строк.
- Хранения хэш-таблицы кэшированных скриптов.
- Хранения состояния глобальной общей памяти OPCache.
Если вы используете проверку скриптов, то OPCache будет проверять дату их изменения при каждом доступе (можно сделать и не при каждом, измените INI-настройку opcache.revalidate_freq) и подскажет, насколько файл свежий. Эта проверка кэшируется: она не настолько дорога, как вам кажется. Иногда после PHP на сцену выходит OPCache, а PHP уже определил (
stat()
) файл: тогда OPCache повторно использует эту информацию, и ради собственных нужд не выполняет снова «дорогой» вызов stat()
файловой системы.Если вы используете проверку временной метки (timestamp) посредством opcache.validate_timestamps и opcache.revalidate_freq, а ваш файл уже фактически изменился, то OPCache просто сочтёт его недействительным и всем его данным в общей памяти присвоит флаг «wasted». OPCache перезапускается только когда у него кончается выделенная общая память И когда доля потерянной памяти достигает значения INI-настройки opcache.max_wasted_percentage INI. Всеми способами избегайте этого. Других вариантов нет.
/* Вычисление необходимого объёма памяти */
memory_used = zend_accel_script_persist_calc(new_persistent_script, key, key_length TSRMLS_CC);
/* Выделение общей памяти */
ZCG(mem) = zend_shared_alloc(memory_used);
if (!ZCG(mem)) {
zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_OOM TSRMLS_CC);
zend_shared_alloc_unlock(TSRMLS_C);
return new_persistent_script;
}
На картинке показано, как может выглядеть сегмент общей памяти спустя некоторое время, когда часть скриптов изменились. Память изменённых скриптов помечена как «потерянная», и OPCache её попросту игнорирует. Также он перекомпилирует изменённые скрипты и создаст для хранения их информации новый сегмент памяти.
Когда количество потерянной памяти достигает некого предела, выполняется перезапуск. OPCache блокирует общую память, опустошает её и снимает блокировку. Это помогает вашему серверу в ситуациях, когда он только запустился: каждый рабочий процесс пытается скомпилировать файлы, и поэтому стремится заблокировать память. Из-за этих блокировок сервер работает очень медленно. Чем выше нагрузка, тем ниже производительность, таково неприятное правило блокировок. И это может продолжаться долгие секунды.
Поэтому никогда не допускаете истощения общей памяти.
В общем, вам нужно отключить отслеживание модифицирования скриптов на production-сервере, тогда кэш никогда не будет перезапускаться (на самом деле, это не совсем так: у OPCache ещё может закончиться место для ключа persistent-скрипта, о чём мы поговорим ниже). При классическом развёртывании нужно соблюдать следующие правила:
- Отключите сервер от нагрузки (отключите от балансировщика).
- Очистите OPCache (вызовите
opcache_reset()
) или напрямую закройте FPM (так даже лучше, но об этом — ниже)). - Целиком разверните новую версию приложения.
- Перезапустите пул FPM, если нужно, и постепенно заполните новый кэш с помощью curl-запросов на основные точки входа приложения.
- Снова пустите трафик на сервер.
Всё это можно сделать с помощью shell-скрипта из 50 строк. Если некоторые тяжёлые запросы не собираются заканчивать, то этот же скрипт может применить к ним
lsof
и kill
. Вспоминайте возможности Unix ;-)Также вы можете получить представление о происходящем с помощью любого из многожества GUI-фронтендов для OPCache. Все они используют функцию
opcache_get_status()
:Но история на этом не закончена. Ещё нужно хорошо помнить про ключи кэша (cache keys).
Когда OPCache сохраняет в общую память закэшированный скрипт, то он сохраняет его в хэш-таблицу, чтобы можно было потом отыскать этот скрипт. Для индексирования хэш-таблицы OPCache должен выбрать ключ. Какой ключ? Это во многом зависит от конфигурации и архитектуры вашего приложения.
Обычно OPCache резолвит полный путь к скрипту. Но будьте осторожны, потому что он использует realpath_cache, а это может вам навредить. Если с помощью симлинка вы измените корневую папку, то присвойте opcache.revalidate_path значение 1 и очистите realpath cache (это может быть непросто выполнить, потому что кэш привязан к рабочему процессу, обрабатывающему текущий запрос).
Итак, OPCache резолвит полный путь к файлу, при этом в качестве ключа кэша для скрипта используется строка realpath. Подразумевается, что значение INI-настройки opcache.revalidate_path равно 1. Если это не так, то OPCache будет использовать в качестве ключа кэша unresolved путь. Это приведёт к проблемам в случае, если вы применяли симлинки, потому что если вы потом изменили цель симлинка, то OPCache этого не заметит и по-прежнему будет использовать unresolved путь в качестве ключа, чтобы искать старый целевой скрипт (для экономии вызова резолвинга симлинка).
Если присвоить opcache.use_cwd значение 1, то OPCache будет добавлять
cwd
в начало каждого ключа. Это делают при использовании относительных путей для вставки файлов, наподобие require_once "./foo.php";
. Если вы тоже используете относительные пути, и при этом хостите на одном экземпляре PHP несколько приложений (чего делать не следует), то я предлагаю всегда присваивать opcache.use_cwd значение 1. Кроме того, если вы использовали симлинки, то присвойте единицу и opcache.revalidate_path. Но всё это не спасёт вас от проблем с realpath-кэшем. Вы даже можете поменять www-симлинк на другую цель, OPCache этого не заметит, даже если вы очистите кэш с помощью opcache_reset()
.Из-за realpath-кэша вы можете столкнуться с проблемами при использовании симлинков для обработки корня для развёртывания. Присвойте opcache.use_cwd и opcache.revalidate_path значение 1, но даже в этом случае могут происходить плохие разрешения симлинков. По этой причине на запросы разрешения realpath от OPCache, PHP даёт неправильный ответ, исходящий от механизма
realpath_cache
.Если вы хотите надёжно обезопасить себя при развёртывании, то в первую очередь не используйте симлинки для управления documentroot. Если такой задачи у вас нет, тогда используйте двойной FPM-пул и балансировщик FastCGI, чтобы при развёртывании балансировать нагрузку между двумя пулами. Насколько я помню, эта функция по умолчанию включена в Lighttpd и Nginx:
- Отключите сервер от нагрузки (отключите от балансировщика).
- Закройте FPM, тем самым вы убьёте PHP (а затем и OPCache). Это обеспечит вам полную безопасность, особенно в связи с realpath-кэшем, который может ввести вас в заблуждение. Он будет очищен при закрытии FPM. Отслеживайте рабочие процессы, которые могли застрять, и при необходимости уничтожайте их.
- Разверните новую версию вашего приложения.
- Перезапустите FPM-пул. Не забудьте постепенно заполнить новый кэш с помощью curl-запросов на основные точки входа приложения.
- Снова пустите трафик на сервер.
Если вы не хотите отключать сервер от балансировщика, что можно сделать позднее, то выполните следующие действия:
- Разверните свой новый код в другой папке, поскольку PHP-сервер всё ещё имеет один активный FPM-пул и обслуживает production-запросы.
- Запустите ещё один FPM-пул, прослушивая другой порт. Первый пул должен всё ещё быть активен и обслуживать production-запросы.
- Теперь у вас есть два FPM-пула: один горячий, второй ожидает запросы.
- Измените цель documentroot-симлинка на новый путь развёртывания, и сразу же после этого остановите первый FPM-пул.
Если ваш веб-сервер знает об обоих пулах, то он увидит, что первый умирает, и попытается перебалансировать трафик на новый пул, без прерывания трафика и потери запросов. После этого начнёт работать второй пул, который зарезолвит новый documentroot-симлинк (пока он свежий и имеет чистый realpath-кэш), и обслуживать новый контент. Этот алгоритм действий работает хорошо, я много раз применял его на production-серверах. Достаточно написать shell-скрипт строк на 80.
В зависимости от настроек, для одного уникального скрипта OPCache может вычислить несколько разных ключей. Но хранилище ключей не бесконечно: оно тоже находится в общей памяти и может заполниться. В этом случае OPCache поведёт себя так, словно у него вообще закончилась память, даже если в сегменте общей памяти ещё достаточно места: для следующего запроса будет инициирован перезапуск.
Поэтому всегда отслеживайте количество ключей в хранилище, оно не должно полностью заполниться.
OPCache даёт вам эту информацию при использовании
opcache_get_status()
— функции, на которую опираются разные GUI — когда возвращается количество num_cached_keys. Дам совет: заранее сконфигурируйте количество ключей с помощью INI-настройки opcache.max_accelerated_files. В имени настройки подразумевается не количество файлов, а количество вычисляемых OPCache ключей. Как мы видели, разные ключи могут вычисляться для одного файла. Отслеживайте этот параметр и используйте правильное значение. Избегайте относительных путей в выражениях require_once
, иначе OPCache будет генерировать больше ключей. Рекомендуется использовать хорошо сконфигурированный автозагрузчик, чтобы всегда делать запросы include_once
с полными путями, а не относительными.При запуске OPCache создаёт в памяти хэш-таблицу для хранения будущих persistent-скриптов, и никогда не меняет её размер. Если хэш-таблица заполнится, то она инициирует перезапуск. Это сделано для улучшения производительности.
Поэтому количество num_cached_scripts может отличаться от num_cached_keys, от отчёта о статусе OPСache. Релевантно только значение num_cached_keys. Если оно достигает max_cached_keys, то у вас возникнет проблема перезапуска.
Не забывайте, вы можете понять, что происходит, за счёт снижения уровня лога OPСache (INI-настройка opcache.log_verbosity_level). Он подскажет вам, если память заканчивается, и сообщит, какая OOM-ошибка сгенерирована (OutOfMemory): заполнилась ли общая память или хэш-таблица.
static void zend_accel_add_key(char *key, unsigned int key_length, zend_accel_hash_entry *bucket TSRMLS_DC)
{
if (!zend_accel_hash_find(&ZCSG(hash), key, key_length + 1)) {
if (zend_accel_hash_is_full(&ZCSG(hash))) {
zend_accel_error(ACCEL_LOG_DEBUG, "No more entries in hash table!");
ZSMMG(memory_exhausted) = 1;
zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_HASH TSRMLS_CC);
} else {
char *new_key = zend_shared_alloc(key_length + 1);
if (new_key) {
memcpy(new_key, key, key_length + 1);
if (zend_accel_hash_update(&ZCSG(hash), new_key, key_length + 1, 1, bucket)) {
zend_accel_error(ACCEL_LOG_INFO, "Added key '%s'", new_key);
}
} else {
zend_accel_schedule_restart_if_necessary(ACCEL_RESTART_OOM TSRMLS_CC);
}
}
}
}
Подведём итог использованию памяти:
При старте PHP вы запускаете OPCache, он немедленно размещает opcache.memory_consumption мегабайт общей памяти (shm). Дальше эта память используется для общего хранилища внутренних строк (opcache.interned_strings_buffer). Также в памяти размещается хэш-таблица для хранения будущих persistent-скриптов и их ключей. Объём используемой памяти зависит от opcache.max_accelerated_files.
Теперь часть общей памяти используется внутренними компонентами OPCache, а вы можете заполнять оставшийся объём структурами данных ваших скриптов. Сегмент памяти будет заполняться по мере изменения скриптов и их перекомпилировании OPCache (если вы ему так скажете). Память будет постепенно переходить в разряд «потерянно», если только вы не скажете OPCache, чтобы он не перекомпилировал изменённые скрипты (рекомендуется).
Это может выглядеть так:
Если хэш-таблица persistent-скриптов окажется заполненной, или закончится свободная общая память, то OPCache будет перезапущен (вам нужно избегать этого любой ценой).
Конфигурирование OPCache
Если вы используете приложение на базе фреймворка, например, Symfony, то я очень рекомендую:
- Выключить на production механизм повторной проверки (присвоить opcache.validate_timestamps значение 0)
- Развернуть приложение, используя полностью новый runtime скриптов. Это как раз тот случай с приложениями на базе Symfony.
- Правильно настроить размер буферов:
- opcache.memory_consumption. Это особенно важно.
- opcache.interned_strings_buffer. Отслеживайте объём потребляемой памяти и в соответствии с ним настройте размер. Не забывайте: если вы скажете OPCache сохранять комментарии, которые наверняка будете писать, если используете «аннотации» PHP (opcache.save_comments = 1), что это тоже строки, причём большие, и они очень активно заполняют буфер хранилища.
- opcache.max_accelerated_files. Заранее определённое количество ключей. Опять же, отслеживайте потребление и настраивайте в соответствии с ним.
- Выключить opcache.opcache.revalidate_path и opcache.use_cwd. Это сэкономит объём в хранилище ключей.
- Включить opcache.enable_file_override, это ускорит автозагрузчик.
- Заполнить список opcache.blacklist_filename именами скриптов, которые вы наверняка сгенерируете в ходе runtime. Но их в любом случае не должно быть слишком много.
- Выключить opcache.consistency_checks, по сути, это проверка сумы ваших скриптов, съедающая производительность.
После всех этих процедур ваша память больше не должна «теряться». В этом случае уже мало пользы от then opcache.max_wasted_percentage. Также вам нужно будет при развёртывании выключить главный экземпляр FPM. Можете поиграться с несколькими пулами FPM, как было описано выше, чтобы не возникало простоя сервиса.
Всего этого должно быть достаточно.
Оптимизация компилятора OPCache
Введение
Итак, мы обсудили кэширование опкодов в общей памяти и их загрузку обратно. Непосредственно перед их кэшированием OPCache может также несколько раз прогнать оптимизатор. Чтобы понять его работу, вам нужно хорошо знать, как работает исполнитель виртуальной машины Zend. Если вы пока новичок в вопросе работы компилятора, то можете начать с чтения статей, посвящённых этой теме. Или хотя бы изучите обязательную к чтению «Книгу дракона». В любом случае, я постараюсь описывать понятным языком и не слишком скучно.
В принципе, оптимизатор получает всю структуру OPArray, которую может просматривать, находить утечки и исправлять их. Но поскольку мы анализируем опкоды в течение компилирования, то у нас нет никаких подсказок относительно всего, что связано с «переменной PHP». Мы пока не знаем, что будет сохранено в операндах IS_VAR и IS_CV, знаем лишь будущее содержимое IS_CONST и иногда — IS_TMP_VAR. Как и в любом компиляторе любого языка мы должны создать структуру, наиболее оптимизированную для выполнения в ходе runtime, чтобы всё прошло как можно быстрее.
Оптимизатор OPCache может оптимизировать много вещей в IS_CONST. Также мы можем заменять одни опкоды другими (оптимизированными под runtime); с помощью CGF-анализа (графы потоков управления) можем находить и удалять неисполняемые куски кода. Но мы пока не проходим по циклам и не выносим за их рамки инвариантный код. У нас есть и другие возможности относительно внутренних компонентов PHP: можно изменить способ привязки классов, чтобы в некоторых случаях оптимизировать этот процесс. Но у нас нет никакой возможности выполнить кросс-файловую оптимизацию, потому что OPCache работает с используемыми при компиляции файлов OPArray (не считая OPArray других функций), а они полностью изолированы. PHP никогда не был построен на основе кросс-файловой виртуальной машины — и язык, и виртуальная машина ограничены рамками одного файла: во время компилирования файла у нас нет никакой информации об уже скомпилированных файлах и следующих на очереди. Поэтому мы вынуждены пытаться оптимизировать файл за файлом, и не должны предполагать, например, что класс А будет представлен в будущем, если сейчас его нет. Этот подход сильно отличается от Java или С++, которые компилируют «проект» целиком и могут выполнять много кросс-файловых оптимизаций. PHP так не может.
Компилятор PHP работает в рамках одного файла и не имеет общего состояния в течение нескольких компиляций файлов. Он компилирует проект не целиком, а по файлам, один за другим. Так что просто нет возможности выполнять кросс-файловые оптимизации.
Оптимизация OPCache может применяться и только для каких-то конкретных случаев. За это отвечает INI-настройка opcache.optimization_level. Она представляет собой маску желаемых оптимизаций на базе двоичных значений:
/* zend_optimizer.h */
#define ZEND_OPTIMIZER_PASS_1 (1<<0) /* CSE, конструкция STRING */
#define ZEND_OPTIMIZER_PASS_2 (1<<1) /* Постоянное преобразование и переходы */
#define ZEND_OPTIMIZER_PASS_3 (1<<2) /* ++, +=, серия переходов */
#define ZEND_OPTIMIZER_PASS_4 (1<<3) /* INIT_FCALL_BY_NAME -> DO_FCALL */
#define ZEND_OPTIMIZER_PASS_5 (1<<4) /* оптимизация на базе CFG */
#define ZEND_OPTIMIZER_PASS_6 (1<<5)
#define ZEND_OPTIMIZER_PASS_7 (1<<6)
#define ZEND_OPTIMIZER_PASS_8 (1<<7)
#define ZEND_OPTIMIZER_PASS_9 (1<<8) /* использование TMP VAR */
#define ZEND_OPTIMIZER_PASS_10 (1<<9) /* удаление NOP */
#define ZEND_OPTIMIZER_PASS_11 (1<<10) /* Объединение одинаковых констант */
#define ZEND_OPTIMIZER_PASS_12 (1<<11) /* Подгонка использованного стека */
#define ZEND_OPTIMIZER_PASS_13 (1<<12)
#define ZEND_OPTIMIZER_PASS_14 (1<<13)
#define ZEND_OPTIMIZER_PASS_15 (1<<14) /* Сбор констант */
#define ZEND_OPTIMIZER_ALL_PASSES 0xFFFFFFFF
#define DEFAULT_OPTIMIZATION_LEVEL "0xFFFFBFFF"
Известные постоянные выражения и удаление веток
Обратите внимание, что в PHP 5 многие постоянные выражения, известные во время компилирования, вычислены НЕ компилятором, а OPCache. Зато в PHP 7 они уже вычисляются компилятором.
Пример:
if (false) {
echo "foo";
} else {
echo "bar";
}
При классическом компилировании получаем:
Оптимизированное компилирование:
Как видите, неисполняемый код в ветке
if(false)
был удалён, после чего виртуальная машина Zend просто запустила опкод ZEND_ECHO
. Это сэкономило нам память, потому что мы выкинули несколько опкодов. Возможно, немного сэкономили и циклы процессора в течение runtime.Напоминаю, что мы пока не можем знать содержимое любой переменной, поскольку мы ещё в процессе компилирования (находимся между компилированием и исполнения). Если бы у нас вместо операнда IS_CONST был IS_CV, то код нельзя было бы оптимизировать:
/* Это нельзя оптимизировать, что находится в $a ? */
if ($a) {
echo "foo";
} else {
echo "bar";
}
Возьмём другой пример, чтобы показать разницу между PHP 5 и PHP 7:
if (__DIR__ == '/tmp') {
echo "foo";
} else {
echo "bar";
}
В PHP 7 будет подставлено значение константы
__DIR__
и компилятор выполнит проверку тождественности, без участия OPCache. Однако анализ ветвей и удаление неисполняемого кода выполняется при прохождении оптимизатора OPCache. В PHP 5.6 тоже подставляется значение константы __DIR__
, но компилятор не проверяет тождественность. Это позднее делает OPCache.Подведём итог. Если вы запустите PHP 5.6 и PHP 7 с активированным оптимизатором OPCache, то в результате получите одинаковые оптимизированные опкоды. Но если вы не воспользуетесь оптимизатором, то скомпилированный в PHP 5.6 код будет менее эффективен, чем в PHP 7, потому что компилятор PHP 5.6 не выполняет никаких оценок, в то время как компилятор PHP 7 самостоятельно вычисляет много вещей (без привлечения оптимизатора OPCache).
Предварительная оценка функций-констант
OPCache умеет превращать некоторые IS_TMP_VAR в IS_CONST. Иными словами, он может в ходе компилирования самостоятельно вычислять некоторые известные значения. Поэтому какие-то функции могут выполняться уже в ходе компилирования, если их результаты являются константами. Вот некоторые из таких функций:
function_exists()
иis_callable()
, только для внутренних функций.extension_loaded()
, если в пространстве пользователя отключёнdl()
.defined()
иconstant()
, только для внутренних констант.dirname()
если аргумент является константой.strlen()
иdirname()
с аргументами-константами (только в PHP 7).
Взгляните на пример:
if (function_exists('array_merge')) {
echo 'yes';
}
Если отключить оптимизатор, то компилятор нагенерирует немало работы для runtime:
Оптимизация включена:
Обратите внимание, что эти функции не вычисляют в пространстве пользователя. К примеру, функция:
if function_exists('my_custom_function')) { }
не оптимизирована, потому что наверняка в другом файле у вас определена 'ваша_кастомная_функция’. Не забудьте, что компилятор PHP и оптимизатор OPCache работают только пофайлово. Даже если вы сделаете так:
function my_custom_function() { }
if function_exists('my_custom_function')) { }
Этот код не будет оптимизирован, потому что вероятность слишком мала, оптимизатор вызова функций работает только для внутренних типов (внутренних функций и констант).
Другой пример с
dirname()
(только для PHP 7):if (dirname(__FILE__) == '/tmp') {
echo 'yo';
}
Без оптимизации:
С оптимизацией:
strlen()
в PHP 7 оптимизированы. Если мы соединим их в цепочку, то наверняка будет выполнена качественная оптимизация. Например:if (strlen(dirname(__FILE__)) == 4) {
echo "yes";
} else {
echo "no";
}
Без оптимизации:
С оптимизацией:
Вы могли заметить в предыдущем примере, что каждое выражение было вычислено в процессе компилирования/оптимизации, а затем оптимизатор OPCache удалил все «фальшивые» ветви (предполагая, что выбрана «правильная» часть).
Транстипизация (Transtyping)
Оптимизатор OPCache может переключать типы вашего операнда IS_CONST, если знает, что среда выполнения должна будет их транстипизировать. Это неплохо экономит циклы процессора во время runtime:
$a = 8;
$c = $a + "42";
echo $c;
Классическое компилирование:
Оптимизированное компилирование:
Обратите внимание на второй true-тип операции
ZEND_ADD
: он был переключён с строчного на целочисленный. Оптимизатор выполнил транстипизацию аргумента для математической операции ADD
. Если бы он этого не сделал, то виртуальная машина runtime делал бы это снова, снова, снова и снова, пока выполняется код. Так что на транстипизации мы сэкономили циклы процессора.Вот код оптимизатора OPCache, который выполняет эту работу:
if (ZEND_OPTIMIZER_PASS_2 & OPTIMIZATION_LEVEL) {
zend_op *opline;
zend_op *end = op_array->opcodes + op_array->last;
opline = op_array->opcodes;
while (opline < end) {
switch (opline->opcode) {
case ZEND_ADD:
case ZEND_SUB:
case ZEND_MUL:
case ZEND_DIV:
if (ZEND_OP1_TYPE(opline) == IS_CONST) {
if (ZEND_OP1_LITERAL(opline).type == IS_STRING) {
convert_scalar_to_number(&ZEND_OP1_LITERAL(opline) TSRMLS_CC);
}
}
/* break missing *intentionally* - операция присваивания может оптимизировать только op2 */
case ZEND_ASSIGN_ADD:
case ZEND_ASSIGN_SUB:
case ZEND_ASSIGN_MUL:
case ZEND_ASSIGN_DIV:
if (opline->extended_value != 0) {
/* операция над объектом с тремя состояниями – не пытайтесь оптимизировать! */
break;
}
if (ZEND_OP2_TYPE(opline) == IS_CONST) {
if (ZEND_OP2_LITERAL(opline).type == IS_STRING) {
convert_scalar_to_number(&ZEND_OP2_LITERAL(opline) TSRMLS_CC);
}
}
break;
/* ... ... */
Однако вы должны были заметить, что подобная оптимизация была перенесена в компилятор PHP 7. Это означает, что компилятор PHP 7 уже выполняет эту оптимизацию даже с отключённым OPCache (или отключённой оптимизацией), также как и многое другое, что не выполнялось компилятором PHP 5.
Если добавить два выражения IS_CONST, то результат может быть вычислен в ходе компилирования. В PHP 5 компилятор по умолчанию этого не делает, нужен оптимизатор OPCache:
$a = 4 + "33";
echo $a;
Классическое компилирование:
Оптимизированное компилирование:
Оптимизатор вычислил результат
4 + 33
и стёр операцию ZEND_ADD
, заменив её непосредственно результатом. Это снова даёт экономию циклов процессора в ходе runtime, потому что исполнителю виртуальной машины нужно теперь проделать меньше работы. Повторюсь: в PHP 7 это делается компилятором, а в PHP 5 потребуется оптимизатор OPCache.Оптимизированная подстановка опкодов
Давайте теперь подробнее рассмотрим опкоды. Изредка можно подставлять вместо одних опкодов другие, оптимизированные.
$i = "foo";
$i = $i + 42;
echo $i;
Классическое компилирование:
Оптимизированное компилирование:
Знание исполнителя виртуальной машины Zend VM позволяет нам подставить
ZEND_ASSIGN_ADD
вместо ZEND_ADD
и ZEND_ASSIGN
. Это можно делать для выражений вроде $i+=3;
. ZEND_ASSIGN_ADD
лучше оптимизирован, получается один опкод вместо двух (обычно это предпочтительнее, но не всегда)На ту же тему:
$j = 4;
$j++;
echo $j;
Классическое компилирование:
Оптимизированное компилирование:
Здесь оптимизатор OPCache подставил выражение
++$i
вместо $i++
, потому что в этом куске кода оно имеет такое же значение. ZEND_POST_INC
— не слишком хороший опкод, потому что он должен считать значение, возвращает его как есть, но инкрементирует в памяти временное значение, поскольку ZEND_PRE_INC
использует само значение: считывает, инкрементирует и возвращает (в этом заключается разница между предварительным и постинкрементированием). Поскольку промежуточное значение, возвращаемое ZEND_POST_INC
, не используется в вышеприведённом скрипте, то чтобы освободить его из памяти компилятор должен выпустить опкод ZEND_FREE
. Оптимизатор OPCache превращает структуру в ZEND_PRE_INC
и убирает бесполезный ZEND_FREE
: меньше работы для runtime.Подстановка констант и предварительное вычисление
А что насчёт PHP-констант? Они гораздо сложнее, чем вы можете думать. Поэтому некоторые очевидные, на первый взгляд, оптимизации не делаются по ряду причин. Давайте рассмотрим пример:
const FOO = "bar";
echo FOO;
Оптимизированное компилирование:
Это часть оптимизаций временных переменных. Как видите, опкод был удалён, при компилировании оптимизатором вычислен результат чтения константы, поэтому в ходе runtime нам нужно проделать меньше работы.
Уродливая функция
define()
может быть заменена выражением const
, если её аргумент является константой:define('FOO', 'bar');
echo FOO;
Неоптимизированные опкоды из этого маленького скрипта ужасно влияют на производительность:
Оптимизированное компилирование:
define()
уродлива, потому что объявляет константу, но делает это в ходе runtime, вызывая функцию (define()
является функцией). Это очень плохо. Ключевое слово const
приводит нас к опкоду DECLARE_CONST
. Подробнее можно почитать об этом в моей статье про виртуальную машину Zend.Разрешение многочисленных меток перехода (Multiple jump target resolution)
Это расписывать несколько труднее, но попробую показать на примере. Оптимизация касается меток перехода в опкодах перехода (их несколько видов). Каждый раз, когда виртуальной машине нужно совершить переход, адрес перехода вычисляется компилятором и сохраняется в операнде виртуальной машины. Переход — это результат решения, когда ВМ встречает точку принятия решения. В PHP-скриптах много переходов.
if
, switch
, while
, try
, foreach
, ?
: — всё это выражения принятия решений. Если решение равно true, то выполняется переход в ветку А, в противном случае — в ветку Б.Подобные алгоритмы можно оптимизировать, если целью перехода является сам переход. Тогда landing-переход заставит виртуальную машину снова перейти, к финальному landing-переходу. Разрешение многочисленных меток перехода заключается в том, чтобы заставить ВМ сразу переходить к конечной точке «маршрута».
Например:
if ($a) {
goto a;
} else {
echo "no";
}
a:
echo "a";
В случае классического компилирования мы получим такие опкоды:
Перевод (только вчитайтесь в это): «результат вычисления $a равен 0, то переходим к цели 3, где выводим «no». В противном случае продолжаем выполнение и переходим к 4, где выводим «a»».
Получается что-то вроде «Перейти к 3, а оттуда перейти к 4». Почему тогда сразу не «перейти к 4»? Вот что делает оптимизация:
Это можно перевести как «если результат вычисления $a не равен нулю, то перейти к 2 и вывести «a», в противном случае вывести «no»». Гораздо проще, верно? Особенно эффективна эта оптимизация в случае очень сложных скриптов, с большим количеством уровней принятия решений. Например,
while
в if
, в котором выполняется goto
, ведущий к switch
, выполняющему try-catches
, и т.д. Без оптимизации общий OPArray может содержать множество опкодов. Многие из них будут переходами, причём один переход будет вести к другому. Инода оптимизация может существенно уменьшить количество опкодов (зависит от скрипта) и упростить путь прохождения виртуальной машины. Так достигается небольшое повышение производительности в ходе runtime.Заключение
Я не показал все возможности оптимизатора. Например, он ещё может оптимизировать встроенные циклы с помощью «ранних возвратов» (early returns). Также он полезен для встраивания блоков try-catch или switch-break. По возможности оптимизируются и вызовы функций PHP, создающие серьёзную нагрузку на движок.
Главная трудность с оптимизатором заключается в том, чтобы он никогда не менял значение скрипта, и в особенности его поток управления. Некоторое время в OPCache были выявлены связанные с этим баги, о весьма неприятно наблюдать, как PHP ведёт себя не так, как ожидается, когда вы запускаете написанный вами маленький скрипт… По сути, генерируемые опкоды изменяются оптимизатором, и движок выполняет ошибочный код. Это не здорово.
Сегодня оптимизатор OPCache весьма стабилен, но всё ещё находится в стадии разработки для новых версий PHP. Его нужно хорошенько пропатчить в PHP 7, поскольку здесь было сделано много изменений в архитектуре внутренних структур. Также нужно заставить компилятор PHP 7 выполнять гораздо больше оптимизаций (большинство из которых тривиальны) по сравнению с PHP 5 (фактически компилятор PHP 5 вообще ничего не оптимизирует).
Возможно, вас удивляет, почему всё это не делается сразу в компиляторе. Дело в том, что мы хотим сохранить компилятор как можно более безопасным. Он генерирует опкоды, которые иногда, не при каждом компилировании, не слишком поддаются оптимизации. И тогда на помощь может придти внешний оптимизатор, вроде того, что есть в OPCache. То же самое касается любого другого компилятора: обычно они компилируют код в лоб, и только после этого можно применять различные оптимизаторы. Но исходный код после компилятора должен быть максимально безопасным (хотя и не слишком быстрым для runtime).
Конец
Мы увидели, что OPCache наконец-то стал официально рекомендованным решением для кэширования опкодов. Разобрали его работу, которая не слишком трудна для понимания, но пока ещё могут возникать ошибки. Сегодня OPCache работает очень стабильно о обеспечивает высокий прирост производительности PHP, уменьшая продолжительность компилирования скриптов и оптимизируя генерирование опкодов. Для каждого процесса PHP-пула используется общая память, что позволяет получать доступ к структурам, добавленным другими процессами. Буфер внутренних строк также расположен в общей памяти, что позволяет ещё больше экономить память в пуле рабочих процессов — обычно используя PHP-FPM SAPI.