[Перевод] Внутреннее представление значений в PHP 7 (часть 2)

В первой части мы рассматривали высокоуровневые различия во внутреннем представлении значений между PHP 5 и PHP 7. Как вы помните, главное отличие заключается в том, что zval больше не выделяются отдельно и не хранят в себе refcount. Простые значения, вроде целочисленных или с плавающей точкой, могут храниться прямо в zval, в то время как сложные значения представляются с помощью указателя на отдельную структуру.
Все эти дополнительные структуры используют стандартный заголовок, определяемый с помощью zend_refcounted:

struct _zend_refcounted {
    uint32_t refcount;
    union {
        struct {
            ZEND_ENDIAN_LOHI_3(
                zend_uchar    type,
                zend_uchar    flags,
                uint16_t      gc_info)
        } v;
        uint32_t type_info;
    } u;
};


Этот заголовок теперь содержит refcount, тип данных, информацию для сборщика мусора gc_info, а также ячейку для зависящего от типа flags. Далее мы рассмотрим индивидуальные сложные типы и сравним с их реализацией в PHP 5. В частности, речь пойдёт о ссылках, которые уже обсуждались в первой части статьи. Ресурсов мы касаться не будем, поскольку я не считаю их достаточно интересными для рассмотрения здесь.
В PHP 7 строки представляются с помощью типа zend_string:

struct _zend_string {
    zend_refcounted   gc;
    zend_ulong        h;        /* hash value */
    size_t            len;
    char              val[1];
};


Помимо содержащегося в заголовке refcounted, здесь также используется кэш хэша h, длина len и значение val. Кэш хэша используется для того, чтобы не пересчитывать хэш строки при каждом обращении к HashTable. При первом использовании он инициализируется как ненулевой хэш.

Если вы не слишком хорошо знакомы с разнообразными хаками в языке С, то определение val может показаться странным: он объявляется как массив символов с одним-единственным элементом. Но мы же наверняка захотим хранить строки длиной больше, чем один символ. Здесь используется метод под названием «структурный хак» (struct hack): массив хоть и объявляется с одним элементом, но при создании zend_string мы определим возможность хранения более длинной строки. Кроме того, можно будет получить доступ к более длинным строкам с помощью val.

Технически это неявная особенность, ведь мы же читаем и записываем односимвольный массив. Однако компиляторы С понимают, что к чему, и успешно обрабатывают код. С99 поддерживает эту особенность в виде «членов динамического массива», однако, спасибо нашим друзьям из Microsoft, C99 не могут использовать разработчики, которым нужна кросс-платформенная совместимость.

Новая реализация строковой переменной имеет ряд преимуществ перед обычными строками в языке С. Во-первых, в неё теперь интегрирована длина, которая больше не «болтается» где-то поблизости. Во-вторых, в заголовке используется подсчёт ссылок, поэтому стало возможным использовать строки в разных местах без применения zval. Это особенно важно для расшаривания ключей хэш-таблицы.

Но есть и большая ложка дёгтя. Получить из zend_string строку языка С легко (с помощью str→val), а вот из С-строки напрямую получить zend_string нельзя. Для этого придётся скопировать значение строки в заново созданный zend_string. Особенно досадно, когда дело доходит до работы с текстовыми строками (literal string), то есть постоянными строками (constant string), встречающимися в исходном С-коде.

Строка может иметь различные флаги, хранящиеся в соответствующем поле GC:

#define IS_STR_PERSISTENT           (1<<0) /* allocated using malloc */
#define IS_STR_INTERNED             (1<<1) /* interned string */
#define IS_STR_PERMANENT            (1<<2) /* interned string surviving request boundary */


Персистентные строковые используют обычный системный распределитель вместо менеджера памяти Zend (ZMM), и потому могут существовать дольше одного запроса. Если задать используемый распределитель в качестве флага, то можно прозрачно использовать в zval персистентные строки. В PHP 5 для этого требовалось предварительно скопировать в ZMM.

Изолированные (interned) строки — это такие строки, которые не уничтожаются до завершения запроса и потому не нуждаются в использовании счётчика ссылок. Они дедуплицированы, поэтому при создании новой изолированной строки движок сначала проверяет, нет ли другой с таким же значением. Вообще все строки, имеющиеся в PHP-коде (включая переменные, названия функций и т.д.), обычно являются изолированными. Неизменяемые строки — это изолированные строки, созданные до начала запроса. Они не уничтожаются по окончании запроса, в отличие от изолированных.

Если используется OPCache, то изолированные строки будут храниться в общей памяти (SHM) и использоваться всеми PHP-процессами. В этом случае неизменяемые строки становятся бесполезными, поскольку изолированные и так не будут уничтожены.


