23 заметки с тегом

php7

Забавный баг в FFI (PHP 7.4.2)

В последнее время очень заинтересовался FFI в ПХП — интерфейсом к языку Си, появившимся в версии 7.4. Очень полезное, как по мне, нововведение, позволяющее расширять язык, обходясь минимальными знаниями о других языках программирования.

Очевидно, потребность в чём-нибудь таком давно назрела, появлялся даже специальный язык для написания расширений — «Зефир», очень похожий на ПХП, принципиальных отличий нет, порог входа — низкий. При этом он транслируется в Си, благодаря чему достигается почти нативная производительность.

К несчастью, модуль FFI в ПХП только-только появился и содержит по меньшей мере один очень неприятный баг, который может привести к труднопонимаемым ошибкам в программах.

Небольшой код для иллюстрации проблемы:

const IRRELEVANT = "Hello ";

echo IRRELEVANT;  // Выведет «Hello»

FFI::cdef('char *strcpy(char *dst, const char *src);')->strcpy("Hello ", "world!");

echo IRRELEVANT; // Выведет «world!»

В чём суть? Мы объявляем константу IRRELEVANT, убеждаемся, что она содержит присвоенное ей значение, потом вызываем через FFI копирование одной строки в другую, и константа внезапно меняет своё значение. Как это произошло? Чтобы понять, немного модифицируем программу.

function strcpy(string $dst, string $src):void
{
    FFI::cdef('char *strcpy(char *dst, const char *src);')->strcpy($dst, $src);
}

$rock1 = str_repeat('ROCK', 1);
$rock2 = str_repeat('ROCK', 1);

var_dump($rock1, $rock2); // Выведет «ROCK» два раза

strcpy($rock1, "SOCK");
strcpy($rock2, "LOCK");

var_dump($rock1); // выведет «SOCK»
var_dump($rock2); // выведет «LOCK»

Что у нас тут? Вызов strcpy обёрнут в функцию, которая ничего не возвращает и ничего не должна модифицировать — параметры передаются по значению, а не ссылке. Тем не менее, если вызвать объявленную функцию и передать в неё переменные, их значение будет изменено.

В ПХП для обеспечения производительности используется механизм копирования при записи — если куда-то переменную нужно передать по значению, то интерпретатор для скорости передаёт её по ссылке, а если требуется модификации переданного значения, копирует его. Модификацию отслеживает сам интерпретатор, но в случае FFI этого, по всей видимости, не происходит.

В силу этой особенности, переменные остаются ссылочными, попадая в таком виде в сишный код, где модифицируются — вызов strcpy меняет первую переданную в него переменную, копируя в неё значение второго параметра.

Кстати, если изменить строку инициализации переменных на вот такую:

$rock1 = $rock2 = str_repeat('ROCK', 1);

то после вызовов strcpy обе переменные получат значение «LOCK» — работает тот же самый механизм.

Почему же в первом листинге изменилось значение константы? Сейчас разберёмся, осталось совсем немного.

Ещё одной распространённой техникой увеличения производительности (и снижения потребления памяти заодно) является интернирование, её используют многие интерпретаторы и компиляторы. В общем случае она заключается в том, что какие-то простые сущности (небольшие строки, числа и т. п.) хранятся один раз и все переменные, которые их содержат, ссылаются на одну и ту же копию данных.

Таким образом константа IRRELEVANT и строка «Hello » в первом параметре — суть одна и та же область памяти. Когда функция strcpy модифицирует эту область, меняются сразу все значения всего, что, для оптимальности, ссылается на это же место. Естественно, константа изменяется тоже.

Поскольку интернирование не работает во время выполнения, результат str_repeat('ROCK', 1); не интернируется и во втором листинге такого эффекта не создаётся.

Как же разорвать эту мистическую связь и заставить работать код так как задумывалось? Для этого нужно, чтобы интерпретатор создал новую область памяти, которую будет портить FFI, не затрагивая нашу переменную. Это можно сделать, например, при помощи уже упомянутой функции str_repeat:

function strcpy(string $dst, string $src):void
{
    // Создаём новое место, которое испортит strcpy
    $dst = str_repeat($dst, 1);
    FFI::cdef('char *strcpy(char *dst, const char *src);')->strcpy($dst, $src);
}

$rock = 'ROCK';
var_dump($rock); // Выведет «ROCK»

strcpy($rock, "SOCK");
var_dump($rock); // Так же выведет «ROCK»

