Огляд розширення 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):

  • Хеш-таблиці функцій
    1. Таблицю контейнерів хеш-таблиці функцій (Bucket **)
    2. Контейнер хеш-таблиці функцій (Bucket *)

    3. Ключ контейнерів хеш-таблиці функцій (char *)
    4. Покажчик даних контейнерів хеш-таблиці функцій (void *)
    5. Дані контейнерів хеш-таблиці функцій (*)
  • OPArray функцій
    1. Ім'я файлу OPArray (char *)
    2. Літерали OPArray (імена (char) і значення (zval ))
    3. Опкоды OPArray (zend_op *)
    4. Імена функцій OPArray function name (char *)
    5. arg_infos OPArray (zend_arg_info, а також ім'я та ім'я класу обидва як char)
    6. Масив break-continue OPArray (zend_brk_cont_element *)
    7. Статичні змінні OPArray (Повна хеш-таблиця і zval*)
    8. Коментарі документації до OPArray (char *)
    9. Масив try-catch OPArray try- (zend_try_catch_element *)
    10. Обчислені змінні 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 блокує загальну пам'ять під час запису.
  • Загальна пам'ять використовується для:
    1. Кешування структури даних скрипта, включаючи і кешування опкодов.
    2. Створення буфера загального внутрішнього сховища рядків.

    3. Зберігання хеш-таблиці кешованих скриптів.
    4. Зберігання стану глобальної загальної пам'яті 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, від звіту про статус ОРСасһе. Релевантно тільки значення num_cached_keys. Якщо воно досягає max_cached_keys, то у вас виникне проблема перезапуску.

Не забувайте, ви можете зрозуміти, що відбувається, за рахунок зниження рівня лода ОРСасһе (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.
  • Правильно налаштувати розмір буферів:
    1. opcache.memory_consumption. Це особливо важливо.

    2. opcache.interned_strings_buffer. Відстежуйте об'єм споживаної пам'яті і відповідно з ним налаштуйте розмір. Не забувайте: якщо ви скажете OPCache зберігати коментарі, які напевно будете писати, якщо використовуєте «анотації» PHP (opcache.save_comments = 1), що це теж рядка, причому великі, і вони дуже активно заповнюють буфер сховища.
    3. 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 "так";
} else {
echo "ні";
}

Без оптимізації:



З оптимізацією:



Ви могли помітити в попередньому прикладі, що кожен вираз було обчислено в процесі компілювання/оптимізації, а потім оптимізатор 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 "ні";
}

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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.