Я не буду вдаваться в детали относительно новой реализации массивов. Упомяну лишь про неизменяемые массивы. Это своеобразный аналог изолированных строк. Они тоже не используют счётчик ссылок и не уничтожаются до окончания запроса. В связи с некоторыми особенностями управления памятью, неизменяемые массивы используются только при работающем OPCache. Что это даёт, видно из примера:

for ($i = 0; $i < 1000000; ++$i) {
    $array[] = ['foo'];
}
var_dump(memory_get_usage());


С задействованным OPCache используется 32 Мб памяти, а без него — аж 390, поскольку в этом случае каждый элемент $array получает новую копию ['foo']. Почему делается копия вместо увеличения счётчика ссылок? Дело в том, что строковые операнды VM не используют счётчик ссылок, чтобы не нарушить SHM. Надеюсь, в будущем эта катастрофическая ситуация будет исправлена и можно будет отказаться от OPCache.
Прежде чем говорить о реализации объектов в PHP 7, давайте вспомним, как это было устроено в PHP 5 и какие имелись недостатки. zval использовался для хранения zend_object_value, определяемого следующим образом:

typedef struct _zend_object_value {
    zend_object_handle handle;
    const zend_object_handlers *handlers;
} zend_object_value;


handle — уникальный ID объекта, используемый для поиска его данных. handlers представляют собой VTable указателей функции, реализующие разное поведение объекта. Для «обычных» объектов эта таблица обработчиков будет одной и той же. Но объекты, созданные расширениями PHP, могут использовать кастомные наборы обработчиков, меняющие поведение объектов (например, переопределяя операторы).

Идентификатор объекта используется в качестве индекса в «хранилище объектов». Оно представляет собой массив:

typedef struct _zend_object_store_bucket {
    zend_bool destructor_called;
    zend_bool valid;
    zend_uchar apply_count;
    union _store_bucket {
        struct _store_object {
            void *object;
            zend_objects_store_dtor_t dtor;
            zend_objects_free_object_storage_t free_storage;
            zend_objects_store_clone_t clone;
            const zend_object_handlers *handlers;
            zend_uint refcount;
            gc_root_buffer *buffered;
        } obj;
        struct {
            int next;
        } free_list;
    } bucket;
} zend_object_store_bucket;


Здесь много всего интересного. Первые три элемента являются какими-то метаданными (были ли вызван деструктор объекта, было ли вообще использовано это ведро, сколько раз к этому объекту обращался рекурсивный алгоритм). Конструкция union зависит от того, используется ли хранилище в данный момент или находится в списке свободных. Нам важен случай, когда struct_store_object используется.

object является указателем на конкретный объект. Он не интегрирован в хранилище объектов, поскольку объекты не имеют фиксированного размера. За указателем следуют три обработчика, отвечающие за уничтожение, освобождение и клонирование. Обратите внимание, что в PHP операции уничтожения и освобождения объектов являются явными процедурами, хотя первая из них в некоторых случаях может пропускаться (unclean shutdown). Обработчик клонирования виртуально вообще не используется. Поскольку эти обработчики хранилища не относятся к обычным обработчикам объектов, то вместо расшаривания они дублируются для каждого объекта.

Эти обработчики хранилища по указателю переходят к обычным handlers. Те сохраняются, если объект был уничтожен без уведомления об этом zval (в котором обычно хранятся обработчики).

В хранилище также содержится и refcount, что даёт определённые преимущества в свете того, что в PHP 5 счётчик ссылок уже хранится в zval. Зачем нам два счётчика? Обычно zval «копируется» простым увеличением счётчика. Но бывает так, что появляются полноценные копии, то есть для того же самого zend_object_value создаётся совершенно новый zval. В результате два разных zval используют одно и то же объектное хранилище, что требует подсчёта ссылок. Этот «двойной подсчёт» является характерной особенностью реализации zval в PHP 5. По тем же причинам дуплицируется и указатель buffered в корневом буфере GC.

Рассмотрим object, на который ссылается хранилище объектов. Обычные объекты в пространстве пользователя определяются следующим образом:

typedef struct _zend_object {
    zend_class_entry *ce;
    HashTable *properties;
    zval **properties_table;
    HashTable *guards;
} zend_object;


zend_class_entry — это указатель на класс, сущностью которого является объект. Следующие два элемента используются для обеспечения хранения свойств объекта двумя разными способами. Для динамических свойств (то есть тех, что добавляются во время выполнения и не объявляются в классе) используется хэш-таблица properties, которая связывает имена свойств и их значения.

