Pull to refresh
VK
Building the Internet

Внутреннее представление значений в PHP7 (часть 1)

Reading time 13 min
Views 27K
Original author: Никита Попов
В связи с большим объёмом материала, публикацию пришлось разбить на две части. В первой из них я расскажу о том, как менялись реализации zval (Zend value) начиная с пятой версии PHP. Также обсудим реализацию ссылок. Во второй части будет подробно рассмотрена реализация отдельных типов данных, таких как строки и объекты.

zval’ы в PHP 5


Структура zval в пятой версии выглядит так:

typedef struct _zval_struct {
    zvalue_value value;
    zend_uint refcount__gc;
    zend_uchar type;
    zend_uchar is_ref__gc;
} zval;

Как видите, конструкция включает в себя value, type и дополнительную информацию __gc, о чём я расскажу ниже. Value представляет собой объединение различных возможных значений, которые может хранить zval:

typedef union _zvalue_value {
    long lval;                 // Для булевых, целочисленных и ресурсов
    double dval;               // Для чисел с плавающей запятой
    struct {                   // Для строковых
        char *val;
        int len;
    } str;
    HashTable *ht;             // Для массивов
    zend_object_value obj;     // Для объектов
    zend_ast *ast;             // Для констант
} zvalue_value;

Объединение в языке С – это структура, в которой лишь один компонент может быть активен в данный момент времени, и размер которой равен размеру самого большого компонента. Все компоненты объединения хранятся в памяти в одном месте и могут интерпретироваться по-разному, в зависимости от того, к кому из них вы обращаетесь. Если считать lval, то его значение будет интерпретировано как знаковое целочисленное. Значение dval будет представлено в виде числа двойной точности с плавающей запятой. И так далее.

Чтобы узнать, какой компонент объединения используется в данный момент, можно посмотреть текущее значение свойства type:

#define IS_NULL     0      /* Значение не используется */
#define IS_LONG     1      /* Используется lval */
#define IS_DOUBLE   2      /* Используется dval */
#define IS_BOOL     3      /* Используется lval со значениями 0 и 1 */
#define IS_ARRAY    4      /* Используется ht */
#define IS_OBJECT   5      /* Используется obj */
#define IS_STRING   6      /* Используется str */
#define IS_RESOURCE 7      /* Используется lval в качестве resource ID */

/* Специальные типы, используемые для позднего связывания констант */
#define IS_CONSTANT 8
#define IS_CONSTANT_AST 9


Подсчёт ссылок в PHP 5


За некоторыми исключениями, zval в PHP 5 располагаются в куче. Поэтому PHP нужно как-то отслеживать: какие именно zval сейчас используются и какие нужно очистить. Для этого используется подсчёт ссылок. Компонент refcount__gc как раз и хранит информацию о том, сколько раз ссылались на zval. Например, в $a = $b = 42 на значение 42 ссылаются две переменные, поэтому refcount равен 2. Если значение refcount равно нулю, это означает, что значение не используется и может быть очищено.

Обратите внимание, что ссылки, которые подсчитывает refcount (сколько раз на данный момент используется какое-то значение), не имеют ничего общего с PHP-ссылками (использующими &). Чтобы не возникало путаницы, далее по тексту будем использовать термины «ссылки» и «PHP-ссылки». Последние мы пока что не рассматриваем.

Схожая с подсчётом ссылок идея лежит в основе «копирования при записи». Совместно использовать zval можно лишь до тех пор, пока он не изменяется. Для видоизменения расшаренного zval его нужно дублировать (отделить) и все операции проводить уже с копией.

В этом примере показано копирование при записи и уничтожение zval’а:

$a = 42;   // $a         -> zval_1(type=IS_LONG, value=42, refcount=1)
$b = $a;   // $a, $b     -> zval_1(type=IS_LONG, value=42, refcount=2)
$c = $b;   // $a, $b, $c -> zval_1(type=IS_LONG, value=42, refcount=3)

// Следующая строка дублирует zval
$a += 1;   // $b, $c -> zval_1(type=IS_LONG, value=42, refcount=2)
           // $a     -> zval_2(type=IS_LONG, value=43, refcount=1)

unset($b); // $c -> zval_1(type=IS_LONG, value=42, refcount=1)
           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)

unset($c); // zval_1 уничтожен, потому что refcount=0
           // $a -> zval_2(type=IS_LONG, value=43, refcount=1)

У подсчёта ссылок есть один серьёзный недостаток: этот механизм не способен определять циклические ссылки. Для этого в PHP используется дополнительный инструмент — циклический сборщик мусора. Каждый раз, когда значение refcount уменьшается и возникает вероятность, что zval стал частью цикла, он записывается в root buffer. Когда этот буфер заполняется, потенциальные циклы помечаются и зачищаются сборщиком мусора.