Естественно, подойдут и substr, и sprintf, и вообще любые функции, в общем случае возвращающие модифицированную строку. Интересно, что implode([$dst]) от бага не защищает, видимо для этого случая внутри работает какая-то оптимизация, возвращающая значение $dst по ссылке до первой модификации.

Всё описанное верно для версии актуальной на текущий момент — 7.4.2. Надеюсь, в скором времени этот довольно забавный баг устранят и необходимость в таких ухищрениях исчезнет.

PHP: FFI vs. exec

Нередко отсутствующую в ПХП функциональность наскоро заменяют вызовом утилит командной строки. И мне стало интересно взглянуть на досуге — может ли вызов через ffi (интерфейс к Си, который появился в ПХП 7.4) быть хорошей альтернативой.

Говорят, вызов через ffi работает очень медленно, такова природа этого способа (и мы это видим в других языках), это, конечно, очевидно, но медленно насколько?

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

$mkdtemp = function (string $template): string {
    $cmd = '/usr/bin/mktemp -d '.escapeshellarg($template);
    $dir = exec($cmd, $out, $ret);

    if ($ret === 0) {
        return $dir;
    } else {
        ['message' => $message, 'type' => $type] = error_get_last();
        throw new RuntimeException($message, $type);
    }
};

Весь код был обёрнут в цикл на 10000 итераций и запущен. Я провёл по десять запусков, а результат усреднил.

Создание директория через ffi заняло примерно 0,77 секунд на 10 тысяч итераций, а через утилиту командной строки — примерно 72,62 секунды на те же 10 тысяч итераций.

Конечно какое-то время занимает запуск интерпретатора и прочие расходы, но даже в этом случае разница более чем очевидна — запуск утилиты несомненно хуже.

PHP FFI

В ПХП 7.4 предпринята очередная попытка обзавестись интерфейсом к языку Си. Кажется наконец за это расширение взялись по-серьёзному, и его поддержка не исчезнет со временем, как это уже случалось ранее с прошлыми реализациями.

Для интереса посмотрел что за АПИ получилось. В принципе, всё очень просто. Вот как выглядит функция-обёртка вокруг вызова mkdtemp с обработкой ошибок (проверял у себя на «Маке»):

/**
 * @method object mkdtemp(string $template)
 * @method object strerror(int $errnum)
 * @property-read int $errno
 */
$ffi = FFI::cdef('
    // Импортируем функцию создания временного директория
    char *mkdtemp(char *template);

    // Импортируем функцию перевода кодов ошибок в строку
    char *strerror(int errnum);

    // Импортируем переменную, хранящую коды ошибок
    int errno;
');

/**
 * Фунция для создания временных директориев по маске
 * @param string $template Шаблон создания (см. man mkdtemp)
 * @return string Путь до временного директория
 */
$mkdtemp = function (string $template) use ($ffi): string {
    $result = $ffi->mkdtemp($template);

    if ($result === null) {
        $errno = $ffi->errno;
        $errstr = $ffi->strerror($errno);
        throw new RuntimeException(FFI::string($errstr), $errno);
    }

    return FFI::string($result);
};

Как можно понять из кода, создаётся объект, в который импортируются сущности, перечисленные в сишной нотации в первом параметре. В общем случае вторым параметром требуется указать библиотеку для импорта, но в данном случае работает и так.

Не знаю заработает ли этот код без изменений под Линуксом и Виндоузом, у кого есть под рукой, посмотрите, пожалуйста.

В этом примере параметры в импортированные функции передаются как обычные типы ПХП, а некоторые возвращаемые значения требуют конвертации. Так char * возвращается как специальный объект, который нужно преобразовывать в строку вызовом метода FFI::string.

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

$template = implode(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), 'ffiphp.XXXXXX']);

try {
    $tmp = $mkdtemp($template);
    rmdir($tmp);
    echo $tmp, "\n";
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
}

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

Переход на PHP 7.4

Потихоньку тестируем на работе свой продукт под интерпретатором версии 7.4. Пока релиз не производит впечатления стабильного — в свежих версиях правятся очень неприятные баги. Мы при тестировании нашли два сюрприза, которые не описаны в списке изменений.

Во-первых, в недокументированной функции net_get_interfaces появилось новое поле — состояние сетевого интерфейса, в одном месте у нас вышло предупреждение в коде из-за этого;

Во-вторых, часть функций для работы с массивами (например, key, pos и некоторые другие), которые раньше работали с объектами класса ArrayObject внезапно перестали работать:

