[Перевод] Обзор расширения OPCache для PHP
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). Он подскажет вам, если память заканчивается, и сообщит, кака