Для обеспечения работы этого циклического сборщика используется следующая структура:

typedef struct _zval_gc_info {
    zval z;
    union {
        gc_root_buffer       *buffered;
        struct _zval_gc_info *next;
    } u;
} zval_gc_info;


Структура zval_gc_info включает в себя обычный zval и дополнительный указатель. Указатель u, являющийся объединением, используется для обозначения одного из двух типов. Указатель buffered хранит информацию о том, откуда в root buffer ссылаются на zval. В случае уничтожения zval указатель уничтожается до момента запуска циклического сборщика (что весьма удобно), next используется при уничтожении значений сборщиком.

Необходимость перемен


Поговорим немного о размерах (всё нижесказанное относится к 64-битным системам). Объединение zvalue_value занимает 16 байт, поскольку тот же размер имеют str и obj. Вся структура zval занимает 24 байта, а zval_gc_info — 32 байта. Помимо прочего, размещение zval в куче дополнительно потребляет 16 байт. Итого на каждый zval приходится по 48 байт, вне зависимости от количества мест, где он используется.

И здесь закрадываются сомнения в эффективности реализации zval. Судите сами: допустим, он хранит простое целочисленное, которое само по себе занимает 8 байт. Также в любом случае нужно хранить и метку типа, которая занимает один байт, но из-за структуры требует все восемь. К получившимся 16 байтам надо добавить ещё 16 для нужд подсчёта ссылок и циклического сборщика мусора, и ещё 16 — для размещения в куче. Не говоря о том, что сами операции размещения и последующего удаления потребляют немало ресурсов.

Уместно задать вопрос: действительно ли хранение простых целочисленных требует подсчёта ссылок, использования циклического сборщика и размещения в куче? Конечно, нет. Вот список основных проблем, связанных с реализацией zval в PHP 5:
  • Zval (почти) всегда требуется размещать в куче.
  • Zval всегда требуют использования подсчёта ссылок и сбора информации о циклах. Даже в тех случаях, когда расшаривание значений не стоит потраченных ресурсов (целочисленные) или циклы не могут возникнуть в принципе.
  • Прямой подсчёт ссылок приводит к двойному выполнению этой процедуры в случае с объектами и ресурсами. Причину этого явления я разберу во второй части публикации.
  • В некоторых случаях приходится прибегать к большому количеству обходных манёвров. Например, чтобы получить доступ к объекту, хранящемуся в переменной, необходимо суммарно разыменовать четыре указателя, со всеми сопутствующими цепочками. Об этом я тоже поговорю во второй части.
  • Прямой подсчёт ссылок также означает, что значения можно расшаривать только между zval’ами. Например, строку невозможно совместно использовать в zval и ключе хэш-таблицы (без хранения этого ключа также в виде zval).

Zval’ы в PHP 7


В седьмой версии языка мы получили новую реализацию zval. Одним из главных нововведений стало то, что zval больше не нужно отдельно размещать в куче. Также refcount теперь хранится не в самом zval, а в любом из комплексных значений, на которые он указывает — в строках, массивах или объектах. Это даёт следующие преимущества:
  • Простые значения не требуют размещения в куче и не используют подсчёт ссылок.
  • Больше нет никакого двойного подсчёта. В случае с объектами используется счётчик только внутри самого объекта.
  • Поскольку refcount теперь хранится в самом значении, то оно может быть использовано независимо от самого zval. Например, строка может использоваться и в zval, и быть ключом в хэш-таблице.
  • Теперь стало гораздо меньше указателей, которые нужно перебрать, чтобы получить значение.

Вот как выглядит структура нового zval:

