Забавный баг в FFI (PHP 7.4.2)
В последнее время очень заинтересовался FFI в ПХП — интерфейсом к языку Си, появившимся в версии 7.4. Очень полезное, как по мне, нововведение, позволяющее расширять язык, обходясь минимальными знаниями о других языках программирования.
Очевидно, потребность
К несчастью, модуль FFI в ПХП
Небольшой код для иллюстрации проблемы:
const IRRELEVANT = "Hello ";
echo IRRELEVANT; // Выведет «Hello»
FFI::cdef('char *memcpy(char *dst, const char *src, size_t len);')
->memcpy("Hello ", "world!", 6);
echo IRRELEVANT; // Выведет «world!»В чём суть? Мы объявляем константу IRRELEVANT, убеждаемся, что она содержит присвоенное ей значение, потом вызываем через FFI копирование одной строки в другую, и константа внезапно меняет своё значение. Как это произошло? Чтобы понять, немного модифицируем программу.
function memcpy(string $dst, string $src):void
{
FFI::cdef('char *memcpy(char *dst, const char *src, size_t len);')
->memcpy($dst, $src, strlen($src));
}
$rock1 = str_repeat('ROCK', 1);
$rock2 = str_repeat('ROCK', 1);
var_dump($rock1, $rock2); // Выведет «ROCK» два раза
memcpy($rock1, "SOCK");
memcpy($rock2, "LOCK");
var_dump($rock1); // выведет «SOCK»
var_dump($rock2); // выведет «LOCK»Что у нас тут? Вызов memcpy обёрнут в функцию, которая ничего не возвращает и ничего не должна модифицировать — параметры передаются по значению, а не ссылке. Тем не менее, если вызвать объявленную функцию и передать в неё переменные, их значение будет изменено.
В ПХП для обеспечения производительности используется механизм копирования при записи — если
В силу этой особенности, переменные остаются ссылочными, попадая в таком виде в сишный код, где модифицируются — вызов memcpy меняет первую переданную в него переменную, копируя в неё значение второго параметра.
Кстати, если изменить строку инициализации переменных на вот такую:
$rock1 = $rock2 = str_repeat('ROCK', 1);то после вызовов memcpy обе переменные получат значение «LOCK» — работает тот же самый механизм.
Почему же в первом листинге изменилось значение константы? Сейчас разберёмся, осталось совсем немного.
Ещё одной распространённой техникой увеличения производительности (и снижения потребления памяти заодно) является интернирование, её используют многие интерпретаторы и компиляторы. В общем случае она заключается в том, что
Таким образом константа IRRELEVANT и строка «Hello » в первом параметре — суть одна и та же область памяти. Когда функция memcpy модифицирует эту область, меняются сразу все значения всего, что, для оптимальности, ссылается на это же место. Естественно, константа изменяется тоже.
Поскольку интернирование не работает во время выполнения, результат str_repeat('ROCK', 1); не интернируется и во втором листинге такого эффекта не создаётся.
Как же разорвать эту мистическую связь и заставить работать код так как задумывалось? Для этого нужно, чтобы интерпретатор создал новую область памяти, которую будет портить FFI, не затрагивая нашу переменную. Это можно сделать, например, при помощи уже упомянутой функции str_repeat:
function memcpy(string $dst, string $src):void
{
// Создаём новое место, которое испортит memcpy
$dst = str_repeat($dst, 1);
FFI::cdef('char *memcpy(char *dst, const char *src, size_t len);')
->memcpy($dst, $src, strlen($src));
}
$rock = 'ROCK';
var_dump($rock); // Выведет «ROCK»
memcpy($rock, "SOCK");
var_dump($rock); // Так же выведет «ROCK»Внимание! Это некорректный способ, корректный описан в более поздней заметке.
Естественно, подойдут и substr, и sprintf, и вообще любые функции, в общем случае возвращающие модифицированную строку. Интересно, что implode([$dst]) от бага не защищает, видимо для этого случая внутри работает
Всё описанное верно для версии актуальной на текущий момент — 7.4.2. Надеюсь, в скором времени этот довольно забавный баг устранят и необходимость в таких ухищрениях исчезнет.
Комментарии 10
strcpy("Hello ", «world!»)
Я надеялся, что это достаточно очевидно из написанной статьи. ☹️
Объяснения в посте выглядят в целом верно. Но str_repeat мог бы и не помочь. Например, если бы PHP интернировал также и результат str_repeat(…, 1). Просто такую оптимизацию никто не стал делать.
Первый параметр strcpy должен быть указателем на отдельно выделенную память достаточного размера. И в FFI есть метод для выделения памяти: FFI::new. Его и надо использовать вместо str_repeat.
https://www.php.net/manual/en/ffi.new.php
(В комментариях можно писать блоки код? Последняя моя попытка с грохотом провалилась.)
В одном из заметок у меня был вполне реальный код:/all/php-ffi .
Мог бы, но помог.
В общем случае я могу хотеть копировать куда угодно.
Нет.
Пример в этом посте полагается на внутренние детали реализации строк в PHP. Пришлось даже перебрать разные способы «перехитрить» PHP. Поэтому я настаиваю, что это не баг в PHP.
Прошлый пример FFI тоже не корректный: mkdtemp переписывает template, а не выделяет новую память. Достаточно вывести значение $template до и после вызова, чтобы в этом убедиться. Правильно — выделить память через FFI::new, скопировать туда template через FFI::memcpy, и только потом вызвать Сишный mkdtemp.
Пример в этом посте показывает баг в ПХП и даёт способчто-то с этим сделать до его исправления.
В этом и заключается баг в ПХП. Все переданные строки должны копироваться, если я не передал их в обёртку по ссылке.
FFI тут нарушает сематнику языка. В этом я вижу баг.
https://bugs.php.net/bug.php?id=79230
Другой комментатор: «I think this is a deliberate design decision of FFI.»
Мне кажется, главная проблема, что FFI магически приводит
«You should be careful, however, not to pass [pointer types] to functions expecting pointers to mutable memory. If you need mutable memory blocks, ctypes has a create_string_buffer() function which creates these in various ways.»
https://docs.python.org/3/library/ctypes.html
Да, всегда копировать PHP строки при передаче в FFI тоже решит эту проблему. Но замедлит все случаи, когда копирование не требуется.
Да, тут нужнокакое-то решение, которое приведёт семантику ПХП к семантике FFI, а сейчас это выглядит неожиданно. С си-тайпс я знаком, много работал с этим модулем в «Яндексе».
Это кажется достаточно хорошим предложением. Если добавите его комментарием в заведённый мной тикет, то это возможно поможет сдвинуть эту проблему с мёртвой точки.