Для объявленных свойств используется оптимизация. В ходе компиляции каждое подобное свойство записывается в индекс, а его значение хранится в индексе в properties_table. Связи между именами и индексом хранятся в хэш-таблице в записи класса. Тем самым для индивидуальных объектов предотвращается перерасход памяти хэш-таблицы. Более того, индекс свойства полиморфно кэшируется в ходе выполнения.

Хэш-таблица guards используется для реализации рекурсивного поведения «магических» методов наподобие _get, но здесь я не буду её рассматривать.

Помимо вышеупомянутого двойного подсчёта ссылок, представление объекта также требует большого количества памяти. Минимальный объект с одним свойством занимает 136 байт (не считая zval). Более того, используется и немало косвенных адресаций. Например, чтобы вызвать свойство из zval объекта, вам придётся сначала вызвать хранилище объектов, потом объект Zend, потом таблицу свойств, и наконец свойство, на которое ссылается zval. Минимум четыре уровня косвенной адресации, а в реальных проектах будет не меньше семи.


Все вышеописанные недостатки попытались исправить в седьмой версии. В частности, отказались от двойного подсчёта ссылок, уменьшили потребление памяти и количество косвенной адресации. Так выглядит новая структура zend_object:

struct _zend_object {
    zend_refcounted   gc;
    uint32_t          handle;
    zend_class_entry *ce;
    const zend_object_handlers *handlers;
    HashTable        *properties;
    zval              properties_table[1];
};


Эта структура — почти всё, что осталось от объекта. zend_object_value, заменённый прямым указателем на объект и хранилище объектов, хоть и не исключён совсем, но на глаза попадается куда реже.

Помимо традиционного заголовка zend_refcounted, внутрь zend_object «переехали» handle и handlers. properties_table теперь тоже использует структурный хак, поэтому zend_object и таблица свойств размещаются одним блоком. И конечно же, в таблицу свойств теперь напрямую включаются сами zval, а не указатели на них.

Таблица guards теперь вынесена из структуры объекта и хранится в первой ячейке properties_table, если объект использует __get и т.д. Если же эти «магические» методы не используются, то не задействуется и таблица guards.

Обработчики dtor, free_storage и clone, которые ранее хранились в хранилище объектов, переехали в таблицу handlers:

struct _zend_object_handlers {
    /* offset of real object header (usually zero) */
    int                                     offset;
    /* general object functions */
    zend_object_free_obj_t                  free_obj;
    zend_object_dtor_obj_t                  dtor_obj;
    zend_object_clone_obj_t                 clone_obj;
    /* individual object functions */
    // ... rest is about the same in PHP 5
};


Элемент offset является не совсем обработчиком. Он имеет отношение к способу представления объектов: внутренний объект всегда внедряет стандартный zend_object, но в то же время обычно добавляет и некоторое количество элементов «сверху». В PHP 5 они добавлялись после стандартного объекта:

struct custom_object {
    zend_object std;
    uint32_t something;
    // ...
};


То есть вы можете просто отправить zend_object* в свой кастомный struct custom_object*. Это говорит о внедрении наследования структуры в языке С. Однако у подхода в PHP 7 есть свои особенности: поскольку zend_object использует структурный хак для хранения таблицы свойств, PHP сохраняет свойства в самом zend_object, перезаписывая дополнительные внутренние элементы. Поэтому в седьмой версии дополнительные методы хранятся перед стандартным объектом:

struct custom_object {
    uint32_t something;
    // ...
    zend_object std;
};


Это приводит к тому, что больше нельзя с помощью простого преобразования напрямую конвертировать между zend_object* и struct custom_object*, потому что между ними находится offset. Он хранится в первом элементе таблицы обработчиков объекта. Во время компиляции offset можно определить с помощью макроса offsetof().

Наверное, вы недоумеваете, почему PHP 7 до сих пор содержит handle. Ведь теперь используется прямой указатель на zend_object, так что больше нет нужды использовать handle для поиска объекта в хранилище. Однако handle всё ещё необходим, потому что до сих пор существует хранилище объектов, пусть и в существенно урезанном виде. Теперь это простой массив указателей на объекты. При создании объекта указатель помещается в хранилище в индекс handle, и удаляется оттуда при освобождении объекта.

Для чего ещё нужно хранилище объектов? Во время завершения запроса наступает момент, когда выполнение пользовательского кода может быть небезопасным, потому что исполнитель уже частично прекратил работу. Чтобы избежать этой ситуации, PHP запускает все деструкторы объектов на раннем этапе завершения. Для этого и нужен список всех активных объектов.

Кроме того, handle полезен для отладки, поскольку наделяет каждый объект уникальным ID. Это позволяет сразу понять, являются ли два объекта одни и тем же. Обработчик объекта всё ещё хранится в HHVM, хотя тот и не является хранилищем объектов.