struct _zval_struct {
    zend_value value;
    union {
        struct {
            ZEND_ENDIAN_LOHI_4(
                zend_uchar type,
                zend_uchar type_flags,
                zend_uchar const_flags,
                zend_uchar reserved)
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t var_flags;
        uint32_t next;                 // hash collision chain
        uint32_t cache_slot;           // literal cache slot
        uint32_t lineno;               // line number (for ast nodes)
        uint32_t num_args;             // arguments number for EX(This)
        uint32_t fe_pos;               // foreach position
        uint32_t fe_iter_idx;          // foreach iterator index
    } u2;
};


Первый компонент остался практически таким же, это объединение value. Второй компонент — целочисленный, хранящий информацию о типе, который с помощью объединения разбит на отдельные байты (можно игнорировать макрос ZEND_ENDIAN_LOHI_4, он нужен лишь для обеспечения консистентной структуры между платформами с разными порядками следования байтов). Важными частями этой вложенной конструкции являются type и type_flags, о них я расскажу ниже.

Также здесь есть одна небольшая проблема. Value занимает 8 байт, и благодаря своей структуре добавление даже одного байта повлечёт за собой увеличение размера zval на 16 байт. Но ведь нам не нужно целых 8 байт для хранения типа. Поэтому в zval есть дополнительное объединение u2, которое по умолчанию не используется, но может применяться для хранения 4 байт данных. Разные компоненты объединения предназначены для разных видов использования этого дополнительного хранилища.

В PHP 7 объединение value несколько отличается от пятой версии:

typedef union _zend_value {
    zend_long         lval;
    double            dval;
    zend_refcounted  *counted;
    zend_string      *str;
    zend_array       *arr;
    zend_object      *obj;
    zend_resource    *res;
    zend_reference   *ref;
    zend_ast_ref     *ast;

    // Эти пока можно игнорировать, они специальные
    zval             *zv;
    void             *ptr;
    zend_class_entry *ce;
    zend_function    *func;
    struct {
        ZEND_ENDIAN_LOHI(
            uint32_t w1,
            uint32_t w2)
    } ww;
} zend_value;


Обратите внимание, что value теперь занимает 8 байт вместо 16. Оно хранит только целочисленные (lval) и числа с плавающей запятой (dval). Всё остальное — это указатель. Все типы указателей (за исключением специальных, отмеченных выше) используют подсчёт ссылок и содержат заголовок, определяемый 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. Кроме того, здесь присутствуют type, flags и gc_info. Type всего лишь наследует тип zval и позволяет GC различать разные подсчитываемые структуры без хранения в zval. Flags используется для различных задач с разными типами данных. О нём я расскажу подробнее во второй части.

Gc_info аналогичен buffered в старой версии zval. Но вместо хранения указателя на root buffer он теперь хранит индекс. Поскольку root buffer имеет ограниченную ёмкость (10 000 элементов), то достаточно использовать 16-битный указатель вместо 64-битного. Также в gc_info содержится информация о «цвете» ноды, используемом для обозначения нод в коллекциях.

Управление памятью zval


Я уже упоминал, что zval больше не нужно отдельно размещать в куче. Но их же нужно где-то хранить. Они всё ещё являются частью структур, размещаемых в кучах. Например, хэш-таблица будет содержать собственный zval вместо указателя на отдельный zval. Скомпилированная таблица переменных функции и таблица свойств объекта будут представлять собой zval-массивы. В качестве таких zval теперь обычно хранятся те, у которых косвенность на один уровень ниже. То есть zval’ом теперь называется то, что раньше было zval*.

Когда-то нужно было копировать zval* и инкрементить его refcount, чтобы использовать zval в новом месте. Теперь для этого достаточно скопировать содержимое zval (игнорируя u2) и, может быть, инкрементить refcount того значения, на которое он указывает, если значение использует подсчёт ссылок.

Откуда PHP знает, что используется подсчёт? Это нельзя определить по одному лишь типу, поскольку некоторые типы не используют refcount — например, строки и массивы. Для этого используется один бит компонента type_info.

Несколько бит также используется для кодирования свойств типа:

#define IS_TYPE_CONSTANT            (1<<0)   /* специальный */
#define IS_TYPE_IMMUTABLE           (1<<1)   /* специальный */
#define IS_TYPE_REFCOUNTED          (1<<2)
#define IS_TYPE_COLLECTABLE         (1<<3)
#define IS_TYPE_COPYABLE            (1<<4)
#define IS_TYPE_SYMBOLTABLE         (1<<5)   /* специальный */


Три основных свойства, которые может иметь тип: refcounted, collectable и copyable.

Collectable означает, что zval может быть частью цикла. Например, строковые переменные часто refcounted, но создать с ними цикл невозможно.

Сopyable определяет, должно ли копироваться значение, когда выполняется дупликация. Если вы дуплицируете zval, указывающий на массив, то это не означает, что всего лишь увеличится значение refcount массива. Вместо этого будет создана новая независимая копия массива. Но в случае с некоторыми типами, например, объектами и ресурсами, при дублировании всего лишь увеличивается refcount. Такие типы называются некопируемыми (non-copyable). Это соответствует передаче семантики объектов и ресурсов (которые не передаются по ссылке).

Ниже приведена таблица, в которой показано, какие флаги могут использовать те или иные типы. Под «простыми» подразумеваются типы вроде целочисленных или булевых, которые не используют указатель на внешнюю структуру. Во второй части я также подробнее разберу неизменяемые массивы.

