Всем доброго времени суток! Меня зовут Константин, в Badoo я работаю в команде Features Team. Скорее всего, вы уже знаете, что наш бэкенд написан на PHP и обслуживает более трёх сотен миллионов пользователей. Так что я не мог упустить шанс перевести эту статью core-разработчика PHP Никиты Попова. Уверен, она будет полезна разработчикам всех уровней, но новичкам может показаться сложноватой. Приятного (и полезного) чтения!
В статье представлен обзор виртуальной машины Zend для PHP 7. Это не исчерпывающее описание, но я постараюсь охватить большинство важных частей, а также некоторые детали.
Описание сделано на основе PHP версии 7.2 (в настоящее время находится в разработке), но почти всё справедливо и для PHP 7.0/7.1. Однако отличия от виртуальных машин серии PHP 5.x являются значительными, и с ними я, как правило, не проводил параллели.
В большей части статьи рассматриваются вещи на уровне листингов инструкций, и только несколько разделов в конце касаются уровня фактической реализации виртуальной машины на языке C. Тем не менее я хочу предоставить ссылки на основные файлы, составляющие виртуальную машину:
- zend_vm_def.h: файл определений виртуальной машины;
- zend_vm_execute.h: сгенерированная виртуальная машина;
- zend_vm_gen.php: генерирующий скрипт;
- zend_execute.c: большая часть непосредственно обслуживающего кода.
Опкоды (Opcodes)
Вначале был опкод (opcode). Говоря «опкод», мы ссылаемся на инструкцию виртуальной машины целиком (включая операнды), но также это может обозначать только «фактический» код операции, который представляет собой число small integer, определяющее тип инструкции. Предполагаемое значение должно быть ясно из контекста. В исходном коде инструкции целиком обычно называются oplines.
Отдельная инструкция соответствует следующей структуре zend_op:
struct _zend_op {
const void *handler;
znode_op op1;
znode_op op2;
znode_op result;
uint32_t extended_value;
uint32_t lineno;
zend_uchar opcode;
zend_uchar op1_type;
zend_uchar op2_type;
zend_uchar result_type;
};
Таким образом, опкоды по существу представляют собой инструкцию в формате «трехадресного кода». Есть opcode, определяющий тип инструкции, есть два входных операнда op1 и op2 и один выходной операнд result.
Не все инструкции используют все операнды. Инструкция ADD (представляющая оператор +) будет использовать все три. Инструкция BOOL_NOT (представляющая оператор !) использует только op1 и result. Инструкция ECHO использует только op1. Некоторые инструкции могут использовать или не использовать операнд. Например, DO_FCALL может иметь или не иметь операнд результата (в зависимости от того, используется ли возвращаемое значение вызова функции). Некоторым инструкциям требуется больше двух входных операндов, и в этом случае для дополнительных операндов они просто будут использовать вторую вспомогательную инструкцию (OP_DATA).
Рядом с этими тремя стандартными операндами существует дополнительное числовое поле extended_value, которое может использоваться для хранения дополнительных модификаторов инструкций. Например, для CAST оно может содержать целевой тип, к которому нужно выполнить приведение.
Каждый операнд имеет тип, хранящийся в op1_type, op2_type и result_type соответственно. Возможные типы: IS_UNUSED, IS_CONST, IS_TMPVAR, IS_VAR и IS_CV.
Три последних типа предназначены для операндов-переменных (с тремя разными типами переменных виртуальной машины), IS_CONST обозначает операнд-константу (5, или «строка», или даже [1, 2, 3]), в то время как IS_UNUSED обозначает операнд, который либо фактически не используется, либо используется как 32-битное числовое значение (так называемый непосредственный операнд). Например, инструкция перехода будет хранить адрес перехода в операнде UNUSED.
Получение дампов опкодов
В дальнейшем я буду часто демонстрировать фрагменты опкода, которые генерирует PHP. В настоящее время существует три способа получения таких дампов опкода:
# Opcache, since PHP 7.1
php -d opcache.opt_debug_level=0x10000 test.php
# phpdbg, since PHP 5.6
phpdbg -p* test.php
# vld, third-party extension
php -d vld.active=1 test.php
Из них opcache предоставляет наилучший результат. Листинги, используемые в этой статье, основаны на дампах opcache, с незначительными корректировками синтаксиса. Магическое число 0x10000 является сокращением «до оптимизации», поэтому мы видим опкоды такими, какими их создал PHP-компилятор. 0x200000 выдаст вам оптимизированные опкоды. Opcache также может генерировать намного больше информации. Например, 0x40000 сгенерирует CFG, а 0x200000 выдаст SSA. Но не будем опережать события: для наших целей достаточно обычных старых линеаризованных дампов опкодов.
Типы переменных
Вероятно, одним из наиболее важных моментов, которые следует учитывать при работе с виртуальной машиной PHP, является использование трёх различных типов переменных. В PHP 5 TMPVAR, VAR и CV имели очень разные представления в стеке виртуальной машины, и способы доступа к ним тоже были очень разными. В PHP 7 они стали очень похожими, поскольку используют один и тот же механизм хранения. Однако существуют важные различия в значениях, которые они могут содержать, и в их семантике.
CV — сокращение от «скомпилированной переменной» (compiled variable). Она ссылается на «реальную» переменную PHP. Если функция использует переменную $a, то для неё будет соответствующая CV.
Переменные CV могут иметь тип UNDEF, чтобы обозначать неопределённые переменные. Если в инструкции используется UNDEF CV, это в большинстве случаев выдаёт широко известное уведомление «неопределённая переменная» (undefined variable). На входе функции все CV, не являющиеся аргументами, инициализируются как UNDEF.
Переменные CV не уничтожаются инструкциями. Например, инструкция ADD $a, $b не уничтожит значения, хранящиеся в переменных $a и $b. Вместо этого все CV-переменные уничтожаются одновременно при выходе из области видимости. Это также подразумевает, что все CV-переменные содержат допустимые значения в течение всей продолжительности функции.
Переменные TMPVAR и VAR, в свою очередь, являются временными переменными виртуальной машины. Они обычно вводятся в качестве операнда результата некоторой операции. Например, код $a = $b + $c + $d приведёт к опкоду, аналогичному следующему:
T0 = ADD $b, $c
T1 = ADD T0, $d
ASSIGN $a, T1
Переменные TMP/VAR всегда определяются перед использованием и как таковые не могут содержать значение UNDEF. В отличие от CV, эти типы переменных уничтожаются инструкциями, в которых они используются. В приведённом выше примере второй ADD уничтожит значение операнда T0, и после этой точки T0 не должна больше использоваться. Аналогично ASSIGN уничтожит значение T1, делая переменную T1 недействительной.
Из этого следует, что переменные TMP/VAR обычно очень недолговечны. В большинстве случаев они живут только в пределах одной инструкции. Вне этого короткого интервала значения в них являются мусором.
Так в чём же различия между TMP- и VAR-переменными? Их немного. Разница была унаследована от PHP 5, где TMP размещались в стеке виртуальной машины, а VAR – в куче. В PHP 7 все переменные размещаются в стеке. Таким образом, в настоящее время основное различие между TMP и VAR состоит в том, что только последним разрешено содержать ссылки (это позволяет нам исключать разыменование (DEREF) переменных TMP). Кроме того, VAR могут содержать два типа специальных значений, а именно class entries и значения INDIRECT. Последние используются для обработки нетривиальных присвоений.
В этой таблице приведены основные отличия переменных:
UNDEF | REF | INDIRECT | Consumed? | Named? | |
---|---|---|---|---|---|
CV | yes | yes | no | no | yes |
TMPVAR | no | no | no | yes | no |
VAR | no | yes | yes | yes | no |
Оп-массивы
Все функции PHP представлены в виде структур, имеющих общий заголовок zend_function. Понятие «функция» здесь трактуется несколько шире и включает в себя всё: от «реальных» функций и методов до автономного pseudo-main-кода и eval-кода.
В пользовательских функциях используется структура zend_op_array. У неё более 30 полей, поэтому я начну с её уменьшенной версии:
struct _zend_ {
/* Common zend_function header here */
/* ... */
uint32_t last;
zend_op *opcodes;
int last_var;
uint32_t T;
zend_string **vars;
/* ... */
int last_literal;
zval *literals;
/* ... */
};
Наиболее важная часть здесь — это, конечно, opcodes, которые представляют собой массив опкодов (инструкций). last — количество опкодов в этом массиве. Обратите внимание, что терминология несколько сбивает с толку, поскольку last звучит так, как будто он должен быть индексом последнего опкода, в то время как на самом деле это количество опкодов (на один больше, чем последний индекс). То же самое относится ко всем другим значениям last_* в структуре op_array.
last_var — это количество CV, а T — количество TMP и VAR (в большинстве случаев мы не делаем между ними различий). vars — массив имён для CV.
literals — это массив литералов, встречающихся в коде, то, на что ссылаются операнды CONST. В зависимости от ABI каждый операнд CONST будет либо содержать указатель на элемент этой таблицы литералов, либо хранить смещение относительно её начала.
В этой структуре есть ещё кое-что, но это можно отложить.
Схема стекового кадра
За исключением executor globals (EG), всё состояние выполнения хранится в стеке виртуальной машины. Стек VM распределяется на страницах по 256KiB, а отдельные страницы связаны через связанный список.
При каждом вызове функции в стеке виртуальных машин выделяется новый стековый кадр, имеющий следующую схему:
+----------------------------------------+
| zend_execute_data |
+----------------------------------------+
| VAR[0] = ARG[1] | arguments
| ... |
| VAR[num_args-1] = ARG[N] |
| VAR[num_args] = CV[num_args] | remaining CVs
| ... |
| VAR[last_var-1] = CV[last_var-1] |
| VAR[last_var] = TMP[0] | TMP/VARs
| ... |
| VAR[last_var+T-1] = TMP[T] |
| ARG[N+1] (extra_args) | extra arguments
| ... |
+----------------------------------------+
Кадр начинается со структуры zend_execute_data, за которой следует массив слотов переменных. Слоты все одинаковы (простые zval), но используются они для разных целей. Первые слоты last_var являются CV, из которых первая num_args содержит аргументы функции. За слотами CV следуют T-слоты для TMP/VAR. Наконец, иногда могут быть дополнительные аргументы, хранящиеся в конце кадра. Они используются для func_get_args().
Операнды CV и TMP/VAR в инструкциях кодируются как смещения относительно начала стекового кадра, поэтому выборка определённой переменной является простым чтением из ячейки по адресу execute_data плюс указанное смещение.
Данные в начале кадра определяются следующим образом:
struct _zend_execute_data {
const zend_op *opline;
zend_execute_data *call;
zval *return_value;
zend_function *func;
zval This; /* this + call_info + num_args */
zend_class_entry *called_scope;
zend_execute_data *prev_execute_data;
zend_array *symbol_table;
void **run_time_cache; /* cache op_array->run_time_cache */
zval *literals; /* cache op_array->literals */
};
Самое главное, что эта структура содержит opline, которая является выполняемой в данный момент инструкцией, и func, которая является выполняемой в данный момент функцией. Более того:
- return_value указатель на zval, в котором будет сохранено возвращаемое значение;
- This – это $this-объект, но также количество аргументов функции и пара флагов метаданных вызова в некоторых неиспользуемых пространствах zval;
- called_scope область видимости, на которую в PHP-коде ссылается static::;
- prev_execute_data указывает на предыдущий кадр стека, к которому вернётся выполнение после завершения этой функции;
- symbol_table обычно неиспользуемая таблица символов, применяемая в случае, если какой-то сумасшедший фактически использует variable-переменные или аналогичные функции;
- run_time_cache кеширует op_array->run_time_cache, чтобы избежать косвенной адресации при доступе к этой структуре (что будет рассмотрено ниже);
- literals кеширует таблицу литералов oп-массива по той же причине.
Вызовы функций
Я пропустил одно поле в структуре execute_data, а именно call, так как оно требует некоторого дополнительного объяснения того, как работают вызовы функций.
Во всех вызовах используется одна и та же последовательность инструкций. var_dump($a, $b) в глобальной области видимости компилируется в:
INIT_FCALL (2 args) "var_dump"
SEND_VAR $a
SEND_VAR $b
V0 = DO_ICALL # или просто DO_ICALL если retval не используется
Существует восемь различных типов инструкций INIT (в зависимости от того, какой это вызов). INIT_FCALL используется для вызовов функций (не являющихся методами класса), которые мы распознаём во время компиляции. Аналогично, есть десять различных опкодов SEND (в зависимости от типа аргументов и функции). Существует только небольшое количество из четырёх опкодов DO_CALL, где ICALL используется для вызовов внутренних функций.
Хотя конкретные инструкции могут отличаться, структура всегда одна и та же: INIT, SEND, DO. Основной проблемой, с которой должна справиться последовательность вызовов, являются вложенные вызовы функций, которые компилируют что-то вроде этого:
# var_dump(foo($a), bar($b))
INIT_FCALL (2 args) "var_dump"
INIT_FCALL (1 arg) "foo"
SEND_VAR $a
V0 = DO_UCALL
SEND_VAR V0
INIT_FCALL (1 arg) "bar"
SEND_VAR $b
V1 = DO_UCALL
SEND_VAR V1
V2 = DO_ICALL
Я отформатировал последовательность опкодов, чтобы визуализировать, какие инструкции соответствуют какому вызову.
Опкод INIT помещает в стек кадр вызова, который содержит достаточно места для всех переменных и аргументов функции, о которых мы знаем (если задействована распаковка аргументов, мы можем в итоге получить больше аргументов). Этот кадр вызова инициализируется вызываемой функцией, $this и called_scope (в данном случае оба последних будут NULL, поскольку мы вызываем функции).
Указатель на новый кадр сохраняется в execute_data->call, где execute_data является кадром вызывающей функции. В дальнейшем мы будем обозначать это как EX(call). Примечательно, что prev_execute_data нового кадра устанавливается в старое значение EX(call). Например, INIT_FCALL для вызова foo запишет в prev_execute_data кадр стека var_dump. Таким образом, prev_execute_data в этом случае формирует связанный список «незаконченных» вызовов, в то время как обычно он обеспечивает цепочку backtrace.
Затем опкоды SEND переходят к передаче аргументов в слоты переменных EX(call). На этом этапе все аргументы являются последовательными и могут перетекать из раздела для аргументов в другие CV или TMP. Это будет исправлено позже.
Наконец, DO_FCALL выполняет фактический вызов. То, что было EX(call), становится текущей функцией, а prev_execute_data меняется на вызывающую функцию. Кроме того, процедура вызова зависит от того, какая это функция. Внутренним функциям нужно лишь вызвать функцию-обработчик, в то время как пользовательские функции должны завершить инициализацию стекового кадра.
Эта инициализация включает в себя приведение в порядок стека аргументов. PHP позволяет передавать функции больше аргументов, чем она ожидает (и func_get_args полагается на это). Тем не менее только фактические аргументы имеют соответствующие CV. Любые другие аргументы будут записываться в память, зарезервированную для других CV и TMP. По существу, эти аргументы будут размещены после TMP, в результате чего аргументы будут разделены на два отдельных фрагмента.
Необходимо пояснить, что вызовы пользовательских функций не предполагают рекурсию на уровне виртуальной машины. Они подразумевают только переключение с одного execute_data на другой, но VM продолжает работать в линейном цикле. Рекурсивные вызовы виртуальной машины возникают только в том случае, если внутренние функции вызывают пользовательские колбэки (например, через array_map). По этой причине бесконечная рекурсия в PHP обычно прерывается из-за нехватки памяти или ошибки OOM, но можно вызвать переполнение стека рекурсией через колбэки или магические методы.
Передача аргументов
Для передачи аргументов PHP использует большое количество опкодов, различия между которыми могут сбивать с толку из-за их неудачного наименования.
SEND_VAL и SEND_VAR – простейшие варианты, которые производят передачу аргументов по значению, когда значение известно во время компиляции. SEND_VAL используется для CONST- и TMP-операндов, а SEND_VAR – для VAR и CV.
SEND_REF, напротив, используется для аргументов, о которых во время компиляции известно, что они являются ссылками. Поскольку только указатели могут быть переданы по ссылке, этот опкод принимает только VAR и CV.
SEND_VAL_EX и SEND_VAR_EX – варианты SEND_VAL/SEND_VAR для случаев, когда мы не можем статически определить, передаётся аргумент по значению или по ссылке. Эти опкоды проверяют тип аргумента, основываясь на arginfo, и ведут себя соответствующим образом. В большинстве случаев фактически используется не структура arginfo, а довольно компактный битовый вектор непосредственно в структуре функции.
И ещё есть SEND_VAR_NO_REF_EX. Не пытайтесь понять что-нибудь из его имени – это откровенная ложь. Этот опкод используется при передаче чего-то, что на самом деле не является переменной, но возвращает VAR как статически неизвестный аргумент. Два конкретных примера, в которых он используется, — передача результата вызова функции в качестве аргумента и передача результата присваивания.
Этот случай требует отдельного опкода по двум причинам: во-первых, он создаст знакомое сообщение «Только переменные должны быть переданы по ссылке», если вы попытаетесь передать что-то вроде присваивания по ссылке (если бы использовался SEND_VAR_EX, он бы молча позволил). Во-вторых, этот опкод имеет дело с тем случаем, когда вам может понадобиться передать результат вызова функции по ссылке, не возбуждая никаких исключений. Вариант этого опкода SEND_VAR_NO_REF (без _EX) является специализированным вариантом для случая, когда мы статически знаем, что ссылка ожидается, но не знаем, является ли аргумент ею.
Опкоды SEND_UNPACK и SEND_ARRAY имеют дело с распаковкой аргументов и вложенными вызовами call_user_func_array соответственно. Они оба извлекают элементы из массива и помещают их в стек аргументов и отличаются различными деталями (например, распаковка поддерживает Traversables, а call_user_func_array — нет). Если используется распаковка, может потребоваться увеличить кадр стека (так как реальное число аргументов функции неизвестно во время инициализации). В большинстве случаев это увеличение может произойти просто перемещением указателя вершины стека. Однако если будет пересечена граница страницы стека, новая страница должна быть выделена, и весь кадр вызова (включая уже помещённые в стек аргументы) должен быть скопирован на новую страницу (мы не сможем обрабатывать кадр вызова, пересекающий границу страницы).
Последний опкод — SEND_USER – используется для внутренних вызовов call_user_func и имеет дело с некоторыми её особенностями.
Хотя мы ещё не обсуждали различные режимы получения данных из переменных, самое время представить режим FUNC_ARG. Рассмотрим простой вызов типа func($a[0][1][2]), для которого мы не знаем во время компиляции, будет аргумент передан по значению или по ссылке. В этих случаях поведение будет сильно отличаться. Если производится передача по значению, и $a – пустой, это может создать кучу уведомлений «undefined index». Если осуществляется передача по ссылке, мы должны молча инициализировать вложенные массивы.
Режим получения данных FUNC_ARG динамически выбирает один из двух вариантов поведения (R или W), проверяя arginfo текущей EX(call)-функции. Для примера func($a[0][1][2]) последовательность опкодов может выглядеть примерно так:
INIT_FCALL_BY_NAME "func"
V0 = FETCH_DIM_FUNC_ARG (arg 1) $a, 0
V1 = FETCH_DIM_FUNC_ARG (arg 1) V0, 1
V2 = FETCH_DIM_FUNC_ARG (arg 1) V1, 2
SEND_VAR_EX V2
DO_FCALL
Режимы получения данных (fetch modes)
Виртуальная машина PHP имеет четыре класса опкодов для получения данных:
FETCH_* // $_GET, $$var FETCH_DIM_* // $arr[0] FETCH_OBJ_* // $obj->prop FETCH_STATIC_PROP_* // A::$prop
Они делают именно то, что можно было бы ожидать от них, с замечанием, что основной вариант FETCH_ * используется только для доступа к переменным переменных ($$var) и суперглобальным переменным: обычные обращения к переменным вместо этого происходят через более быстрый CV-механизм.
Эти опкоды получения данных представлены в шести вариантах:
_R _RW _W _IS _UNSET _FUNC_ARG
Мы уже узнали, что _FUNC_ARG выбирает между _R и _W в зависимости от того, как передаётся аргумент функции – по значению или по ссылке. Давайте попробуем создать некоторые ситуации, когда мы ожидаем появления разных вариантов FETCH_*:
// $arr[0];
V2 = FETCH_DIM_R $arr int(0)
FREE V2
// $arr[0] = $val;
ASSIGN_DIM $arr int(0)
OP_DATA $val
// $arr[0] += 1;
ASSIGN_ADD (dim) $arr int(0)
OP_DATA int(1)
// isset($arr[0]);
T5 = ISSET_ISEMPTY_DIM_OBJ (isset) $arr int(0)
FREE T5
// unset($arr[0]);
UNSET_DIM $arr int(0)
К сожалению, фактическое получение по индексу происходит только в случае FETCH_DIM_R. Всё остальное обрабатывается с помощью специальных опкодов. Обратите внимание, что ASSIGN_DIM и ASSIGN_ADD используют дополнительную OP_DATA, потому что им нужно больше двух входных операндов. Причина использования специальных опкодов, таких как ASSIGN_DIM, вместо чего-то вроде FETCH_DIM_W + ASSIGN, заключается (кроме производительности) в том, что эти операции могут быть перегружены (например, в случае ASSIGN_DIM с помощью объекта, реализующего ArrayAccess :: offsetSet ()). Чтобы на самом деле генерировать разные типы выборки, нам необходимо увеличить уровень вложенности:
// $arr[0][1];
V2 = FETCH_DIM_R $arr int(0)
V3 = FETCH_DIM_R V2 int(1)
FREE V3
// $arr[0][1] = $val;
V4 = FETCH_DIM_W $arr int(0)
ASSIGN_DIM V4 int(1)
OP_DATA $val
// $arr[0][1] += 1;
V6 = FETCH_DIM_RW $arr int(0)
ASSIGN_ADD (dim) V6 int(1)
OP_DATA int(1)
// isset($arr[0][1]);
V8 = FETCH_DIM_IS $arr int(0)
T9 = ISSET_ISEMPTY_DIM_OBJ (isset) V8 int(1)
FREE T9
// unset($arr[0][1]);
V10 = FETCH_DIM_UNSET $arr int(0)
UNSET_DIM V10 int(1)
Здесь мы видим, что в то время как внешний доступ использует специализированные опкоды, вложенные индексы будут обрабатываться с помощью FETCH с соответствующим fetch mode. Эти режимы существенно отличаются тем, генерируют ли они уведомление «Undefined offset», если индекс не существует, и получают ли они значение для записи:
Notice? | Write? | |
---|---|---|
R | yes | no |
W | no | yes |
RW | yes | yes |
IS | no | no |
UNSET | no | yes-ish |
Случай с UNSET немного странный, поскольку он будет извлекать только существующие смещения для записи и оставлять неопределённые без обработки. Обычная запись-выборка (write-fetch) инициализирует неопределённые смещения вместо него.
Запись данных и безопасность памяти
Write fetches возвращают VAR, которые могут содержать либо нормальный zval, либо INDIRECT-указатель на другой zval. Конечно, в первом случае любые изменения, применённые к zval, не будут видны, поскольку значение доступно только через временную переменную VM. Хотя PHP запрещает выражения типа [][0] = 42, нам всё равно нужно обрабатывать их для таких случаев, как call()[0] = 42. В зависимости от того, возвращается call() значение или ссылку, это выражение может или не может иметь наблюдаемый эффект.
Более типичный случай – когда fetch возвращает INDIRECT, который содержит указатель на изменяемую ячейку памяти (например, определённое местоположение в массиве данных хеш-таблицы). К сожалению, такие указатели являются хрупкими вещами и легко становятся недействительными: любая одновременная запись в массив может вызвать перераспределение памяти, оставляя «висячий» указатель. Таким образом, важно предотвратить выполнение кода пользователя между точкой, где создаётся значение INDIRECT, и где оно используется.
Рассмотрим следующий пример:
$arr[a()][b()] = c();
Который генерирует:
INIT_FCALL_BY_NAME (0 args) "a"
V1 = DO_FCALL_BY_NAME
INIT_FCALL_BY_NAME (0 args) "b"
V3 = DO_FCALL_BY_NAME
INIT_FCALL_BY_NAME (0 args) "c"
V5 = DO_FCALL_BY_NAME
V2 = FETCH_DIM_W $arr V1
ASSIGN_DIM V2 V3
OP_DATA V5
Примечательно, что эта последовательность сначала выполняет все побочные действия слева направо и только затем делает необходимую выборку-запись (мы называем здесь FETCH_DIM_W «отложенным opline»). Это гарантирует, что write-fetch и инструкция, использующая результат fetch, выполняются непосредственно друг за другом.
Рассмотрим другой пример:
$arr[0] =& $arr[1];
Здесь у нас есть небольшая проблема: обе стороны присваивания должны быть выбраны для записи. Однако если мы выберем $arr[0] для записи, а затем $arr[1] для записи, последнее может сделать недействительным первое. Эта проблема решается следующим образом:
V2 = FETCH_DIM_W $arr 1
V3 = MAKE_REF V2
V1 = FETCH_DIM_W $arr 0
ASSIGN_REF V1 V3
Здесь $arr[1] извлекается для записи первым, а затем превращается в ссылку, используя MAKE_REF. Результат MAKE_REF больше не является INDIRECT и не подлежит аннулированию, поэтому выборку из $arr[0] можно делать безопасно.
Обработка исключений
Исключения — это корень всех зол.
Исключение генерируется путем его записи в EG(exception), где EG ссылается на executor globals. Выбрасывание исключений из кода C не предполагает размотки стека; вместо этого прерывания будут распространяться вверх через возвращаемые коды сбоя или за счёт проверки EG(exception). Исключение обрабатывается только тогда, когда управление возвращается в код виртуальной машины.
Почти все инструкции VM могут прямо или косвенно привести к исключению при некоторых обстоятельствах. Например, уведомление «Неопределённая переменная» может привести к исключению, если используется пользовательский обработчик ошибок. Мы хотим избежать проверки EG(exception) после каждой инструкции VM. Для этого используем небольшой трюк:
Когда генерируется исключение, текущий opline текущего execute data заменяется фиктивным opline HANDLE_EXCEPTION (это не изменяет op array, а только перенаправляет указатель). Opline, в котором возникло исключение, резервируется в EG(opline_before_exception). Это означает, что, когда управление возвращается в основной цикл виртуальной машины, будет вызван опкод HANDLE_EXCEPTION. Существует небольшая проблема с этой схемой: она требует, чтобы: а) в opline, хранящемся в execute data, в действительности в настоящее время исполнялся opline (иначе opline_before_exception было бы неправильным); и б) виртуальная машина использовала opline из execute data для продолжения выполнения (иначе HANDLE_EXCEPTION не будет вызываться).
Хотя эти требования могут показаться тривиальными, это не так. Причина в том, что виртуальная машина может работать с другой переменной opline, которая не синхронизирована с opline, хранящейся в execute data. До появления PHP 7 это случалось только в редко используемых GOTO и SWITCH, а в PHP 7 это фактически режим работы по умолчанию: если компилятор поддерживает это, opline хранится в глобальном регистре.
Таким образом, перед выполнением любой операции, которая может бросить исключение, локальный opline должен быть записан обратно в execute data (операция SAVE_OPLINE). Аналогично после любой потенциально опасной операции локальный opline должен быть заполнен из execute data (обычно операцией CHECK_EXCEPTION).
Таким образом HANDLE_EXCEPTION вызывается после того, как было брошено исключение. Но что он делает? Прежде всего, он определяет, было ли исключение брошено внутри блока try. Для этого op array содержит массив try_catch_elements, который отслеживает смещения opline для блоков try, catch и finally:
typedef struct _zend_try_catch_element {
uint32_t try_op;
uint32_t catch_op; /* ketchup! */
uint32_t finally_op;
uint32_t finally_end;
} zend_try_catch_element;
Пока мы будем делать вид, что блоков finally не существует, поскольку они представляют собой отдельную проблему. Предполагая, что мы действительно находимся внутри блока try, виртуальной машине необходимо очистить все недоработанные операции, которые начались до выброса исключения, и не проскочить мимо конца блока try.
Это включает в себя освобождение кадров стека и связанных данных всех вызовов, а также освобождение всех живых временных переменных. В большинстве случаев временные данные недолговечны до такой степени, что использующая их инструкция следует непосредственно за порождающей их. Однако иногда время их жизни охватывает несколько потенциально бросающих исключение инструкций:
# (array)[] + throwing()
L0: T0 = CAST (array) []
L1: INIT_FCALL (0 args) "throwing"
L2: V1 = DO_FCALL
L3: T2 = ADD T0, V1
В этом случае переменная T0 активна во время инструкций L1 и L2 и как таковая должна быть уничтожена, если функция бросит исключение.
Один особый тип временных данных, имеющий склонность к длительному времени жизни, — переменные цикла. Например:
# foreach ($array as $value) throw $ex;
L0: V0 = FE_RESET_R $array, ->L4
L1: FE_FETCH_R V0, $value, ->L4
L2: THROW $ex
L3: JMP ->L1
L4: FE_FREE V0
Здесь переменная цикла V0 живет от L1 до L3 (обычно всегда охватывая все тело цикла). Диапазоны жизни хранятся в op array, используя следующую структуру:
typedef struct _zend_live_range {
uint32_t var; /* low bits are used for variable type (ZEND_LIVE_* macros) */
uint32_t start;
uint32_t end;
} zend_live_range;
Здесь var — это переменная, к которой применим диапазон, start — это начальное смещение opline (не включая инструкцию генерации), а end — конец смещения opline (включая инструкцию использования). Конечно, диапазоны жизни сохраняются только в том случае, если временные данные не используется немедленно.
Младшие биты var используются для хранения типа переменной, который может быть одним из следующих:
- ZEND_LIVE_TMPVAR: это «нормальная» переменная. Она содержит обычное значение zval. Освобождение этой переменной ведёт себя как опкод FREE;
- ZEND_LIVE_LOOP: это переменная цикла foreach, которая содержит больше простого zval. Она соответствует опкоду FE_FREE;
- ZEND_LIVE_SILENCE: используется для реализации оператора подавления ошибок. Старый уровень уведомления об ошибках сохраняется, а позднее восстанавливается. Если выбрасывается исключение, мы, очевидно, также хотим его восстановить. Соответствует END_SILENCE;
- ZEND_LIVE_ROPE: используется для конкатенации строк, и в этом случае временным является массив указателей фиксированного размера zend_string*, живущих в стеке. В этом случае все строки, которые уже были заполнены, должны быть освобождены. Соответствует примерно END_ROPE.
Трудный вопрос, который следует рассмотреть в этом контексте, заключается в том, должны ли временные данные освобождаться, если исключение бросает генерирующая либо использующая их инструкция. Рассмотрим простой код:
T2 = ADD T0, T1
ASSIGN $v, T2
Если исключение выбрасывает ADD, будет ли T2 автоматически освобождаться, или это ADD-инструкция ответственна за это? Аналогично, если ASSIGN бросает исключение, должна ли T2 быть освобождена автоматически, или ASSIGN должна позаботиться об этом сама? В последнем случае ответ очевиден: инструкция всегда ответственна за освобождение своих операндов, даже если выдаётся исключение.
Случай с операндом результата сложнее, потому что ответ здесь изменился между PHP 7.1 и 7.2. В PHP 7.1 инструкция была ответственна за освобождение результата в случае исключения, а в PHP 7.2 он автоматически освобождается (а инструкция отвечает за то, чтобы результат всегда заполнялся). Мотивация для этого изменения — способ, которым реализованы многие основные инструкции (такие как ADD). Их обычная структура примерно следующая:
- Чтение входных операндов.
- Выполнение операции, запись результата.
- Освобождение входных операндов (при необходимости).
Это проблематично, потому что PHP находится в очень неудачном положении, поддерживая не только исключения и деструкторы, но и бросание исключений в деструкторах (это то место, где разработчики компилятора в ужасе вскрикивают). Таким образом, шаг 3 может бросить исключение, после того как результат уже заполнен. Чтобы избежать утечек памяти в этом случае, ответственность за освобождение операнда результата была перенесена с инструкции на механизм обработки исключений.
Как только мы выполнили эти операции очистки, мы можем продолжить выполнение блока catch. Если catch нет (и нет finally), мы разматываем стек, то есть уничтожаем текущий кадр стека и предоставляем родительскому кадру возможность для обработки исключения.
Чтобы вы получили полное представление о том, насколько безобразна вся обработка исключений, я расскажу о ещё одном моменте, связанном с бросающим исключение деструктором. Это неприменимо на практике, но справедливости ради нам всё равно нужно с ним разобраться.
Рассмотрим следующий код:
foreach (new Dtor as $value) {
try {
echo "Return";
return;
} catch (Exception $e) {
echo "Catch";
}
}
Теперь представьте, что Dtor — это класс Traversable с бросающим исключение деструктором. Этот код приведёт к следующей последовательности опкодов (с отступом тела цикла для удобочитаемости):
L0: V0 = NEW 'Dtor', ->L2
L1: DO_FCALL
L2: V2 = FE_RESET_R V0, ->L11
L3: FE_FETCH_R V2, $value
L4: ECHO 'Return'
L5: FE_FREE (free on return) V2 # <- return
L6: RETURN null # <- return
L7: JMP ->L10
L8: CATCH 'Exception' $e
L9: ECHO 'Catch'
L10: JMP ->L3
L11: FE_FREE V2 # <- the duplicated instr
Важно отметить, что return скомпилирован в FE_FREE-переменной цикла и RETURN. Что происходит, если FE_FREE бросает исключение? Ведь у Dtor бросающий исключение деструктор. Обычно мы говорим, что эта инструкция находится внутри блока try, поэтому мы должны вызывать catch. Однако на этом этапе переменная цикла уже уничтожена! Catch сбрасывает исключение – и мы попытаемся продолжить итерацию уже мёртвой переменной цикла.
Причина этой проблемы в том, что, хотя выбрасывание исключения в FE_FREE находится внутри блока try, он является копией FE_FREE в L11. Логически это то, где произошло исключение на самом деле. Вот почему FE_FREE, генерируемый прерыванием, аннотируется как FREE_ON_RETURN. Это предписывает механизму обработки исключений перемещать источник исключения в исходную инструкцию освобождения. Таким образом, приведённый выше код не будет запускать блок catch – он будет генерировать неперехваченное исключение.
Обработка finally
История PHP с блоками finally несколько неблагополучна. Впервые они были введены в PHP 5.5, но это была по-настоящему глючная реализация. PHP 5.6, 7.0 и 7.1 поставлялись со значительными переделками в этой области. Каждая версия исправляла целый ряд ошибок, но не могла достичь полностью правильной реализации. И вот, похоже, PHP 7.1 это наконец-то удалось (будем надеяться).
Во время написания этого раздела я с удивлением обнаружил, что с точки зрения текущей реализации обработка finally на самом деле не так уж и сложна. Действительно, во многих отношениях реализация стала проще, а не сложнее. Это показывает, как недостаточное понимание проблемы может привести к слишком сложной и неповоротливой реализации (хотя, справедливости ради, часть сложности реализации PHP 5 проистекала непосредственно из отсутствия AST).
Блоки finally выполняются всякий раз, когда управление выходит из блока try, либо обычным способом (например, с помощью return), либо ненормально (бросая исключения). Есть несколько интересных случаев для рассмотрения, которые я проиллюстрирую, прежде чем приступать к реализации. Рассмотрим:
try {
throw new Exception();
} finally {
return 42;
}
Что происходит? Finally выигрывает, и функция возвращает 42.
Рассмотрим:
try {
return 24;
} finally {
return 42;
}
И снова finally выигрывает, и функция возвращает 42. Finally всегда выигрывает.
PHP запрещает переход из блоков finally. Например, запрещено следующее:
foreach ($array as $value) {
try {
return 42;
} finally {
continue;
}
}
Continue здесь будет генерировать ошибку компиляции. Важно понимать, что это ограничение – чисто «косметическое», и его легко можно обойти, используя хорошо известную схему делегирования управления catch:
foreach ($array as $value) {
try {
try {
return 42;
} finally {
throw new JumpException;
}
} catch (JumpException $e) {
continue;
}
}
Единственное реальное ограничение, которое существует, состоит в том, что невозможно перейти в блок finally. Например, запрещён переход goto извне блока finally на метку внутри блока finally.
С предварительными отступлениями, мы можем посмотреть, как finally работает. Реализация использует два опкода: FAST_CALL и FAST_RET. Грубо говоря, FAST_CALL предназначен для перехода в блок finally, а FAST_RET — для выхода из него. Рассмотрим простейший случай:
try {
echo "try";
} finally {
echo "finally";
}
echo "finished";
Этот код компилируется в следующую последовательность опкодов:
L0: ECHO string("try")
L1: T0 = FAST_CALL ->L3
L2: JMP ->L5
L3: ECHO string("finally")
L4: FAST_RET T0
L5: ECHO string("finished")
L6: RETURN int(1)
FAST_CALL сохраняет своё собственное местоположение в T0 и переходит в finally-блок в L3. Когда достигнут FAST_RET, он возвращается к месту, хранящемуся в T0. В данном случае это будет L2, где происходит прыжок через блок finally. Это базовый случай, когда нет специального потока управления (возвратов или исключений).
Рассмотрим теперь случай с исключением:
try {
throw new Exception("try");
} catch (Exception $e) {
throw new Exception("catch");
} finally {
throw new Exception("finally");
}
При обработке исключения мы должны рассмотреть позицию брошенного исключения относительно ближайшего окружающего блока try/catch/finally:
- Выбрасывание из try с соответствующим catch: заполнение $e и переход в catch.
- Выбрасывание из catch или try без соответствующего catch, если есть finally-блок: переход в блок finally и создание с помощью FAST_CALL бэкапа исключения во временной переменной (вместо того чтобы хранить там обратный адрес).
- Выбрасывание из finally: если есть бэкап исключения, созданный ранее FAST_CALL, привязка его в качестве предыдущего к только что брошенному. Продолжение всплытия исключения к следующему try/catch/finally.
- В остальных случаях: продолжение всплытия исключения к следующему try/catch/finally.
В этом примере мы рассматриваем первые три шага: сначала бросаем исключение в try, инициируя переход в catch. Catch также бросает исключение, запуская переход в блок finally с бэкапом исключения в FAST_CALL. Блок finally также бросает исключение, так что исключение «finally» будет вызывать исключение «catch», установленное как его предыдущее исключение.
Немного отличается от предыдущего примера этот код:
try {
try {
throw new Exception("try");
} finally {}
} catch (Exception $e) {
try {
throw new Exception("catch");
} finally {}
} finally {
try {
throw new Exception("finally");
} finally {}
}
Вход во все внутренние блоки finally происходит за счёт бросания исключений, а выход – как обычно (через FAST_RET). В этом случае описанная выше процедура обработки исключений возобновляется начиная с родительского блока try/catch/finally. Этот родительский try/catch хранится в опкоде FAST_RET (здесь – «try-catch(0)»).
Это по существу охватывает взаимодействие finally и исключения. Но как насчёт return в finally?
try {
throw new Exception("try");
} finally {
return 42;
}
Соответствующей последовательностью опкодов является такая:
L4: T0 = FAST_CALL ->L6
L5: JMP ->L9
L6: DISCARD_EXCEPTION T0
L7: RETURN 42
L8: FAST_RET T0
Дополнительный опкод DISCARD_EXCEPTION отвечает за отказ от дальнейшей обработки исключения, инициированного в блоке try (помните: возврат в finally выигрывает). А как насчёт return в try?
try {
$a = 42;
return $a;
} finally {
++$a;
}
Возвращаемое значение здесь равно 42, а не 43, так как оно определяется строкой return $a, а любая дальнейшая модификация $a не должна иметь значения. Результатом этого кода будет:
L0: ASSIGN $a, 42
L1: T3 = QM_ASSIGN $a
L2: T1 = FAST_CALL ->L6, T3
L3: RETURN T3
L4: T1 = FAST_CALL ->L6 # недоступно
L5: JMP ->L8 # недоступно
L6: PRE_INC $a
L7: FAST_RET T1
L8: RETURN null
Два из опкодов недоступны, так как они встречаются непосредственно после return. Они будут удалены во время оптимизации, но я показываю неоптимизированные опкоды. Здесь есть два интересных момента. Во-первых, $a копируется в T3, используя QM_ASSIGN (которая является в основном инструкцией «Копировать во временную переменную»). Это и предотвращает воздействие последующей модификации $a на возвращаемое значение. Во-вторых, T3 также передаётся в FAST_CALL, который будет бэкапить значение в T1. Если позднее return из блока try будет отброшен (например, потому что в finally появится throw или return), этот механизм будет использоваться для освобождения неиспользованного возвращаемого значения.
Все эти механизмы сами по себе просты, но необходимо соблюдать осторожность при их совместном использовании. Рассмотрим следующий пример, где Dtor снова является классом Traversable с бросающим исключение деструктором:
try {
foreach (new Dtor as $v) {
try {
return 1;
} finally {
return 2;
}
}
} finally {
echo "finally";
}
Этот код генерирует следующие опкоды:
L0: V2 = NEW (0 args) "Dtor"
L1: DO_FCALL
L2: V4 = FE_RESET_R V2 ->L16
L3: FE_FETCH_R V4 $v ->L16
L4: T5 = FAST_CALL ->L10 # внутрений try
L5: FE_FREE (free on return) V4
L6: T1 = FAST_CALL ->L19
L7: RETURN 1
L8: T5 = FAST_CALL ->L10 # недоступно
L9: JMP ->L15
L10: DISCARD_EXCEPTION T5 # внутрений finally
L11: FE_FREE (free on return) V4
L12: T1 = FAST_CALL ->L19
L13: RETURN 2
L14: FAST_RET T5 try-catch(0)
L15: JMP ->L3
L16: FE_FREE V4
L17: T1 = FAST_CALL ->L19
L18: JMP ->L21
L19: ECHO "finally" # внешний finally
L20: FAST_RET T1
Последовательность для первого return (из внутреннего try) — FAST_CALL L10, FE_FREE V4, FAST_CALL L19, RETURN. Это сначала вызовет внутренний блок finally, затем освободит переменную цикла foreach, затем вызовет внешний блок finally и, наконец, вернёт значение. Последовательность для второго return (из внутреннего finally) — DISCARD_EXCEPTION T5, FE_FREE V4, FAST_CALL L19. Сначала произойдёт отмена исключения (или здесь – возвращаемого значения) внутреннего блока try, затем освободится переменная цикла foreach и, наконец, вызовется внешний блок finally. Обратите внимание, что в обоих случаях порядок этих инструкций является обратным порядку соответствующих блоков в исходном коде.
Генераторы
Функции генераторы могут быть приостановлены и возобновлены и, следовательно, требуют специального управления стеком виртуальной машины. Вот простой генератор:
function gen($x) {
foo(yield $x);
}
Из этого получаются следующие опкоды:
$x = RECV 1
GENERATOR_CREATE
INIT_FCALL_BY_NAME (1 args) string("foo")
V1 = YIELD $x
SEND_VAR_NO_REF_EX V1 1
DO_FCALL_BY_NAME
GENERATOR_RETURN null
До достижения GENERATOR_CREATE всё выполняется как обычная функция в обычном стеке виртуальной машины. GENERATOR_CREATE создаёт объект Generator, а также распределённую по куче структуру execute_data (включая слоты для переменных и аргументов, как обычно), в которую копируются данные execute_data из стека VM.
Когда выполнение генератора возобновится, executor будет использовать execute_data в куче, но продолжит использовать основной стек виртуальных машин для размещения кадров вызова. Очевидная проблема заключается в том, что, как показывает предыдущий пример, генератор может прерваться во время выполнения вызова. Здесь YIELD выполняется в точке, где кадр вызова для foo() уже был помещён в стек VM.
Этот относительно необычный случай обрабатывается путём копирования активных кадров вызова в структуру генератора при выполнении yield и их восстановления при возобновлении выполнения генератора.
Такая конструкция используется начиная с PHP 7.1. Раньше у каждого генератора была своя собственная VM-страница 4 KB, которая подгружалась в executor при восстановлении генератора. Это позволяло избежать необходимость копирования кадров вызова, но увеличивало расход памяти.
Смарт-ветви
Очень часто после инструкций сравнения сразу следуют условные переходы. Например:
L0: T2 = IS_EQUAL $a, $b
L1: JMPZ T2 ->L3
L2: ECHO "equal"
Поскольку этот шаблон очень распространён, все опкоды сравнения (такие как IS_EQUAL) реализуют механизм смарт-ветвления (smart branch): они проверяют, является ли следующая инструкция инструкцией JMPZ или JMPNZ, и если да, то сами выполняют операцию перехода.
Этот механизм проверяет только, является ли следующая инструкция JMPZ/JMPNZ, но не проверяет, является ли операнд этой инструкции именно результатом сравнения. Это требует особой осторожности в тех случаях, когда сравнение и последующий переход не связаны. Например, код ($a == $b) + ($d? $e: $f) генерирует:
L0: T5 = IS_EQUAL $a, $b
L1: NOP
L2: JMPZ $d ->L5
L3: T6 = QM_ASSIGN $e
L4: JMP ->L6
L5: T6 = QM_ASSIGN $f
L6: T7 = ADD T5 T6
L7: FREE T7
Обратите внимание, что между IS_EQUAL и JMPZ вставлен NOP. Если бы этого NOP не было, для перехода использовался бы результат сравнения IS_EQUAL, а не операнд JMPZ.
Рантайм-кеш
Поскольку массивы опкодов открыты для совместного доступа нескольким процессам (без блокировок), они являются строго неизменными. Однако рантайм-значения могут быть кешированы в отдельном рантайм-кеше, который обычно представляет собой массив указателей. Литералы могут иметь связанную запись рантайм-кеша (или более одной).
Записи рантайм-кеша бывают двух типов. Первый — это обычные записи кеша (например, используемые в INIT_FCALL). После того, как INIT_FCALL единожды выполнит поиск вызванной функции (по её имени), указатель функции будет кеширован в соответствующем слоте рантайм-кеша.
Второй тип — это полиморфные записи кеша, которые представляют собой два последовательных кеш-слота, где первый сохраняет начало класса, а второй — смещение члена класса. Они используются для операций типа FETCH_OBJ_R, где смещение свойства в таблице свойств определённого класса кешируется. Если следующее обращение произойдёт к свойству того же самого класса (что весьма вероятно), будет использоваться кешированное значение. В противном случае выполняется более дорогостоящая операция поиска, и результат кешируется для нового класса.
Прерывания виртуальной машины
До PHP 7.0 использовались тайм-ауты выполнения, обрабатываемые посредством перехода в шатдаун-последовательность непосредственно из обработчика сигнала. Как вы можете представить, это вызывало всевозможные неприятности. Начиная с PHP 7.0 обработка тайм-аутов задерживается, пока управление не вернётся виртуальной машине. Если управление не возвращается в течение определённого (заданного) периода, то процесс завершается. Начиная с PHP 7.1 pcntl-обработчики сигналов используют тот же механизм, что и тайм-ауты выполнения.
Когда сигнал находится в состоянии ожидания, устанавливается флаг прерывания, который проверяется виртуальной машиной в определённых точках. Проверка выполняется не при каждой новой инструкции, а только при переходах и вызовах. Таким образом, прерывание будет обработано не сразу по возвращении управления виртуальной машине, а в конце текущей секции линейного потока управления.
Специализация
Если вы посмотрите на файл определений виртуальной машины, вы обнаружите, что обработчики опкодов определены следующим образом:
ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMPVAR|CV, CONST|TMPVAR|CV)
Здесь 1 — номер опкода, ZEND_ADD — его имя, а два других аргумента указывают, какие типы операндов принимает инструкция. Сгенерированный код виртуальной машины (сгенерированный с помощью zend_vm_gen.php) будет содержать специализированные обработчики для каждой из возможных комбинаций типов операндов. Они будут иметь имена, такие как ZEND_ADD_SPEC_CONST_CONST_HANDLER.
Специализированные обработчики генерируются путём замены определённых макросов в теле обработчика. Очевидными являются OP1_TYPE и OP2_TYPE, но такие операции, как GET_OP1_ZVAL_PTR () и FREE_OP1 (), также являются специализированными.
Обработчик для ADD указал, что он принимает операнды CONST|TMPVAR|CV. Здесь TMPVAR означает, что код операции принимает как TMP, так и VAR, но требует, чтобы они не специализировались отдельно. Помните, что для большинства целей единственное различие между TMP и VAR состоит в том, что последние могут содержать ссылки. Для такого кода операции, как ADD (где ссылки находятся на медленном пути в любом случае), наличие отдельной специализации для этого не имеет смысла. В некоторых других опкодах, делающих это различие, в списке операндов будет использоваться TMP|VAR.
Помимо специализации по операнду, обработчики также могут специализироваться по другим факторам, например, по использованию возвращаемого значения.
ASSIGN_DIM специализируется на основе типа операнда следующего OP_DATA-опкода:
ZEND_VM_HANDLER(147, ZEND_ASSIGN_DIM,
VAR|CV, CONST|TMPVAR|UNUSED|NEXT|CV, SPEC(OP_DATA=CONST|TMP|VAR|CV))
На основе этой сигнатуры будут генерироваться 2*4*4=32 различных варианта ASSIGN_DIM.
Спецификация второго операнда также содержит запись для NEXT. Это не связано со специализацией, вместо этого он определяет, что означает значение UNUSED-операнда в этом контексте: это означает, что это операции добавления ($arr[]).
Другой пример:
ZEND_VM_HANDLER(23, ZEND_ASSIGN_ADD,
VAR|UNUSED|THIS|CV, CONST|TMPVAR|UNUSED|NEXT|CV, DIM_OBJ, SPEC(DIM_OBJ))
Здесь первый UNUSED-операнд подразумевает доступ по $this. Это общее соглашение для опкодов, связанных с объектами (например, FETCH_OBJ_R UNUSED, 'prop' соответствует $this->prop). Второй UNUSED-операнд снова подразумевает операцию добавления. Третий аргумент здесь определяет значение операнда extended_value: он содержит флаг, который различает $a += 1, $a[$b] += 1 и $a->b += 1. И, наконец, SPEC(DIM_OBJ) указывает, что для каждого из них должен быть создан специализированный обработчик. В этом случае число генерируемых обработчиков является нетривиальным, поскольку генератор ВМ знает, что некоторые комбинации невозможны. Например, UNUSED op1 применим только для случая OBJ и т. д.
Наконец, генератор виртуальной машины поддерживает дополнительный, более сложный механизм специализации. В конце файла определений вы найдёте несколько обработчиков этой формы:
ZEND_VM_TYPE_SPEC_HANDLER(
ZEND_ADD,
(res_info == MAY_BE_LONG && op1_info == MAY_BE_LONG && op2_info == MAY_BE_LONG),
ZEND_ADD_LONG_NO_OVERFLOW,
CONST|TMPVARCV, CONST|TMPVARCV, SPEC(NO_CONST_CONST,COMMUTATIVE)
)
Эти обработчики специализируются не только на типе операнда, но также на основе возможных типов, которые может принимать операнд во время выполнения. Механизм, посредством которого определяются возможные типы операндов, является частью инфраструктуры оптимизации кеширования и совершенно выходит за рамки данной статьи. Однако, предполагая, что такая информация доступна, должно быть ясно, что это обработчик для добавления формы int + int -> int. Кроме того, аннотация SPEC сообщает специализатору (компонент specializer), что варианты для двух константных операндов не должны генерироваться и что операция является коммутативной, так что, если у нас уже есть специализация CONST + TMPVARCV, нам не нужно генерировать TMPVARCV + CONST.
Разделение на быстрый и медленный пути
Многие обработчики опкодов реализуются с использованием разделения быстрый путь/медленный путь, где сначала обрабатывается несколько распространённых случаев, а затем происходит возврат к общей реализации.
Пришло время взглянуть на какой-нибудь реальный код, поэтому я просто вставлю здесь полную реализацию SL (сдвиг влево):
ZEND_VM_HANDLER(6, ZEND_SL, CONST|TMPVAR|CV, CONST|TMPVAR|CV)
{
USE_OPLINE
zend_free_op free_op1, free_op2;
zval *op1, *op2;
op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)
&& EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)
&& EXPECTED((zend_ulong)Z_LVAL_P(op2) < SIZEOF_ZEND_LONG * 8)) {
ZVAL_LONG(EX_VAR(opline->result.var), Z_LVAL_P(op1) << Z_LVAL_P(op2));
ZEND_VM_NEXT_OPCODE();
}
SAVE_OPLINE();
if (OP1_TYPE == IS_CV && UNEXPECTED(Z_TYPE_INFO_P(op1) == IS_UNDEF)) {
op1 = GET_OP1_UNDEF_CV(op1, BP_VAR_R);
}
if (OP2_TYPE == IS_CV && UNEXPECTED(Z_TYPE_INFO_P(op2) == IS_UNDEF)) {
op2 = GET_OP2_UNDEF_CV(op2, BP_VAR_R);
}
shift_left_function(EX_VAR(opline->result.var), op1, op2);
FREE_OP1();
FREE_OP2();
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}
Реализация начинается с выборки операндов с использованием GET_OPn_ZVAL_PTR_UNDEF в режиме BP_VAR_R. Здесь часть UNDEF означает, что в случае с CV не выполняется проверка на неопределённость данных, вместо этого вы просто возвращаете значение UNDEF как есть. Как только у нас есть операнды, мы проверяем, что оба они являются целыми числами, и ширина сдвига находится в диапазоне. В этом случае результат может быть непосредственно вычислен, и мы переходим к следующему опкоду. Примечательно, что проверку типа здесь не волнует, являются ли операнды UNDEF, поэтому использование GET_OPn_ZVAL_PTR_UNDEF является оправданным.
Если операнды не удовлетворяют быстрому пути, мы возвращаемся к общей реализации, которая начинается с SAVE_OPLINE (). Это наш сигнал для «потенциально бросающих исключение операций». Прежде чем идти дальше, обрабатывается случай неопределённых переменных. GET_OPn_UNDEF_CV в этом случае будет выдавать уведомление о неопределённой переменной и возвращать значение NULL.
Затем вызывается стандартная функция shift_left_function, и её результат записывается в EX_VAR(opline->result.var). Наконец, входные операнды освобождаются (если необходимо), и мы переходим к следующему опкоду с проверкой исключения (что означает, что opline перезагружается перед тем, как двигаться дальше).
Таким образом, быстрый путь здесь позволяет избежать двух проверок на неопределённые переменные, вызова стандартной функции, освобождения операндов, а также сохранения и перезагрузки opline для обработки исключений. Аналогичным образом вычисляется большинство опкодов, чувствительных к производительности.
Макросы виртуальной машины
Как видно из предыдущего кода, реализация виртуальной машины позволяет свободно использовать макросы. Некоторые из них являются обычными макросами языка C, а другие обрабатываются во время генерации виртуальной машины. В частности, существует несколько макросов для выборки и освобождения операндов инструкций:
OPn_TYPE OP_DATA_TYPE GET_OPn_ZVAL_PTR(BP_VAR_*) GET_OPn_ZVAL_PTR_DEREF(BP_VAR_*) GET_OPn_ZVAL_PTR_UNDEF(BP_VAR_*) GET_OPn_ZVAL_PTR_PTR(BP_VAR_*) GET_OPn_ZVAL_PTR_PTR_UNDEF(BP_VAR_*) GET_OPn_OBJ_ZVAL_PTR(BP_VAR_*) GET_OPn_OBJ_ZVAL_PTR_UNDEF(BP_VAR_*) GET_OPn_OBJ_ZVAL_PTR_DEREF(BP_VAR_*) GET_OPn_OBJ_ZVAL_PTR_PTR(BP_VAR_*) GET_OPn_OBJ_ZVAL_PTR_PTR_UNDEF(BP_VAR_*) GET_OP_DATA_ZVAL_PTR() GET_OP_DATA_ZVAL_PTR_DEREF() FREE_OPn() FREE_OPn_IF_VAR() FREE_OPn_VAR_PTR() FREE_UNFETCHED_OPn() FREE_OP_DATA() FREE_UNFETCHED_OP_DATA()
Как видно, здесь есть несколько вариаций. Аргументы BP_VAR_* определяют режим получения данных и поддерживают те же режимы, что и инструкции FETCH_* (за исключением FUNC_ARG).
GET_OPn_ZVAL_PTR() — это основная инструкция получения операнда. Она выдаст уведомление о неопределённом CV и не будет разыменовывать операнд. GET_OPn_ZVAL_PTR_UNDEF(), как мы уже узнали, является вариантом, который не проверяет неопределённые CV. GET_OPn_ZVAL_PTR_DEREF() включает DEREF для zval. Это часть специализированной операции GET, так как разыменование необходимо только для CV и VAR, но не для CONST и TMP. Поскольку этот макрос должен различать TMP и VAR, он может использоваться только с TMP|VAR-специализацией (но не TMPVAR).
Варианты GET_OPn_OBJ_ZVAL_PTR*() дополнительно обрабатывают случай с UNUSED-операндом. Как упоминалось выше, по соглашению $this доступ использует UNUSED-операнд, поэтому макросы GET_OPn_OBJ_ZVAL_PTR*() возвратят ссылку на EX(This) для UNUSED.
Наконец, есть несколько вариантов PTR_PTR. Именование здесь является пережитком времён PHP 5, где фактически использовались двойные INDIRECT-указатели zval. Эти макросы используются в операциях записи и как таковые поддерживают только типы CV и VAR (все остальное возвращает NULL). Они отличаются от обычных выборок PTR тем, что они «де-INDIRECT-уют» операнды VAR.
Макросы FREE_OP*() затем используются для освобождения выбранных операндов. Для работы им требуется определение переменной zend_free_op free_opN, в которую операция GET сохраняет это значение для освобождения. Базовая операция FREE_OPn() освободит TMP и VAR, но не освободит CV и CONST. FREE_OPn_IF_VAR() делает именно то, что он говорит: освобождает операнд, только если он является VAR.
Вариант FREE_OP*_VAR_PTR() используется в сочетании с выборками PTR_PTR. Он будет освобождать только операнды VAR и только если они не INDIRECT.
Варианты FREE_UNFETCHED_OP*() используются в случаях, когда операнд должен быть освобождён до того, как он был получен с помощью GET. Обычно это происходит, если перед получением операнда выбрасывается исключение.
Помимо этих специализированных макросов, существует также немало более стандартных макросов. ВМ определяет три макроса, которые управляют тем, что происходит после запуска обработчика опкода:
ZEND_VM_CONTINUE() ZEND_VM_ENTER() ZEND_VM_LEAVE() ZEND_VM_RETURN()
CONTINUE продолжит выполнение опкодов как обычно, в то время как ENTER и LEAVE используются для входа/выхода из вложенного вызова функции. Специфика их работы зависит от того, как именно компилируется виртуальная машина (например, используются ли глобальные регистры, и если да, то какие). В широком смысле, они будут синхронизировать некоторое состояние из глобальных переменных, прежде чем продолжить. RETURN используется для фактического выхода из основного цикла VM.
ZEND_VM_CONTINUE () ожидает, что opline будет обновлено заранее. Конечно, есть больше макросов, связанных с этим:
Continue? | Check exception? | Check interrupt? | |
---|---|---|---|
ZEND_VM_NEXT_OPCODE() | yes | no | no |
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION() | yes | yes | no |
ZEND_VM_SET_NEXT_OPCODE(op) | no | no | no |
ZEND_VM_SET_OPCODE(op) | no | no | yes |
ZEND_VM_SET_RELATIVE_OPCODE(op, offset) | no | no | yes |
ZEND_VM_JMP(op) | yes | yes | yes |
В таблице показано, содержит ли макрос неявный ZEND_VM_CONTINUE(), будет ли он проверять наличие исключений и прерывания VM.
Рядом с ними есть также SAVE_OPLINE(), LOAD_OPLINE() и HANDLE_EXCEPTION(). Как уже упоминалось в разделе об обработке исключений, SAVE_OPLINE() используется перед первой потенциально бросающей исключение операцией в обработчике опкода. При необходимости он записывает обратно opline, используемый VM (может находиться в глобальном регистре), в execute data. LOAD_OPLINE() — это обратная операция, но в настоящее время она мало используется, потому что фактически была добавлена в ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION() и ZEND_VM_JMP(). HANDLE_EXCEPTION() используется для возврата из обработчика опкода после того, как вы уже знаете, что было создано исключение. Он выполняет комбинацию LOAD_OPLINE и CONTINUE.
Конечно, есть больше макросов (макросов всегда больше...), но эта статья должна охватить наиболее важные из них.
Автор: Badoo