В отличие от PHP 5, теперь используется только один счётчик ссылок (в zval его больше нет). Существенно уменьшилось потребление памяти, теперь для базового объекта достаточно 40 байт, а для каждого объявленного свойства — 16 байт, включая zval. Стало гораздо меньше косвенной адресации, поскольку многие промежуточные структуры были исключены или объединены с другими структурами. Поэтому при чтении свойства теперь используется лишь один уровень косвенной адресации вместо четырёх.


Давайте теперь рассмотрим специальные типы zval, используемые в особых случаях. Одним из них является IS_INDIRECT. Значение косвенного zval хранится в другом месте. Обратите внимание, что этот тип zval отличается от IS_REFERENCE тем, что он прямо указывает на другой zval, в отличие от структуры zend_reference, в которую zval внедрён.

В каких случаях может пригодиться этот тип zval? Давайте сначала рассмотрим реализацию переменных в PHP. Все переменные, которые известны на стадии компилирования, заносятся в индекс, а их значения записываются в таблицу скомпилированных переменных (CV) в этом индексе. Но PHP также позволяет нам динамически ссылаться на переменные, с помощью переменные переменной или, если вы находитесь в глобальной области видимости, с помощью $GLOBALS. При таком доступе PHP создаёт таблицу символов для функции/скрипта, содержащую карту имён переменных и их значений.

Возникает вопрос: как можно одновременно поддерживать два разных вида доступа? Для вызова нормальных переменных нам нужен доступ с помощью таблицы CV, а для переменных переменной — с помощью таблицы символов. В PHP 5 таблица CV использовала дважды косвенные указатели zval**. В обычной ситуации эти указатели приводят ко второй таблице указателей zval*, а она, в свою очередь, ссылается на сами zval:

+------ CV_ptr_ptr[0]
| +---- CV_ptr_ptr[1]
| | +-- CV_ptr_ptr[2]
| | |
| | +-> CV_ptr[0] --> some zval
| +---> CV_ptr[1] --> some zval
+-----> CV_ptr[2] --> some zval


Теперь, раз мы используем таблицу символов, вторая таблица с одиночными указателями zval* уже не применяется, а указатели zval** ссылаются на хранилища хэш-таблиц. Небольшая иллюстрация с тремя переменными $a, $b и $c:

CV_ptr_ptr[0] --> SymbolTable["a"].pDataPtr --> some zval
CV_ptr_ptr[1] --> SymbolTable["b"].pDataPtr --> some zval
CV_ptr_ptr[2] --> SymbolTable["c"].pDataPtr --> some zval


В PHP 7 такой подход больше невозможен, потому что указатель на хранилище будет признан недействительным при изменении размера хэш-таблицы. Теперь используется такой подход: для переменных, хранящихся в таблице CV, в хэш-таблице символов содержится запись INDIRECT, указывающая на запись CV. Таблица CV не подвергается перераспределению до тех пор, пока существует таблица символов. Поэтому больше нет проблемы недействительных указателей.

Если взять функцию с CV $a, $b и $c, а также динамически созданную переменную $d, то таблица символов может выглядеть так:

SymbolTable["a"].value = INDIRECT --> CV[0] = LONG 42
SymbolTable["b"].value = INDIRECT --> CV[1] = DOUBLE 42.0
SymbolTable["c"].value = INDIRECT --> CV[2] = STRING --> zend_string("42")
SymbolTable["d"].value = ARRAY --> zend_array([4, 2])


Косвенные zval также могут указывать на zval IS_UNDEF. В этом случае он обрабатывается так, словно хэш-таблица не содержит связанных ключей. А если unset($a) записывает в CV[0] тип UNDEF, то обрабатываться будет так, словно символьной таблице нет ключа «a».
Напоследок разберём и два специальных типа zval, имеющиеся в PHP 5 и 7 — IS_CONSTANT и IS_CONSTANT_AST. Чтобы понять их назначение, рассмотрим пример:

function test($a = ANSWER,
              $b = ANSWER * ANSWER) {
    return $a + $b;
}

define('ANSWER', 42);
var_dump(test()); // int(42 + 42 * 42)


По умолчанию для значений параметров функции test() используется константа ANSWER. Но она ещё не определена на момент объявления функции. Значение константы станет известно только после вызова define(). Поэтому дефолтные значения параметров и свойств, а также константы и все элементы, способные принимать «статическое выражение», могут откладывать вычисление выражения до первого использования.

Если значение является константой (или константой класса), то применяется zval типа IS_CONSTANT с именем константы. Если значение является выражением, то используется zval типа IS_CONSTANT_AST, ссылающийся на дерево абстрактного синтаксиса (AST).

* * *

На этом позвольте завершить столь объёмный обзор представления значений в PHP 7.

© Habrahabr.ru