                       | refcounted | collectable | copyable | immutable
-----------------------+------------+-------------+----------+----------
Простые типы           |            |             |          |
Строковая              |      x     |             |     x    |
Интернированная строка |            |             |          |
Массив                 |      x     |      x      |     x    |
Неизменяемый массив    |            |             |          |     x
Объект                 |      x     |      x      |          |
Ресурс                 |      x     |             |          |
Ссылка                 |      x     |             |          |


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

$a = 42;   // $a = zval_1(type=IS_LONG, value=42)

$b = $a;   // $a = zval_1(type=IS_LONG, value=42)
           // $b = zval_2(type=IS_LONG, value=42)

$a += 1;   // $a = zval_1(type=IS_LONG, value=43)
           // $b = zval_2(type=IS_LONG, value=42)

unset($a); // $a = zval_1(type=IS_UNDEF)
           // $b = zval_2(type=IS_LONG, value=42) 


Поскольку целочисленные значения больше не расшариваются, то обе переменные используют разные zval. Напоминаю, что они теперь встроены, а не размещены в памяти отдельно. Это подчёркивается и использованием = вместо ->. При очистке переменной тип соответствующего zval изменится на IS_UNDEF.

Теперь второй пример, здесь уже используется сложное значение:

$a = [];   // $a = zval_1(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

$b = $a;   // $a = zval_1(type=IS_ARRAY) -> zend_array_1(refcount=2, value=[])
           // $b = zval_2(type=IS_ARRAY) ---^

// Здесь происходит разделение zval
$a[] = 1   // $a = zval_1(type=IS_ARRAY) -> zend_array_2(refcount=1, value=[1])
           // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])

unset($a); // $a = zval_1(type=IS_UNDEF) и zend_array_2 уничтожен
           // $b = zval_2(type=IS_ARRAY) -> zend_array_1(refcount=1, value=[])


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

Типы


Какие типы поддерживаются в PHP 7:

// обычные типы данных
#define IS_UNDEF                    0
#define IS_NULL                     1
#define IS_FALSE                    2
#define IS_TRUE                     3
#define IS_LONG                     4
#define IS_DOUBLE                   5
#define IS_STRING                   6
#define IS_ARRAY                    7
#define IS_OBJECT                   8
#define IS_RESOURCE                 9
#define IS_REFERENCE                10

// константные выражения
#define IS_CONSTANT                 11
#define IS_CONSTANT_AST             12

// внутренние типы
#define IS_INDIRECT                 15
#define IS_PTR                      17


В чём отличия от PHP 5:
  • Тип IS_UNDEF используется вместо указателя на zval NULL (не путайте с IS_NULL zval). Например, в приведённых выше примерах переменным назначается тип IS_UNDEF.
  • Тип IS_BOOL разделён на IS_FALSE и IS_TRUE. Поскольку такое булево значение теперь встроено в тип, это позволяет оптимизировать ряд проверок на основе типа. Данное изменение незаметно для пользователей, которые по прежнему оперируют единственным «булевым» типом.
  • PHP-ссылки больше не используют в zval флаг is_ref. Вместо него введён новый тип IS_REFERENCE. Ниже я расскажу, как это работает.
  • IS_INDIRECT и IS_PTR являются специальными внутренними типами.

Тип IS_LONG вместо обычного long из языка С теперь использует значение zend_long. Причина в том, что в 64-битных Windows long имеет разрядность только 32 бита. Поэтому PHP 5 больше не использует в Windows в обязательном порядке 32-битные числа. А в PHP 7 вы можете использовать 64-битные значения, если система также 64-битная.

В следующей части мы подробнее рассмотрим реализацию индивидуальных типов zend_refcounted. Здесь же ограничимся разбором реализации PHP-ссылок.

Ссылки


В PHP 7 кардинально изменился подход к использованию & PHP-ссылок. И это стало одной из основных причин появления багов. Для начала вспомним, как это реализовано в PHP 5. В обычной ситуации принцип «копирование при записи» подразумевает, что zval нужно продублировать, прежде чем вносить изменения. Это делается для того, чтобы случайно не изменить значение для каждого места, использующего zval, что соответствует семантике передачи по значению.

Для PHP-ссылок это не годится. Если значение является PHP-ссылкой, то вы сами захотите поменять его для каждого его пользователя. В PHP 5 флаг is_ref позволяет определить, является ли значение PHP-ссылкой, и если да, то требуется ли провести отделение перед внесением изменений.

$a = [];  // $a     -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b =& $a; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[])

$b[] = 1; // $a = $b = zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_1(value=[1])
          // Поскольку is_ref=1, PHP не будет отделять zval


С подобным подходом связана одна значительная проблема: невозможно расшаривать значение между двумя переменными, одна из которых является PHP-ссылкой, а другая нет.

$a = [];  // $a         -> zval_1(type=IS_ARRAY, refcount=1, is_ref=0) -> HashTable_1(value=[])
$b = $a;  // $a, $b     -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
$c = $b   // $a, $b, $c -> zval_1(type=IS_ARRAY, refcount=3, is_ref=0) -> HashTable_1(value=[])

$d =& $c; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[])
          // $d является ссылкой $c, а не $a или $b, поэтому zval должен быть скопирован сюда. Теперь у нас есть один zval с is_ref=0 и один с is_ref=1.

