PHP, GDB и массивы

Зачем простому PHP разработчику может понадобится дебаг исходников? Ну например если он заметил какое-то не очевидное поведение и хочет разобраться в нем на максимально “низком” уровне. О таком интересном для меня поведение, а так же процессе “дебага сурсов” я и хотел бы поговорить.

Мотивация


Начнем с места в карьер, вот два похожих куска кода:

Раз
$arr = [];

for ($i =0; $i < 300; $i++)
{
  $arr[rand(1,1000)] = 1;
}
 
$sum = 0;
for ($i = 1001; $i < 200000000; $i++){
  if (array_key_exists($i, $arr)){
    $sum++;
  }
}


Два
$arr = [];

for ($i =0; $i < 300; $i++)
{
  $arr[rand(1,1000)] = 1;
}

sort($arr);
 
$sum = 0;
for ($i = 1001; $i < 200000000; $i++){
  if (array_key_exists($i, $arr)){
    $sum++;
  }
}


Разница между ними в том, что во втором случае мы отсортировали массив $arr, тем самым обновили ключи 0..count($arr)-1. А заинтересовал меня тот момент, что первый скрипт выполняется 6.0 секунд, тогда как второй 4.7 секунды. Получается около 20 процентов разницы.

Если Вы знакомы с внутренним устройством хэш-таблицы php, а так же с хэш функцией, то Вы или уже знаете ответ или можете без особого труда догадаться. Ну а для остальных я расскажу как настроить среду для дебага и выяснить в чем же дело.

Настраиваем окружение


Нам понадобится:

  • исходники php.
  • дебагер gdb.
  • сам php скомпилированный в debug моде.

Клонируем к себе исходники php:

git clone https://git.php.net/repository/php-src.git
cd php-src
git checkout -b PHP-7.1 origin/PHP-7.1

Если у вас нет необходимых для компиляции библиотек, ставим и их:

sudo apt-get install build-essential autoconf automake libtool

И компилируем:

./buildconf
./configure --disable-all --enable-debug –prefix=$HOME/dev/bin/php
make && make install


Начинаем поиски


Для начала определимся с отправной точкой нашего расследования, это будет функция array_key_exists. Найти ее в исходниках труда не составит если знать как в PHP объявляются функции – это макрос PHP_FUNCTION(%function_name%). Делаем поиск по исходникам строки PHP_FUNCTION(array_key_exists) и находим нашу функцию в extension/standart/array.c. Код:

PHP_FUNCTION(array_key_exists)
/* {{{ proto bool array_key_exists(mixed key, array search)
   Checks if the given key or index exists in the array */
PHP_FUNCTION(array_key_exists)
{
	zval *key;					/* key to check for */
	HashTable *array;			/* array to check in */

	ZEND_PARSE_PARAMETERS_START(2, 2)
		Z_PARAM_ZVAL(key)
		Z_PARAM_ARRAY_OR_OBJECT_HT(array)
	ZEND_PARSE_PARAMETERS_END();

	switch (Z_TYPE_P(key)) {
		case IS_STRING:
			if (zend_symtable_exists_ind(array, Z_STR_P(key))) {
				RETURN_TRUE;
			}
			RETURN_FALSE;
		case IS_LONG:
			if (zend_hash_index_exists(array, Z_LVAL_P(key))) {
				RETURN_TRUE;
			}
			RETURN_FALSE;
		case IS_NULL:
			if (zend_hash_exists_ind(array, ZSTR_EMPTY_ALLOC())) {
				RETURN_TRUE;
			}
			RETURN_FALSE;

		default:
			php_error_docref(NULL, E_WARNING, "The first argument should be either a string or an integer");
			RETURN_FALSE;
	}
}


Перед нами высокоуровневая обертка для core функций языка, копнем чуть глубже в функцию zend_hash_index_exists.

Найти мы ее можем в Zend/zend_hash.c:

ZEND_API zend_bool ZEND_FASTCALL zend_hash_index_exists(const HashTable *ht, zend_ulong h)
{
	Bucket *p;

	IS_CONSISTENT(ht);

	if (ht->u.flags & HASH_FLAG_PACKED) {
		if (h < ht->nNumUsed) {
			if (Z_TYPE(ht->arData[h].val) != IS_UNDEF) {
				return 1;
			}
		}
		return 0;
	}

	p = zend_hash_index_find_bucket(ht, h);
	return p ? 1 : 0;
}


Дебажим


Запускаем дебагер:

gdb --args /home/your_pc/dev/bin/php/bin/php /home/your_pc/array_w_sort_test.php

1й параметр – путь к скомпилированному php, 2й — путь к нашему тесту в котором используем сортировку.

После того как мы попали в дебагер стоит выполнить такую команду:

(gdb) source ~/dev/c/php-src/.gdbinit

Это даст нам несколько очень полезных команд, посмотреть список можно написав:

(gdb) help user-defined

Ставим breakpoint на функции zend_hash_index_exists:

(gdb) break zend_hash_index_exists

И запускаем скрипт:

(gdb) run

Видим на экране что-то вроде:

(gdb) run
Starting program: /home/your_pc/dev/bin/php/bin/php /home/your_pc/array_w_sort_test.php

Breakpoint 1, zend_hash_index_exists (ht=0x7ffff7059780, h=1001)
    at /home/your_pc/dev/c/php-src/Zend/zend_hash.c:2030
2030		IS_CONSISTENT(ht);

Отлично, мы внутри потока выполнения!

Чтобы посмотреть где мы находимся в PHP коде, выполним команду:

(gdb) zbacktrace 

Результат говорит нам, что точка входа была определена правильно:

[0x7ffff7015240] array_key_exists(1001, array(251)[0x7ffff70152a0]) [internal function]
[0x7ffff7015030] (main) /home/your_pc/array_w_sort_test.php:14 

Для перемещения по исходному коду используем команду:

(gdb) next

или

(gdb) step

Первая не будет проваливаться внутрь функций которые встречаются по пути, вторая соответственно будет.

Выполним команду для просмотра параметров с которыми вызвана функция:

(gdb) info args
ht = 0x7ffff7059780
h = 1001

ht — это указатель на хэш таблицу, h — искомый ключ.

И так, добираемся до строки:

if (ht->u.flags & HASH_FLAG_PACKED) {

Делаем next – условие выполняется! Отлично, проделаем те же шаги для теста без сортировки – условие не выполняется! Ну что, мы нашли ту “вилку”, которая и делает такую разницу в скорости. Разберемся подробнее.

Немного о структуре хэш таблиц PHP.


Выполним команду ptype чтобы посмотреть что из себя представляет хэш таблица ht

(gdb) ptype ht

type = const struct _zend_array {
    zend_refcounted_h gc;
    union {
        struct {...} v;
        uint32_t flags;
    } u;
    uint32_t nTableMask;
    Bucket *arData;
    uint32_t nNumUsed;
    uint32_t nNumOfElements;
    uint32_t nTableSize;
    uint32_t nInternalPointer;
    zend_long nNextFreeElement;
    dtor_func_t pDestructor;
} *

Здесь нас интересует:

  • arData – С массив, содержащий бакеты. Бакет – структура C, содержащие zval – хранимый элемент, ключ в массиве php, а так же хэш этого ключа в виде числа.
  • NnumUsed – максимальный занятый индекс в массиве arData + 1.
  • u.flags — без знаковый int, каждый бит которого соответствует какому-либо флагу.
  • Кроме того, внимательно взглянув на структуру, вы спросите как же хэш ключа отображается на массив arData? Для этого разработчики php используют translation table. Она хранится “позади” массива arData (arData[-1], arData[-2] и тд) и представляет собой отображение хэшей в индексы массива arData.
  • И наконец, надо знать что к целочисленным ключам операция хэширования не применяется, и они транслируются в хэш ключи таблицы “как есть”.

Вернемся к коду


ht→u.flags набор флагов свернутый в число. Для флага HASH_FLAG_PACKED нас интересует 3й бит.

Чтобы получить значение флагов выполним команду:

(gdb) print ht->u.flags

$1 = 30

Так и есть, в 3-м бите единица.

Так как условие верно мы попадаем сюда:
if (h < ht->nNumUsed) {
			if (Z_TYPE(ht->arData[h].val) != IS_UNDEF) {
				return 1;
			}
		}
		return 0;

В строке 1, проверяем является ли значение искомого ключа валидным по отношению к массиву arData. Если да, нам остается только проверить содержится ли в массиве arData по адресу h элемент.

Ну а если флаг HASH_FLAG_PACKED не выставлен, то мы попадем сюда

p = zend_hash_index_find_bucket(ht, h);

Эта функция будет искать требуемый элемент, но уже с использованием упомянутой выше translation table.

Ну вот и вся магия. Дебагер можно закрывать. Остался только 1 вопрос. А что же это собственно за флаг HASH_FLAG_PACKED и где он выставляется?

Данный флаг сигнализирует нам о Packed hashtable optimization – все ключи php массива – числа и расположены в сортировке по возрастанию. Относительно тестового примера догадаться не сложно, выставляется этот флаг внутри функции sort. Как оказалось такая оптимизация влияет на довольно большое количество аспектов работы с языком. Тут наверное ни одну еще статью можно написать.

А мой рассказ на этом заканчивается, мы рассмотрели некоторые приемы работы в связке php + dbg, и краем глаза взглянули на одну интересную оптимизацию в алгоритмах хэш таблиц. Надеюсь описанные выше методы помогут и вам в исследование замечательного инструмента PHP.
  • +22
  • 4.6k
  • 3
Share post

Comments 3

    0
    Отличная статья, спасибо
      +1
      Массив сразу создается упакованным если он инициализируется элементом с числовым ключом меньшим nTableSize. «Распаковка» происходит в следующих случаях:
      1. Добавляется элемент со строковым ключем — тут все очевидно
      2. Пустой массив инициализируется элементом с числовым ключем большим или равным nTableSize = HT_MIN_SIZE = 8. Точнее массив сразу инициализируется как mixed.
      3. Добавляется элемент с числовым индексом меньшим, чем максимально использованный в массиве.
      $arr = [];          // flags = HASH_FLAG_UNINITIALIZED, nTableSize = HT_MIN_SIZE
      $arr[2] = 'a';      // Т.к. 2 < nTableSize, то массив инициализируется "упакованным". 
                          // [0=>IS_UNDEF, 1=>IS_UNDEF, 2=>'a' ... 7=>IS_UNDEF]
      $arr[1] = 'b';      // Упс! Мы должны сохранять очередность элементов 
                          // массив "распаковывается" в хеш-таблицу

      4. Производится сортировка с сохранением ключей
        0
        Спасибо за дополнение! 3й случай довольно интересный на мой взгляд

      Only users with full accounts can post comments. Log in, please.