$arr = new \ArrayObject(['key' => 'value']);
// теперь выдаёт NULL, а не «key»
var_dump(key($arr));

Маловероятно, что кто-то ещё столкнётся с этим несовместимостями — всё-таки они очень специфичные, но всё же имейте ввиду.

Добавлено позднее: однако я неправ — про прекращение поддержки объектом класса ArrayObject функций работы с массивами вскользь всё же упомянуто.

Шило на мыло

Всё-таки я большой поклонник статической типизации в языках. Жаль, что ПХП она только-только начинает проникать.

Несколько лет назад, один из программистов написал в нашем продукте интересный код, который я сильно упростил, чтобы показать суть:

<?php
$array = '';
$array[PHP_INT_MAX] = 1;

Главное тут в том, что массив почему-то проинициализирован как строка.

Несмотря на эту странность, всё работало — ПХП на втором присваивании преобразовывал строку в массив. А какое-то время назад этот код стал валиться с нехваткой памяти. Я сегодня разбирался с этой ошибкой.

Оказалось, что в ПХП 7.1 и выше (мы недавно перешли с 7.0 на 7.2), преобразование в массив тут больше не происходит. Зачем-то одно странное поведение заменили другим — теперь в этом коде создаётся гигантская строка, состоящая из пробелов и в позицию PHP_INT_MAX записывается символ «единица». Вот память и кончается. ?

Heredoc и nowdoc в PHP

Не очень-то люблю использовать heredoc и nowdoc в ПХП из-за того, что они портят форматирование (а оно важно в больших проектах). Хотя вещь удобная — внутри можно использовать оба вида кавычек без экранирования, это бывает актуально в эскуэль-запросах.

В Перле давным-давно можно нормально смещать эту конструкцию вправо, она сама обрежет лишние пробелы, а теперь и в ПХП сделали то же — с версии 7.3 можно будет делать вот так:

$values = [<<<END
      a
     b
    c
    END];

О чудо, хвостовую конструкцию можно сдвинуть как надо и не нужно обособлять переводом строки справа! При этом внутри лишние пробелы слева урежутся по величине сдвига закрывающей конструкции. Наконец-то этим можно будет пользоваться!

Баг в PHP 7.2 с output_add_rewrite_var

Для того, чтобы не подставлять в каждую ссылку и форму глобальные для проекта параметры запроса (идентификатор сессии, например), в ПХП используется техника, называемая Url Rewriting. У нас в проекте так передаётся токен против XSRF — благо есть возможность указать собственные параметры, которые надо передать и даже задать список тегов с атрибутами в которых это будет работать.

И при переходе на ПХП 7.2 словили неприятный баг, который уже поправлен в 7.1.9, но ещё почему-то не влит дальше: если урл не содержит ничего, кроме якоря, то параметры добавляются неправильно. Код для повторения бага такой:

<?php
output_add_rewrite_var('foo', 'bar');
?>
<a href="index.php">This is link</a>
<a href="#place">This is anchor</a>

В первую ссылку «foo=bar» добавится нормально, а во второй это будет не к месту, якорь будет испорчен:

<a href="index.php?foo=bar">This is link</a>
<a href="#place/?foo=bar">This is anchor</a>

Наверняка со стандартными сессиями ПХП, передаваемыми в урле, будет тот же результат, но я не пробовал.

Либо надо ждать обновления, либо поставлять вместо якоря текущий полный урл.

Переезд на PHP7

В почте попросили описать переезд на ПХП7, с какими сложностями встретились, как они решались. Опишу, пока не забыл — я этим занимался почти в одиночку, так как задача не была приоритетной. Я считал, что мы сможем получить хороший прирост производительности, поэтому сам её и делал в свободное время.

Если кому интересно, загруженность серверов, где крутится ПХП, действительно упала, примерно в 1,5—1,7 раза.

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

Ниже мой собственный опыт.

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

Когда в табличке без поддержки остался единственный модуль ctwig от шаблонизатора «Твиг», решил, что без него мы можем обойтись — к этому времени в интернете появились замеры скорости и «Твиг» без модуля под «семёркой» демонстрировал похожие результаты с «пятёркой» и модулем.

Дальше я проверил не используются ли у нас где-то новые зарезервированные слова. Так как их использование вызовает ошибку синтаксиса, то случаи использования легко ловятся скриптом наподобие этого:

find . -type f \( -name '*.php' -o -name '*.inc' \) -exec php -l {} \; |
fgrep -v 'No syntax errors'

У нас, к слову, было несколько таких мест — например, был объявлен класс с разными полезными штуками для обработки строк, который назывался String. Так больше нельзя — зарезервированное слово.

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

У нас необработанные кодом исключения ловятся и пишутся в логи, для этого в нескольких местах кода есть перехват всего подряд. Выглядело это примерно так:

try {
    // … какой-то код
} catch (Exception $e) {
   DI::log->get('exception')->error($e);
   throw $e;
}

Из-за пространства имён такие места простым поиском найти непросто, но специализированные редакторы неплохо справляются.

Несложными регулярками поискал различные непрямые выражения, там теперь тоже есть разница, нашёл всего несколько штук и поправил.

Потом я как-то походя заметил «нотисы», связанные с модулем Memcached и оказалось, что этого модуля сменилось АПИ, а в документации об этом не слова, пришлось писать прокси-класс. Сейчас документация уже обновлена, прокси мы убрали и просто переписали затронутые места.

Далее в дело вступили тестеры и программисты — в основном были исправления, связанные с тем, что в «семёрки» некоторые вещи устарели, ничего сверх этого я не припомню.

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

Особенность PHP 7.2 (и 7.1)

Потихоньку смотрю как наш продукт запускается на ПХП версии 7.2 — у нас сейчас используется 7.0, но очень хочется двинуться дальше. В хитросплетениях кода нашёл очень странный баг интерпретатора, который был разбросан по разным строчкам кода, а в сконцентрированном виде он выглядит так:

$arr = [[1]];
array_walk($arr, function(){});
array_map('array_shift', $arr);
var_dump($arr);

В ПХП 7.0 массив выведется в неизменном виде, а в версии 7.2 (и 7.1, как оказалось) единица исчезнет. Очевидно, что array_walk создаёт какие-то ссылки внутри массива, из-за чего array_shift начинает получать внутренний массив по ссылке и сдвигать. Но никаким другим способом (например, прямым созданием массива со ссылками) мне такое поведение получить не удаётся.

ПХП и строгая типизация

В ПХП много странностей, ещё одна дала о себе знать в неожиданном месте. Сначала немного теории.

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

ПХП использует эту схему везде, кроме расширений и встроенных функций. Понятие «необязательный параметр» там есть, но обрабатывается иначе — у параметра указывается тип (например «строка»), необязательность и «нулабельность» (можно ли в этом параметре принимать null в качестве значения).

Последнее очень полезно для числовых и булевых типов — если «нулабельность» не указана, то null будет преобразован по правилам языка в значение указанного типа.

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

Например, у нас преспокойно работал примерно такой код:

public function put(Serialized $object, $eventName, $extraEventData, $uniqueId = null)
    {
        return DI::gearman_client()->doBackground(
            $this->queueName,
            igbinary_serialize(
                [
                    'object' => $object,
                    'event_data' => $extraEventData,
                    'event_name' => $eventName,
                ]
            ),
            $uniqueId
        );
    }

Всё работало корректно, пока не пришёл ПХП7 и мы не стали потихоньку переползать на строгую типизацию. Вечером я закоммитил изменения в этом файле, которые позволили включить строгую типизацию, а за завтраком поймал в логах странную ошибку, которая сообщала мне, что в метод doBackground время от времени получает в качестве последнего параметра null, а так нельзя.

Сначала я недоумевал, а потом догадался, что случилось — у doBackground последний, необязательный парамер имеет тип «строка» и он не «нулабельный». То есть в строгой типизации я его должен либо не передавать вовсе, либо передавать туда исключительно строку. А null, который передавался туда до перехода на строгую типизацию более не подходит, ибо он не строка.

Пришлось переписать более уродливо:

public function put(Serialized $object, string $eventName, $extraEventData, string $uniqueId = null)
    {
        $args = [
            $this->queueName,
            igbinary_serialize(
                [
                    'object' => $object,
                    'event_data' => $extraEventData,
                    'event_name' => $eventName,
                ]
            ),
        ];

        if ($uniqueId !== null) {
            $args[] = $uniqueId;
        }

        return DI::gearman_client()->doBackground(...$args);
    }

Странно то, что у необязательного параметра нет никакого значения по-умолчанию, которое можно было бы указать. В принципе, даже если бы оно было, это тоже не очень удобно.

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

Ранее Ctrl + ↓