$d[] = 1; // $a, $b -> zval_1(type=IS_ARRAY, refcount=2, is_ref=0) -> HashTable_1(value=[])
          // $c, $d -> zval_1(type=IS_ARRAY, refcount=2, is_ref=1) -> HashTable_2(value=[1])
          // Поскольку у нас два отдельных zval $d[] = 1 не изменяет $a и $b.


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

$array = range(0, 1000000);
$ref =& $array;
var_dump(count($array)); // <-- здесь происходит отделение


count() принимает значение напрямую от переменной, но $array является PHP-ссылкой, поэтому полная копия массива создаётся до того, как он передается в count(). Если бы $array не был ссылкой, то значение было бы расшарено.

Теперь посмотрим, как PHP-ссылки реализованы в седьмой версии. Поскольку zval больше не выделены отдельно, то здесь невозможно использовать подход из PHP 5. Появился новый тип IS_REFERENCE, использующий в качестве значения структуру zend_reference:

struct _zend_reference {
    zend_refcounted   gc;
    zval              val;
};


По сути, zend_reference представляет собой zval с подсчётом ссылок. Во всех переменных набора ссылок zval будет храниться с типом IS_REFERENCE, указывающим на тот же самый инстанс zend_reference. Поведение val ничем не отличается от любого другого zval, в том числе с точки зрения возможности расшаривания сложного значения, на которое он указывает.

На вышеприведённых примерах рассмотрим семантику PHP 7. Для краткости возьмём только структуру, на которую ссылаются индивидуальные zval переменных.

$a = [];  // $a                                     -> zend_array_1(refcount=1, value=[])
$b =& $a; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[])

$b[] = 1; // $a, $b -> zend_reference_1(refcount=2) -> zend_array_1(refcount=1, value=[1])


Новый zend_reference был создан присваиванием по ссылке. Обратите внимание, что у ссылки refcount равен 2 (потому что две переменные являются частью набора PHP-ссылок), но у самого значения refcount равен 1, поскольку на него ссылается одна структура zend_reference. Теперь рассмотрим ситуацию, когда используются ссылки и не-ссылки:

$a = [];  // $a         -> zend_array_1(refcount=1, value=[])
$b = $a;  // $a, $b,    -> zend_array_1(refcount=2, value=[])
$c = $b   // $a, $b, $c -> zend_array_1(refcount=3, value=[])

$d =& $c; // $a, $b                                 -> zend_array_1(refcount=3, value=[])
          // $c, $d -> zend_reference_1(refcount=2) ---^
          // Все переменные, как являющиеся PHP-ссылками, так и не являющиеся, используют один zend_array.

$d[] = 1; // $a, $b                                 -> zend_array_1(refcount=2, value=[])
          // $c, $d -> zend_reference_1(refcount=2) -> zend_array_2(refcount=1, value=[1])
          // Только в данный момент происходит дупликация zend_array, после операции присваивания.


Важное различие между седьмой и пятой версиями заключается в том, что все переменные теперь могут использовать один и тот же массив, вне зависимости от того, являются ли они PHP-ссылками или нет. Массив будет отделён только после внесения некоторых изменений. То есть в PHP 7 можно безопасно передать в count() большой массив ссылку, и он не будет продублирован в памяти. Использование ссылок всё ещё уступает по производительности обычным значениям, поскольку они требуют размещения структуры zend_reference и обычно обрабатываются движком не слишком эффективно.

Заключение


Подведём итоги: главное нововведение в PHP 7 заключается в том, что zval больше не нужно выделять отдельно, и они больше не хранят refcount. Счётчики теперь хранятся внутри сложных значений, на которые они могут указывать — строки, массивы или объекты. Это снижает количество операций по выделению памяти, косвенность при пересчете и потребление памяти.
Tags:
Hubs:
+31
Comments 3
Comments Comments 3

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен