Зачем простому 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. Код:
/* {{{ 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.
Автор: godzie